状態ではなく、振る舞いをモックせよ

TL;DR

  • GOOS本『実践テスト駆動開発』で触れられている「ロールをモックせよ」について、違った角度で解説
  • ドメインモデルを豊かにすることでコードがシンプルになる例

Mock Behaviors, Not States

ユニットテストを記述する際、テスト対象のオブジェクトが利用しているオブジェクト(依存オブジェクト、隣接オブジェクト)はモックオブジェクトにして、テストしたい状況をテストコード側からコントロールします。しかし、闇雲にモックを使ってテストを記述すれば良いわけではありません。今回は、モックが有効に機能するテストとはどういったものなのかを解説します。

サンプルコード

簡単なサンプルで説明します。Extract Till You Dropのモデルと近いものを使います。グループ、メンバー、およびグループリポジトリがあります。グループオブジェクトはインメモリでは所属メンバーの情報を保持しておらず、グループに所属するメンバーについての情報は、かならずグループリポジトリに問い合わせるような作りになっている、という前提とします。グループリポジトリは、次の2つのメソッドを持つインターフェイスで定義されています。

  • membersInGroup() グループに所属するメンバー一覧を取得する
  • addMemberToGroup() メンバーをグループに追加する
interface GroupRepository
{
    /**
     * @param Group $group
     * @return Member[]
     */
    public function membersInGroup(Group $group);

    /**
     * @param Member $member
     * @param Group $group
     */
    public function addMemberToGroup(Member $member, Group $group);
}

このグループにメンバーを追加するユースケースを実装し、対応するテストも実装したいとしましょう。

image

グループには「3名までしか所属できない」というビジネスルールがあります。すでに所属しているメンバーの数が3以上なら例外をスローします。

class JoinGroupUseCase
{
    /**
     * @var GroupRepository
     */
    private $groupRepository;

    /**
     * @param GroupRepository $groupRepository
     */
    public function setGroupRepository($groupRepository)
    {
        $this->groupRepository = $groupRepository;
    }

    /**
     * @param Member $member
     * @param Group $group
     * @throws \RuntimeException
     */
    public function run(Member $member, Group $group)
    {
        if (count($this->groupRepository->membersInGroup($group)) >= 3){
            throw new \RuntimeException('グループにこれ以上追加できません');
        }

        $this->groupRepository->addMemberToGroup($member, $group);
    }
}

run()メソッドで例外が発生するパスをテストするには、GroupRepositoryをモックし、membersInGroup()メソッドで3人のメンバーを返せばよいですね。

class JoinGroupUseCaseTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @var Group
     */
    private $group;
    /**
     * @var Member
     */
    private $member1;
    /**
     * @var Member
     */
    private $member2;
    /**
     * @var Member
     */
    private $member3;
    /**
     * @var Member
     */
    private $member4;
    /**
     * @var
     */
    private $groupRepository;
    /**
     * @var JoinGroupUseCase
     */
    private $SUT;

    /**
     * @test
     */
    public function 正常フロー()
    {
        $this->groupRepository->expects($this->once())
            ->method('membersInGroup')
            ->with($this->equalTo($this->group))
            ->will($this->returnValue([
                $this->member2,
            ]));
        $this->groupRepository->expects($this->once())
            ->method('addMemberToGroup')
            ->with($this->equalTo($this->member1), $this->equalTo($this->group));

        $this->SUT->run($this->member1, $this->group);
    }

    /**
     * @test
     * @expectedException   RuntimeException
     */
    public function 例外フロー()
    {
        $this->groupRepository->expects($this->once())
            ->method('membersInGroup')
            ->with($this->equalTo($this->group))
            ->will($this->returnValue([
                $this->member2,
                $this->member3,
                $this->member4,
            ]));
        $this->groupRepository->expects($this->never())
            ->method('addMemberToGroup');

        $this->SUT->run($this->member1, $this->group);
    }

    protected function setUp()
    {
        $this->groupRepository = $this->getMock(GroupRepository::class);
        $this->group = $this->getMock(Group::class);
        $this->member1 = $this->getMock(Member::class);
        $this->member2 = $this->getMock(Member::class);
        $this->member3 = $this->getMock(Member::class);
        $this->member4 = $this->getMock(Member::class);

        $this->SUT = new JoinGroupUseCase();
        $this->SUT->setGroupRepository($this->groupRepository);
    }
}

image

これでユニットテストを記述できました。テストもパスします。しかしこれでは、テストコードの面からも、ドメインオブジェクトの面からも望ましくない点があります。

  • グループのメンバーがすでに一杯かどうかに関するビジネスルールが、個別ユースケースに埋もれている
  • テストコードが、グループが一杯かどうかに関する詳細なルールに依存している
  • 単純に、モックしているオブジェクトが多い

テストしたいパスのための前提条件を整えるために、複雑なオブジェクトの組み合わせ全体=オブジェクトの状態をモックにより再現しているということです。これは、モックが複雑になるばかりでなく、脆いテストにもなってしまいます。

条件をメソッドに抽出することで抽象化

問題を改善するための最初のステップは、ビジネスルールを表していた部分をメソッドに抽出して明示的に表現することです。今回は「グループには3人までしか追加できない」というルールでした。グループにメンバーを追加するユースケースで知りたいのは、グループに所属しているメンバーの人数そのものではなくて、「追加できるのかどうか」つまり「グループが一杯かどうか」ということです。これを直接表すように、リポジトリのメソッドとして groupIsFull() を追加します。

interface GroupRepository
{
    ...

    /**
     * @param Group $group
     * @return bool
     */
    public function groupIsFull(Group $group);
} 

このメソッドを使って条件を判定するようにユースケースメソッドを変更します。

class JoinGroupUseCase
{
    ...

    /**
     * @param Member $member
     * @param Group $group
     * @throws \RuntimeException
     */
    public function run(Member $member, Group $group)
    {
        if ($this->groupRepository->groupIsFull($group)){
            throw new \RuntimeException('グループにこれ以上追加できません');
        }

        $this->groupRepository->addMemberToGroup($member, $group);
    }
} 

テストコードでは、GroupRepository#groupIsFull()メソッドをモックすることで前提条件をシンプルに制御できるようになります。

class RefinedJoinGroupUseCaseTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @var Group
     */
    private $group;
    /**
     * @var Member
     */
    private $member1;
    /**
     * @var
     */
    private $groupRepository;
    /**
     * @var JoinGroupUseCase
     */
    private $SUT;

    /**
     * @test
     */
    public function 正常フロー()
    {
        $this->groupRepository->expects($this->once())
            ->method('groupIsFull')
            ->with($this->equalTo($this->group))
            ->will($this->returnValue(false));
        $this->groupRepository->expects($this->once())
            ->method('addMemberToGroup')
            ->with($this->equalTo($this->member1), $this->equalTo($this->group));

        $this->SUT->run($this->member1, $this->group);
    }

    /**
     * @test
     * @expectedException   RuntimeException
     */
    public function 例外フロー()
    {
        $this->groupRepository->expects($this->once())
            ->method('groupIsFull')
            ->with($this->equalTo($this->group))
            ->will($this->returnValue(true));
        $this->groupRepository->expects($this->never())
            ->method('addMemberToGroup');

        $this->SUT->run($this->member1, $this->group);
    }

    protected function setUp()
    {
        $this->groupRepository = $this->getMock(GroupRepository::class);
        $this->group = $this->getMock(Group::class);
        $this->member1 = $this->getMock(Member::class);

        $this->SUT = new JoinGroupUseCase();
        $this->SUT->setGroupRepository($this->groupRepository);
    }
}

image

修正したユースケースメソッドとテストコードからは、グループが一杯かどうかについての判定の詳細が現れていません(3人なのかどうか)。条件そのものをメソッドに抽出したことで一段階抽象化が行われ、プロダクションコードからは抽象的に表現された条件を使うようになりました。ユースケースのテストコードでは、各条件の詳細に立ち入ることなく、抽象的に前提条件を整えるだけでよくなり、テストしたいフローに集中できます。これにより、グループが一杯かどうかという条件の中身が変更されても、ユースケースのプロダクションコードとテストコードを変更する必要はありませんし、テストが壊れることはなくなります。また、プロダクションコードもテストコードもシンプルになります(モックするオブジェクトも少なくなります)。

まとめ

ユビキタス言語を反映してドメインオブジェクトを豊かに育てると、プロダクションコードもテストコードもシンプルになります。壊れにくい頑強なドメインモデルとテストを作っていくことができます。DDDはこのようにコードをシンプルにする効果があると言えます。

参考