Open24

ランダムをテストすること

にゃんだーすわんにゃんだーすわん

さいころを表現するクラスを作ろう。それはきっと以下のように使えるものだ。

$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回振る」のような意味だ。

このクラスのテストはどうやれば実現できるだろうか。

にゃんだーすわんにゃんだーすわん

本記事では代表的な副作用に依存するテストの例としてランダムを挙げるが、たとえば時刻や外部のHTTPリクエストも同様の問題を孕んでいる。random_int()file_get_contents('http://httpbin.org/uuid') を置き換えても本質的にはほとんど変わらない。

ということをPHPerKaigi 2021で話した。タイトルはDIコンテナを強調しているが話の半分以上は依存について言及したような気がする。

https://tadsan.fanbox.cc/posts/2061773

にゃんだーすわんにゃんだーすわん

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];
    }
}
にゃんだーすわんにゃんだーすわん

ここで問題を分割して考えよう。ランダムに依存する部分と依存しない部分に切り離してみる。

<?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クラスを用意してみる。

https://www.php.net/mt_rand

<?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()関数はメルセンヌツイスタと呼ばれる擬似乱数生成器を用いる。

https://ja.wikipedia.org/wiki/メルセンヌ・ツイスタ

にゃんだーすわんにゃんだーすわん

この実装はmt_srand()関数を使って初期状態を決定できるのが特徴だ。

https://www.php.net/manual/ja/function.mt-srand.php

<?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()は決まった値を吐き出し続ける。振る舞いが決まっている点についてランダム性が乏しいということと、検証可能であるということは表裏一体である。

にゃんだーすわんにゃんだーすわん

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);
    }
}
にゃんだーすわんにゃんだーすわん

まとめ

ここまでとってきた方法についてまとめよう。

  • 抽象クラス(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にコードを置きました。

https://github.com/tadsample/test-random

にゃんだーすわんにゃんだーすわん

この記事および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.