WebエンジニアのLoL日記

LoLをプレイしたりLJLの試合を見たりするのが好きなエンジニア。LoLのイベントやパッチノートなど気になった点を記事にしたり、LJLについの記事をかいたりしています。某社でWeb系のエンジニアとして働いているので、技術系の記事もたまに書きます。コンタクトを取りたい場合はtwitterまで。

PHPのモックフレームワークMockeryでprotectedメソッドをモックする

f:id:yoshiki_utakata:20181004212834j:plain

PHPのモックフレームワークでprotectedメソッドをモックしたくなる場合があります。例えば、

<?php

/**
 * テスト対象のクラス
 */
class Repository {

  private $database;

  /** コンストラクタDI */
  public function __construct($database) {
    $this->database = $database;
  }

  public function save($title, $description) {
    $createdAt = $this->now();
    $database->store($title, $description, $createdAt);
  }

  protected function now() {
    return now();
  }
}

このようなクラスがあったとします。そして、Mockeryを使って以下のようなテストを書きたいです。

<?php

$databaseMock = Mockery:mock(Database::class);
$databaseMock->souldReceive('store')->once()
  ->with('タイトル', '説明', $今の時間);

しかしここで問題が起こります。 $createdAtnow() が入るので、テストの実行時間によって変わってしまいます。なので簡単にアサーションできません。

今回は now() という単純な内容なので、 $createdAt に関してはチェックしない、という方法もありますが、実際にはチェックしたくなる場面は出てくるでしょう。そういった場合に、以下のようにテスト対象クラスのパーシャルモックを作成し、protectedメソッドを上書きすることができます。

<?php

$now = 適当な時間
$databaseMock = Mockery:mock(Database::class);
$databaseMock->souldReceive('store')->once()
  ->with('タイトル', '説明', $now);

$target = Mockery::mock(Repository::class, [$databaseMock])
  ->makePartial() // パーシャルモック(一部のメソッドだけを置き換える)とする
  ->shouldAllowMockingProtectedMethods(); // これでprotectedがモックできる

$target->shouldReceive('now')->andReturn($now);

$target->save('タイトル', '説明');

本当にこのテスト方法でいいのかという話

ただ、やはりこのテストはあまり良くありません。テスト対象のクラスをパーシャルモックにしているのであまり良くないというのもありますが、一番大きいのは「なぜかオーバーライド可能なnowメソッドができてしまう」という点です。また、このようなクラスを作るたびにnowメソッドが生えてしまうのもなんか微妙ですよね。

そこで、今回であれば「時刻」なので、時刻関連を扱うライブラリを導入するのが良いかと思います。例えば CarbonChronos といったライブラリです。

どちらのライブラリも「テスト用に now を固定する機能は持っていると思うので、

<?php

/**
 * テスト対象のクラス
 */
class Repository {

  private $database;

  /** コンストラクタDI */
  public function __construct($database) {
    $this->database = $database;
  }

  public function save($title, $description) {
    $createdAt = Carbon::now();
    $database->store($title, $description, $createdAt);
  }
}
<?php

$now = 適当な時刻;
Carbon::nowを固定($now);

$databaseMock = Mockery:mock(Database::class);
$databaseMock->souldReceive('store')->once()
  ->with('タイトル', '説明', $now);

$target =new Repository($databaseMock);
$target->save('タイトル', '説明');

こうなり、テストも本体のコードもシンプルになるかと思います。

Mockeryがprotectメソッドをデフォルトでモックできないようになっているのにもそれなりの理由がありますので、できれば shouldAllowMockingProtectedMethods を使わない形でテストを書いたほうが良いでしょう。

参考

パーフェクトPHP

パーフェクトPHP