ランダムをテストすること
さいころを表現するクラスを作ろう。それはきっと以下のように使えるものだ。
$dice = new Dice();
var_dump($dice->roll('3d6'));
// [5, 1, 2]
var_dump($dice->roll('5d6'));
// [4, 6, 1, 2, 4]
var_dump($dice->roll('3d10'));
// [3, 3, 2, 10, 4, 1, 7, 1, 9, 4]
引数の"3d6"
のような文字列は「6面ダイスを3回振る」のような意味だ。
このクラスのテストはどうやれば実現できるだろうか。
この記事はPHPUnit テストコードの書き方【入門】 - RAKUS Developers Blog | ラクス エンジニアブログにインスパイヤされて書いたが、この仕様の差は特に意味はない。ただの私の趣味だ。
本記事では代表的な副作用に依存するテストの例としてランダムを挙げるが、たとえば時刻や外部のHTTPリクエストも同様の問題を孕んでいる。random_int()
とfile_get_contents('http://httpbin.org/uuid')
を置き換えても本質的にはほとんど変わらない。
ということをPHPerKaigi 2021で話した。タイトルはDIコンテナを強調しているが話の半分以上は依存について言及したような気がする。
Dice
クラスを愚直に書いてみよう。
<?php declare(strict_types=1);
namespace Tadsample\Random;
class Dice
{
/**
* @param positive-int $max
* @return positive-int
*/
public function random(int $max): int
{
return random_int(1, $max);
}
/**
* @return list<int>
*/
public function roll(string $n): array
{
[$d, $max] = $this->parse($n);
$roll = [];
for ($i = 0; $i < $d; $i++) {
$roll[] = $this->random($max);
}
return $roll;
}
/**
* @return array{0:positive-int,1:positive-int}
*/
protected function parse(string $n): array
{
if (!preg_match('/\A(?<d>[1-9][0-9]*)d(?<max>[1-9][0-9]*)\z/ui', $n, $matches)) {
throw new DomainException('数は "1d6" の形式で指定すること');
}
$d = filter_var($matches['d'], FILTER_VALIDATE_INT);
$max = filter_var($matches['max'], FILTER_VALIDATE_INT);
assert(is_int($d) && 0 < $d);
assert(is_int($max) && 0 < $max);
return [$d, $max];
}
}
この実装の肝の random_int()
は暗号論的にランダムな乱数を返す関数だ。
ここで問題を分割して考えよう。ランダムに依存する部分と依存しない部分に切り離してみる。
<?php declare(strict_types=1);
namespace Tadsample\Random;
use DomainException;
use function assert;
use function filter_var;
use function is_int;
use function preg_match;
use const FILTER_VALIDATE_INT;
abstract class AbstractDice
{
/**
* @param positive-int $max
* @return positive-int
*/
abstract public function random(int $max): int;
/**
* @return list<int>
*/
public function roll(string $n): array
{
// 実装省略 (Diceと同じ)
}
/**
* @return array{0:positive-int,1:positive-int}
*/
protected function parse(string $n): array
{
// 実装省略 (Diceと同じ)
}
}
この実装の肝はもちろん抽象メソッドだ。
/**
* @param positive-int $max
* @return positive-int
*/
abstract public function random(int $max): int;
それ以外は前の実装とまったく変らない。
このように抽象メソッド、あるいはインターフェイスに切り出すことで具体的な処理を継承(実装)した具象クラスに完全に委任できる。つまり、テストのためにはrandom()
という名前に反して規則的な値を返したっていいのだ。
この状態の AbstractRandom
のテストはこうすればいい。
<?php declare(strict_types=1);
namespace Tadsample\Random;
use function range;
class AbstractDiceTest extends TestCase
{
public function test(): void
{
$subject = new class extends AbstractDice {
private $count = 0;
public function random(int $max): int
{
return ($this->count++ % $max) + 1;
}
};
$expected = [
[1],
range(2, 11),
[6, ...range(1, 6), ...range(1, 3)],
];
$actual = [
$subject->roll('1d10'),
$subject->roll('10d100'),
$subject->roll('10d6'),
];
$this->assertSame($expected, $actual);
}
}
無名クラスの random()
メソッドはカウンタを加算しながらさいころの最大の目を超えないように連番の数を返し続けるだけだ。
次に、mt_rand()
に依存したMtDice
クラスを用意してみる。
<?php declare(strict_types=1);
namespace Tadsample\Random;
use function mt_rand;
class MtDice extends AbstractDice
{
/**
* @param positive-int $max
* @return positive-int
*/
public function random(int $max): int
{
return mt_rand(1, $max);
}
}
このmt_rand()
関数はメルセンヌツイスタと呼ばれる擬似乱数生成器を用いる。
この実装はmt_srand()
関数を使って初期状態を決定できるのが特徴だ。
<?php declare(strict_types=1);
namespace Tadsample\Random;
use function mt_srand;
class MtDiceTest extends TestCase
{
/**
* @runInSeparateProcess
*/
public function test(): void
{
mt_srand(1);
$subject = new MtDice();
$expected = [
[6],
[40, 25, 69, 64, 14, 92, 42, 60, 33, 49],
[4, 5, 6, 2, 1, 1, 4, 3, 5, 1],
];
$actual = [
$subject->roll('1d10'),
$subject->roll('10d100'),
$subject->roll('10d6'),
];
$this->assertSame($expected, $actual);
}
}
mt_srand()
に決まった値を与えると、mt_rand()
は決まった値を吐き出し続ける。振る舞いが決まっている点についてランダム性が乏しいということと、検証可能であるということは表裏一体である。
以下のコードを見ると mt_srand(1)
によって上記コードと同じ番号が吐き出されることを確認できる。
ここでテストは別プロセスで分離して実行している。
/**
* @runInSeparateProcess
*/
mt_srand()
はグローバルな状態に影響を及ぼすため、万一にも影響が漏出しないようにしている。
MtDice
はクラス自体がグローバルな状態に依存してしまっている。
次は乱数生成をオブジェクトに分割してみよう。
<?php declare(strict_types=1);
namespace Tadsample\Random;
class RandomizerDice extends AbstractDice
{
/** @var RandomizerInterface */
private $randomizer;
public function __construct(RandomizerInterface $randomizer)
{
$this->randomizer = $randomizer;
}
/**
* @param positive-int $max
* @return positive-int
*/
public function random(int $max): int
{
return $this->randomizer->randomInt(1, $max);
}
}
RandomizerInterface
はこれだけだ。
<?php declare(strict_types=1);
namespace Tadsample\Random;
interface RandomizerInterface
{
public function randomInt(int $min, int $max): int;
}
テストの仕方はAbstractDiceTest
とほとんど同じでいける。
<?php declare(strict_types=1);
namespace Tadsample\Random;
use function range;
class RandomizerDiceTest extends TestCase
{
public function test(): void
{
$randomizer = new class implements RandomizerInterface {
private $count = 0;
public function randomInt(int $min, int $max): int
{
return (($this->count++ + $min - 1) % $max) + 1;
}
};
$subject = new RandomizerDice($randomizer);
$expected = [
[1],
range(2, 11),
[6, ...range(1, 6), ...range(1, 3)],
];
$actual = [
$subject->roll('1d10'),
$subject->roll('10d100'),
$subject->roll('10d6'),
];
$this->assertSame($expected, $actual);
}
}
本番で利用するときはこういうRandomizerを仕込んでやると良いだろう。
<?php declare(strict_types=1);
namespace Tadsample\Random;
class GlobalRandomizer implements RandomizerInterface
{
public function randomInt(int $min, int $max): int
{
return random_int($min, $max);
}
}
余談だが、オブジェクトスコープ乱数の問題はzeriyoshiさんが関心を持っており、RFCにも取り組んでいる。
GlobalRandomizer
はグローバル関数に依存してしまっているのだが、savvot/random: Deterministic pseudo-random generators libraryやPECL :: Package :: orngを利用すればオブジェクトスコープな乱数を利用できる。
まとめ
ここまでとってきた方法についてまとめよう。
- 抽象クラス(abstract)は乱数に依存せず、実装クラスを乱数に依存させる
-
random_int()
ではなくmt_rand()
に依存することで、mt_srand()
で制御可能にする - 乱数生成を
RandomizerInterface
として定義し、抽象に対して依存させる
実際のアプリケーションでどうやって依存を分離するかは好みによるだろう。
裏技
では、最初のDice
クラスやGlobalRandomizer
クラスに対するユニットテストは不可能なのだろうか? uopzのようなC拡張などを使って無理やり関数定義を上書きすればいけるだろうか?
実際このテストケースで無理やりにテストを実装することが可能だ。
<?php declare(strict_types=1);
namespace Tadsample\Random;
class RandomDiceTest extends TestCase
{
/**
* @runInSeparateProcess
*/
public function test(): void
{
function random_int(int $min, int $max): int
{
static $count = 0;
return (($count++ + $min - 1) % $max) + 1;
}
$subject = new RandomDice();
$expected = [
[1],
range(2, 11),
[6, ...range(1, 6), ...range(1, 3)],
];
$actual = [
$subject->roll('1d10'),
$subject->roll('10d100'),
$subject->roll('10d6'),
];
$this->assertSame($expected, $actual);
}
}
PHPにおいてグローバル関数は上書きできない。だが考えてほしい、名前空間にグローバル関数と同名の関数を定義することは許されている。
クラスは明示的にuse
しない限り同じ名前空間に属すると仮定するが、関数は同じ名前空間を検索して存在しなければグローバル関数にフォールバックする。つまりテスト時だけグローバル関数と同名の関数を定義してやることでテストが可能だという、ひどい裏技である。
メソッドの中で名前付きのfunction
を書くと関数定義されるがnamespace
は反映される。
また、 @runInSeparateProcess
で分離することで複数回呼び出しても問題ない。
強調するが、これはただのひどいハックであって一般的に推奨できるテクニックではない。
そんなわけでGitHubにコードを置きました。
この記事およびtadsample/test-randomのコードは好きに扱って構わない。
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.