いきなりですが、PHPでオブジェクトの比較をするのって面倒ではないですか?
かといってバグ対応の方が面倒なのでテストをサボらずにしぶしぶ書く、そんな毎日でした。
そこで、厳密なオブジェクトの比較を簡単にできないかと考え解決策に至るまでの道のりの話です。
面倒な例
<?php
class Foo {
private $bar;
private $baz;
public function __construct($bar, $baz)
{
$this->bar = $bar;
$this->baz = $baz;
}
public function getBar()
{
return $this->bar;
}
public function getBaz()
{
return $this->baz;
}
}
戻り値のチェック
タイプ量が多いのでまあまあ面倒です。
PHPのテストは本当に手が疲れます。
<?php
$fooList = $service->doSomething();
self::assertCount(3, $fooList);
// プロパティが多いほどタイプ量が増える
self::assertEquals('bar1', $fooList[0]->getBar());
self::assertEquals('baz1', $fooList[0]->getBaz());
self::assertEquals('bar2', $fooList[1]->getBar());
self::assertEquals('baz2', $fooList[1]->getBaz());
assert
メソッドを定義するのは機能としてイマイチ
誰もがメソッドにまとめた事があるかと思いますが、あまり便利じゃありませんよね?
値の種類(?)が増えると引数を増やすしかなく、早々にやめました。
<?php
$fooOrNull = $service->doSomething();
// 例えばnullableになったとした場合、フラグを足さない限り`assert`メソッドにまとめられない
self::assertNotNull($fooOrNull);
self::assertFoo('bar1', 'bar2', $fooOrNull);
// それともフラグを追加する?
self::assertFoo('bar1', 'bar2', $allowNull = false, $fooOrNull);
引数のチェック
複数回コールされる場合はもはやテストなのかすら怪しいですね。
<?php
$service
->expect(self::exactly(2))
->method('doSomething')
->with(self::callback(function (Foo $foo) {
if ('bar1' === $foo->getBar()) {
self::assertEquals('baz1', $foo->getBaz());
} else {
self::assertEquals('baz2', $foo->getBaz());
}
}));
$service->doSomething(new Foo('bar1', 'baz1'));
$service->doSomething(new Foo('bar2', 'baz2'));
労力の割にメリットがないので、妥協できる範囲でサボりますよね・・・
面倒くさがってサボる
結局、最低限しかテストできない状態に。
<?php
// 間違ってはいないが・・・
$service
->expect(self::exactly(3))
->method('doSomething')
->with(self::isInstanceOf(Foo::class))
解決策(ボツ): 制約(Constraint)を定義する
特定のオブジェクトに対するPHPUnitの制約を定義しておく事で、手軽さと厳密さを両立する事が出来ました。
比較ロジック自体がPHPUnitの資産をあまり有効活用できていない点や、比較処理の実装が必要になる点はイマイチですね。
<?php
pubic function test()
{
// 比較が複数回必要でもタイプ数は少なめ
self::assertThat($fooList[0], isFoo('bar1', 'baz1'));
self::assertThat($fooList[1], isFoo('bar2', 'baz2'));
// 引数の比較がそれらしい方法で実現できる
$this->service
->expect(self::exactly(2))
->method('doSomething')
->with(self::logicalOr(
self::isFoo('bar1', 'baz1'),
self::isFoo('bar2', 'baz2')
));
// 比較の柔軟性が高い
$fooOrNull = $this->doSomething();
self::assertThat(self::isNullableFoo('bar', 'baz'), $fooOrNull);
}
private static function isFoo($bar, $baz)
{
return self::logicalAnd(
self::isInstanceOf(Foo::class),
self::callback(function (Foo $foo) use ($bar, $baz) {
// 比較方法は自前実装なので、テストとしてはPHPUnitの比較には劣る
return $bar === $foo->getBar() && $baz === $foo->getBaz();
})
);
}
private static function isNullableFoo($bar, $baz)
{
return self::logicalOr(
self::identialTo(null),
self::isFoo($bar, $baz)
);
}
他にも、テストに失敗した場合のメッセージが親切でない(何と比較したか表示できない)ので、テストに失敗した時はまあまあ最悪です。
# テストが失敗した結果
1) SomeTest::test
Failed asserting that Foo Object &000000005b85f7750000000027609f23 (
'bar' => 'bar'
'baz' => 'baz'
) is instance of class "Foo" and is accepted by specified callback.
解決策: プロパティベースの制約を定義する
オブジェクトの比較ロジックを自前で実装せずに、プロパティベースの制約を組み合わせて制約を生成するといい感じになりました。
素の方法と比べると以下のようなメリットがあります。
- テストに失敗した時に何が原因なのかが分かりやすい
- 比較ロジックにPHPUnitの資産をそのまま使える
- 柔軟性が高い
- 定義が楽
制約の組み立て方のイメージは以下の通り。
<?php
class SomeTest extends \PHPUnit_Framework_TestCase
{
private static function isFoo($bar, $baz)
{
return self::logicalAnd(
// 型が `Foo` である
self::isInstanceOf(Foo::class),
// プロパティ `bar` が `$bar`と一致する
self::property(self::equalTo($bar), 'bar'),
// プロパティ `baz` が `$baz`と一致する
self::property(self::equalTo($baz), 'baz')
);
}
}
テストに失敗した時に何が原因なのかが分かりやすい
何と比較したのかが表示されるため、原因が分かりやすい。
<?php
self::assertThat(new Foo('bar', 'baz'), self::isFoo('a', 'b'));
1) SomeTest::test
Failed asserting that Foo Object &000000003c51c718000000004dc4af2d (
'bar' => 'bar'
'baz' => 'baz'
) is instance of class "Foo" and property "bar" is equal to <string:a> and property "baz" is equal to <string:b>.
比較ロジックにPHPUnitの資産をそのまま使える
PHPUnitの制約をプロパティ$bar
に適用させるだけなので、再実装はありません。
<?php
// プロパティ `bar` が `$bar`と一致する
self::property(self::equalTo($bar), 'bar'),
柔軟性が高い
値の種類(?)が増えても、好きな粒度で制約を合成でき柔軟に対応できる。
<?php
public function test()
{
$constraint = self::isFoo('bar', self::isBaz());
}
// 定義
private function isFoo(string $bar, \PHPUnit_Framework_Constraint $baz = null)
{
// `Foo#baz` の比較を切り替えた後に、制約を合成すればnullableも簡単に実現できる
$baz = $baz ?: self::isNull();
return self::logicalAnd(
self::isInstanceOf(Foo::class),
self::property($bar, 'bar'),
self::property($baz, 'baz')
);
}
定義が楽
楽ですね
self::property()
なんてメソッドないんだけど・・・?
素のPHPUnitにはこの制約の実装がないので実装が必要です。
何度も実装するのは面倒なので、制約の実装ついでに便利メソッドを生やしOSSとして公開しました。
https://packagist.org/packages/hshn/phpunit-object-constraint
概念は今回紹介した内容と同じですが、APIがよりシンプルになっています。
<?php
// この制約は以下のような意味を持ちます
// - \stdClassインスタンスである かつ
// - プロパティ `foo` が 'a' で始まり、'e'で終わる かつ
// - プロパティ `bar` が true である
$constraint = $this->constraintFor(\stdClass::class)
->property('foo')
->stringStartsWith('a')
->stringEndsWith('e')
->property('bar')
->isTrue()
->getConstraint();
制約を作ったらあとはassertするだけです。
<?php
self::assertThat($value, $constraint);
まとめ
2つの面倒くささに板挟みにされても、トレードオフだと言い聞かせて片方の面倒くささを受け入れずに済むようになりました。
まあまあいいと思いますよ〜