じぶん対策

日々学んだことをアウトプットして備忘録にしています。

ユニットテストについて考えてみる。PHPUnitでモックを使用して小さいユニットテストを書く。

はじめに

前回の記事でユニットテストに関する知識を調査したのでよければ参照してください。

今回の目的

方針

テスト対象以外のコードは全てモック化してテスト

方針であり結論です。今回の目的を達成するためにはテスト対象以外を全てモック化してテストします。
ユニットテストではテスト対象の下位のコードをモック化せずに本物を使用することもできますが、今回は全てモック化します。
本来のユニットテストの定義としてはテスト対象以外モックにするのが正しいようですが、実際問題モックを用意せず本番コードを利用してテストを書いているプロジェクトがありました。
モック化した場合としなかった場合それぞれのメリット、デメリットについても考えてみます。
クリーンアーキテクチャの利点を最大限活かせるのがこの方針だと考えています。
また、テスト駆動開発についてもあわせて考えます。

テスト対象以外を全てモック化するべき理由

テストスコープを絞り込むことでテストの失敗箇所を明確にする

まず考えられる一番の理由は、テストのスコープを絞ることにあります。
テスト対象以外に実際のコードを使用した場合、テストがFailedになった際にテスト対象に問題があるのか、テスト対象から呼び出されたコードに問題があるのかを調査しなければなりません。
テスト対象から呼び出される外部のコード全てをモックにしていた場合、テストが通らなければテスト対象に問題があることが明らかです。
また、このユニットテストリファクタリングにおいても効果を発揮します。
プロジェクト全体に対してユニットテストを実行し、そのFailedの件数がバグの件数と等しくなります。
リファクタリングによってどれだけのクラスに影響が出たのか一目でわかります。

テストコードを仕様書にできる

たとえばテスト対象以外を意識してテストを書く場合、以下のような形になります。

テストケース

public function testUseCase(): void
{
    $entity = new Entity(
        'testData'
    );
    $id = 'testId'
    $repository = $this->app->make(RepositoryInterface::class);
    // あらかじめDBに保存しておく必要がある
    $repository->save($entity);

    //テスト対象関数の呼び出し
    $useCase->process();

    $repository->delete($id); // テストに使用したデータを削除する
}

Repositoryクラス

class Repository{
    public function findById(Id $id){
        // $idを使ってEntityを取得してくる処理
    }
    public funciton save(Entity $entity){
        // 渡されたEntityを保存する処理
    }
}

これではテストの前提条件がテスト用のドライバの外部に漏洩します。
こうなっていればまだマシでseederを利用してテストを書いたりできます。
また、delete関数が実装されていない場合もありますし大抵論理削除だと思うので直接DBを操作する必要が出てくるかもしれません。
クリーンアーキテクチャなのにデータの保存先を意識したテストを書かないといけないのは不自然です。

データの保存先を意識しながらテストを書くのは統合テストの時にしておきましょう。

単体テストの理想はテストケースに必要な情報全てがドライバ内に記載されていることです。
テストケースを見るだけでそのクラスがどんな振る舞いをするのかがわかるようなテストケースにします。
するとテストコードで仕様を表現でき、仕様書によくある実装との乖離も起こさないようにできます。
テストが通らないとリリースされませんから。
イメージはこんな感じ

public function testUseCase(): void
{
    $expected = 'hoge'; // 期待値を用意
    $input = 'input'; // 入力値を用意

    $output = $useCase->process($input); // テスト対象クラスの呼び出し

    $this->assertSame($expected, $output); // 実行結果の確認
}

ただ、先ほどのRepositoryの例のようにインプットはidを渡すが、実質的にはRepositoryから取得するEntityがインプットとなるような処理が考えられます。
そこで登場するのがモックです。DBにアクセスすることなくインプットをテストケースに書く方法をこの後考えていきます。

テストの実行時間の問題を解決する

テスト対象がDBにアクセスするような場合、テスト用DBを用意すると思います。
例えばRepositoryパターンなどを採用するでしょう。
こういったパターンの時により上位のUseCaseなどからRepositoryパターンを使用するたびにテスト用DBにアクセスすると単純にテストの実行に時間がかかります。
本来テストにおいて担保したいことは以下の二つに分けられるはずです。

  • Repositoryが適切にDBを操作できること
  • UseCaseが処理した結果を確認すること

UseCaseが処理した結果を確認するたびにRepositoryを操作してテスト用DBに保存し、テストケース(ドライバ)側でRepositoryからデータを取り出してそれを確認する、といった手順を踏んでいるとDBアクセスの時間が嵩みます。
テストの実行時間については開発規模が小さなければ問題となりませんが、規模の大きさに実行時間は比例するので早い段階で対策しておくべきです。
先ほどあげた担保したいことは、それぞれ責務を分けてテストケースを書きます。

ここではUseCaseについて確認します。 簡単な例をPHPUnit、Mockeryといったテスティングフレームワークを用いて書いてみます。

今回の例では、Repositoryから、findByIdという関数でEntityを取得する部分をMockとします。

public function testUseCase(): void
{
    // Repositoryから返してほしいEntityを準備します。
    $entity = new Entity(
        'testData'
    );

    // RepositoryのMockを生成します。
    $repositoryMock = Mockery::mock(Repository::class, RepositoryInterface::class);

    // Repositoryの振る舞いを設定します。
    $repositoryMock->shouldReceive('findById') // findById関数が呼ばれた時の振る舞いを設定します。
        ->once() // 呼び出し回数の設定
        ->andReturn($entity); // 返り値を指定できます。ここでは先ほど用意したEntityクラスのインスタンスを返します。

    // Repositoryに$repositoryMockをDIします。
    $this->app->instance(RepositoryInterface::class, $this->repositoryMock);

    // RepositoryのDI後にUseCaseもDIします。
    $useCase = $this->app->make(UseCaseInterface::class);

    //テスト対象関数の呼び出し
    $useCase->process();

    // この後何かしら処理の結果をassertします。
}

processの中でRepositoryクラスのfindByIdメソッドを使用した場合に返ってくる値をテストケースから指定できました。
また、findByIdクラスの呼び出しが1回ではない場合にエラーを吐いてくれるようにもなりました。

こうすることでRepositoryのテストではDBへのアクセスを担保し、UseCaseでは適切にRepositoryに値が渡っていることをテストを分けて担保できます。
また、このように分割することでRepositoryにバグがあったり、Repositoryをまだ作成していない場合でもUseCaseのテストが書けます。

テストは抽象に対して書きたいという話

先ほどの例のようにthis->app->instance()を用いると依存解決の際に生成済みのインスタンスを使用してくれるようになります。
これを用いることで DIを利用して環境ごとに実行されるコードを切り替えるような設計 の場合のテストができます。
テストで担保したい動作はあくまで振る舞いなので抽象に対して書きます。
つまり、テストを具象クラスに依存させないように書くことができるということです。
Laravelのサービスプロバイダを利用して下記の部分で実現しています。

// Repositoryに$repositoryMockをDIします。
$this->app->instance(RepositoryInterface::class, $this->repositoryMock);

// RepositoryのDI後にUseCaseもDIします。
$useCase = $this->app->make(UseCaseInterface::class);

これができると、同じUseCaseInterfaceを実装した別のUseCaseに対してもコードを変えずにテストを行うことができます。
サービスプロバイダで切り替えるようにするだけですね。

コードを書く順番を変えられる

特にテスト駆動開発をする場合、実装順を任意に変更できることはメリットとなります。
クリーンアーキテクチャを採用する最大のメリットは交換容易性だと考えています。
交換可能なのにRepositoryから書かないといけないのは不自然ですよね。

例えばフロントエンドが完成していない状態でバックエンドを書きたい、であるとか、
具体的なDBはまだ選定していないがコードを書きたい場合などです。
この辺りの話は下記記事でも語られていますし、Youtubeでもお話しされていました。
https://qiita.com/nrslib/items/a5f902c4defc83bd46b8

テスト対象以外をモック化するという今回の方針を採らない場合のことを考えてみましょう。
クリーンアーキテクチャの考え方に従うと、Entityがもっとも重要で、一番初めに書きます。
外部に依存しないのでこの部分は問題ないと思います。

次にユースケースを書く場合を考えます。
その時に考えることはそのユースケースで何をやりたいかということですがRepositoryやServiceを利用する場合、一旦ユースケースの実装を中断して実装する必要があります。部品に依存してしまっているためですね。
部品が実装されている状態でないと実装を進めることができない状態です。

クリーンアーキテクチャはモック化しやすい構造になっています。
違う層にドメイン知識が漏洩せず、閉じられる範囲で各層があります。
モックにすれば、ユースケースを実装する際に必要となる部品の振る舞いをテストケースに書くことができます。
このテストケースを使用すると部品の実装をすることなくユースケースの実装を進めることができます。
つまり、クリーンアーキテクチャにおける依存の方向性を内側に向けることができるようになります。

テスト駆動設計しやすい

テスト駆動開発という開発手法があります。
この時のテストはバグを検出するためのものではなく、開発における動作目標という意味合いが強いです。
つまり正常に実装することを目標として、

テストを書く
↓
テストが通るようにコードを書く(汚くてもテストさえ通れば良い)
↓
テストが通る状態を維持しながらコードを綺麗にする

のサイクルを回して開発を行います。
C0カバレッジ100%を目指すユニットテストと、テスト駆動開発におけるテストは別物だと認識しています。

つまり、テスト駆動設計の時にまず用意するのは正常系のテストに必然的になると考えています。
もちろんその時点で例外処理であったりを用意できるのが理想ですが、大抵の場合は正常系だと思います。

ここでの僕の主張はテスト駆動開発における正常系のテストを書いて先述したサイクルを回す際に別のクラスの影響を受けるべきではないということです。
開発対象であるクラスのみ正常に動作することを担保するべきです。
そのためにテスト対象以外をモック化します。

例外処理のテストが楽に書ける

上記は設計の問題でしたが、それ以外にも副次的なメリットがあります。
テスト対象コードの下位に本物を使っている場合に例外のテストをしようと考えると以下のように例外が起こるパターンの引数を作成して渡す必要があります。
もちろんこれはこれで正しいのですが、このユニットテストで担保したいことは下位のコードで投げられたエラーを適切にハンドリングできているかであり、下位のコードの中身まで意識する必要はないです。
下位のコードに何を渡せば例外が投げられるかは下位のコードのテストで担保します。
なのでここでのテストではモックを利用して例外を投げてしまえばいいという考え方です。

また簡単な例を書いてみます。

今回の例では、Repositoryから、saveという関数でEntityを保存する部分をMockとします。 そのsaveメソッドが何らかの理由でRuntimeExceptionを投げ、それをUseCaseで適切にエラーハンドリングできているかをテストします。 saveから投げられたRuntimeExceptionEntityFailedSaveExceptionという自作例外を投げるようにハンドリングしてみます。

public function testUseCase(): void
{
    // RepositoryのMockを生成します。
    $repositoryMock = Mockery::mock(Repository::class, RepositoryInterface::class);

    // Repositoryの振る舞いを設定します。
    $repositoryMock->shouldReceive('save') // save関数が呼ばれた時の振る舞いを設定します。
        ->once() // 呼び出し回数の設定
        ->andThrow(new RuntimeException()); //save関数が呼ばれると必ず例外を投げます。

    // Repositoryに$repositoryMockをDIします。
    $this->app->instance(RepositoryInterface::class, $this->repositoryMock);

    // RepositoryのDI後にUseCaseもDIします。
    $useCase = $this->app->make(UseCaseInterface::class);

    //テスト対象関数の呼び出し
    $useCase->process();

    $this->expectException(EntityFailedSaveException::class);
}

このようにsave関数の振る舞いを決めることができます。
モック化しなかった場合、Repositoryのsave関数にどんな値が渡された時にエラーになるか調査してテストデータを用意する必要があります。
このテストで何を担保したいのかを考えるのが重要だと思います。

Factoryはどう扱うか

あくまで単体としてテストを実行するのが理想ですが、テストケースに記述する量は単純に増えます。
この問題を解決するために、Factoryを利用しても良いのかどうかを最近悩んでいます。
現時点での僕の意見では利用は避けたいと考えています。
理由としては二つあって、

  • テストケースの期待するEntityをFactoryで用意できるとは限らないこと
  • テストケースの情報がFactoryに依存すること(外部に依存する)を避けたい

からです。
Entityによってはかなり記述量が増えますが、setUp等の関数を利用してDRYにする方がテストとしては望ましいと思っています。

まとめ

  • テストコードを仕様書にする
  • テストの実行時間の改善
  • テストスコープを絞ることによってテスト失敗箇所を明確にする
  • テスト駆動開発しやすくする

上記に挙げたような理由から単体テストは単体としてテストできるように書いた方がいい。
DBにきちんと保存できているか等のテストは統合テストとして上記とは分けて考えるべき。
統合テストがあるからといって単体テストを省略するのはよくない。

テストの考え方的には

単体テストをクリア
↓
統合テストをクリア
↓
システムテストをクリア

の流れにすることでテスト失敗した時にどこで失敗したかが一目瞭然になる。

特にテスト駆動開発の際にいきなり統合テストを書くのは避けたい。

理由は

  • テスト駆動の時に意識するのは開発対象だけにしないとサイクルを回せない(他のクラスの影響を受けるとテスト駆動開発の目的から外れる)
  • テストデータを用意するのが大変(他のクラスのためのテストデータが必要になることがある)

所感

今回の記事は考えていることをまとめるような記事でした。

ユニットテストは最低限開発者が用意するべきテストなのでここをしっかり書くことでバグを大きく減らすことができます。
プログラミングというのは小さな部品をたくさん作って組み合わせていく考え方がベースとなっていると思っていて、ユニットテストもそうあるべきです。
また、このようにきっちりとユニット範囲のスコープを絞ることでユニットテストも書きやすく、テスト駆動開発しやすくなります。

ペアプログラミング等においても、ナビゲータがテストを書き、それを通すようなコードをドライバが実装し、また役割を入れ替えて書いていくといったことがしやすくなります。これは実装順を自由に変更できるというクリーンアーキテクチャのいいところです。

以前phpDocumentorについての記事を書きましたが、アジャイルの場合はできる限りの情報はコードで表現したいと考えています。
仕様書を作らない言い訳になる状態は良くないと思っていますが、仕様書がなくても困らないクオリティのテストを書くことが理想です。
個人的にユニットテストについて色々考える中で、クリーンアーキテクチャの効果を実感し理解が進みました。