y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

PHPUnit の assertEquals を調べる

PHPUnit の assertEquals メソッドは、sebastian/comparator パッケージを利用して等価性を判定します。PHP の == 演算子を直接利用する実装にはなっていません。そのため、独自の拡張モジュールで == 演算子の挙動を変更した場合には、== での比較と assertEquals の結果が一致しないことがあります。この記事では、assertEquals メソッドの実装をソースコードを追いながら具体的に確認し、== 演算子との不一致が問題となる場合の対処方法を示します。

assertEquals メソッドの処理の流れ

PHPUnitアサーションは、大まかに次のように処理されます。

  1. Assert クラスに定義されたメソッドが呼び出される
  2. 満たすべき条件を Constraint クラスのインスタンスとして生成する
  3. 生成された Constraint オブジェクトの evaluate メソッドで条件をチェックする

assertEquals の場合は、Assert クラスの以下の箇所にメソッドが定義されています*1。ここで、$actual が満たすべき条件を Constraint のサブクラスである IsEquals クラスのインスタンスとして生成し、assertThat メソッドを呼び出します。assertThat メソッドから Constraint オブジェクトの evaluate メソッドが実行されます。
https://github.com/sebastianbergmann/phpunit/blob/6.5.5/src/Framework/Assert.php#L531

Constraint クラスの evaluate メソッドは以下の箇所に実装されており、この中で matches メソッドを呼び出して条件を検証します。Constraint クラス自身の matches メソッドは常に false を返す実装になっており、サブクラスで適切にオーバーライドする形になります。実際、Constraint クラスを継承した具象クラスの多くは、そのように実装されています。
https://github.com/sebastianbergmann/phpunit/blob/6.5.5/src/Framework/Constraint/Constraint.php#L49

ところが、assertEquals で利用される IsEquals クラスは、Constraint クラスの evaluate メソッド自体をオーバーライドしており、matches メソッドをオーバーライドする仕組みには従っていません。IsEquals クラスの evaluate メソッドでは、sebastian/comparator パッケージを利用して等価性を判定します。以下では sebastian/comparator パッケージ側の実装を見ていきます。
https://github.com/sebastianbergmann/phpunit/blob/6.5.5/src/Framework/Constraint/IsEqual.php#L92

sebastian/comparator パッケージでの値の比較

sebastian/comparator パッケージは、PHP の値の等価性を比較する機能を提供します。リポジトリの Usage セクションで説明されているように、次の手順で利用します。

  1. Factory オブジェクトの getComparatorFor メソッドを呼び出して、比較される値に応じた Comparator オブジェクトを取得する
  2. 取得した Comparator オブジェクトの assertEquals メソッドを呼び出して、二つの値が等しいか否かを調べる

GitHub - sebastianbergmann/comparator: Provides the functionality to compare PHP values for equality.

Factory クラスの getComparatorFor メソッドは、登録されている Comparator オブジェクトの accept メソッドを順に呼び出して、最初に true を返した Comparator オブジェクトを呼び出し元に戻します。
https://github.com/sebastianbergmann/comparator/blob/2.1.1/src/Factory.php#L61

既定の状態で登録されている Comparator オブジェクトは以下のとおりです。個々の Comparator オブジェクトは array_unshift 関数で追加されていくため、ここに書かれている逆順に並びます。したがって、getComparatorFor メソッドの処理では、ソースコード上で後ろに書かれた Comparator から順に accept メソッドがチェックされます。
https://github.com/sebastianbergmann/comparator/blob/2.1.1/src/Factory.php#L117

通常のオブジェクト同士の比較が行われる場合は、ObjectComparator が選択されます。ObjectComparator クラスの assertEquals メソッドは以下の箇所で定義されています。このメソッドが PHPUnit の assertEquals でオブジェクトの等価性をチェックする処理の実体です。
https://github.com/sebastianbergmann/comparator/blob/2.1.1/src/ObjectComparator.php#L43

このメソッドの実装を眺めてみると、PHP の == 演算子による比較は行われず、以下の基準による等価性の判定が独自に実装されていることが分かります。なお、プロパティの一致は、オブジェクトを配列形式に変換したうえで、ObjectComparator の親クラスである ArrayComparator の assertEquals で判定されます。

この基準は PHP の仕様で定められたオブジェクトの比較と一致しているので、通常は == で比較した場合と同じ結果になります。
PHP: オブジェクトの比較 - Manual

ところが PHP では、拡張モジュールで定義されるクラスではオブジェクトの比較方法を変更することができ、その定義によっては == 演算の結果と PHPUnit の assertEquals の結果が不一致になることがあります。

注意:
拡張モジュール内では、自前で作成したオブジェクトの (== による) 比較方法を独自に定義することができます。

== 演算子と assertEquals の結果が一致しない例と対処方法

それでは実際に、== 演算子の挙動を変更したクラスで assertEquals と結果が不一致になる例を確認します。そのような拡張モジュールを自作するのは大変なので、前回の記事で紹介した Comparable 拡張モジュールを利用します。
nikic/comparable で PHP の比較演算をオーバーライドする - y_uti のブログ

前回の記事と同様に、Comparable インタフェースを実装した以下のクラスを利用します。Comparable インタフェースを実装すると compare メソッドで == 演算子の挙動を変更できます。この例では、MyClass のプロパティである value と ignored のうち、value のみを比較するように == 演算子を定義しています。

<?php
class MyClass implements Comparable
{
    private $value;
    private $ignored;
    public function __construct($value, $ignored)
    {
        $this->value = $value;
        $this->ignored = $ignored;
    }
    public static function compare($a, $b)
    {
        return $a->value <=> $b->value;
    }
}

次のように MyClass の単体テストを作成します。testAssertTrue メソッドは $a == $b の結果が true になることをテストし、testAssertEquals メソッドは $a と $b が等価であることをテストします。

<?php

use PHPUnit\Framework\TestCase;

class MyClassTest extends TestCase
{
    public function testAssertTrue()
    {
        $a = new MyClass(1, 'foo');
        $b = new MyClass(1, 'bar');
        $this->assertTrue($a == $b);
    }

    public function testAssertEquals()
    {
        $a = new MyClass(1, 'foo');
        $b = new MyClass(1, 'bar');
        $this->assertEquals($a, $b);
    }
}

これを実行すると、testAssertTrue はパスしますが testAssertEquals は失敗します。実行結果の一部を抜粋します。$a == $b は MyClass の compare メソッドの定義により true となりますが、assertEquals では compare メソッドを利用しないため、ignore の値が異なると報告されます。

...
1) MyClassTest::testAssertEquals
Failed asserting that two objects are equal.
--- Expected
+++ Actual
@@ @@
 MyClass Object (
     'value' => 1
-    'ignored' => 'foo'
+    'ignored' => 'bar'
...
Tests: 2, Assertions: 2, Failures: 1.

この問題を解決するには、assertEquals が MyClass の == の挙動に合わせた判定処理を行うように PHPUnit の処理を拡張する必要があります。この記事で見てきたように、assertEquals の処理は Comparator オブジェクトに委譲されるので、MyClass 用の Comparator を作成して Factory に登録しておく方法で対応できます。以下、この方法を実際に試してみます。

まず、ObjectComparator を継承したクラスを作成して accepts メソッドと assertEquals メソッドをオーバーライドします。ここでは、$expected と $actual の両者が Comparable インタフェースを実装する場合に、== の結果が true であればアサーションをパスするように実装しました。なお、アサーションに失敗したときの処理を独自に実装するのは面倒なので、== の結果が false のときには親クラスである ObjectComparator にもう一度処理させるようにしています。

<?php

use SebastianBergmann\Comparator\ObjectComparator;

class ComparableComparator extends ObjectComparator
{
    public function accepts($expected, $actual)
    {
        return $expected instanceof Comparable && $actual instanceof Comparable;
    }

    public function assertEquals($expected, $actual, $delta = 0.0, $canonicalize = false, $ignoreCase = false, array &$processed = [])
    {
        if ($expected != $actual) {
            parent::assertEquals($expected, $actual, $delta, $canonicalize, $ignoreCase, $processed);
        }
    }
}

単体テスト側には、作成した ComparableComparator クラスを Factory に登録する処理を追加します。Factory クラスの register メソッド、unregister メソッドを利用すると、独自に作成した Comparator クラスを Factory に登録できます。独自に登録した Comparator は、既定の Comparator よりも優先してチェックされます。

<?php

use PHPUnit\Framework\TestCase;
use SebastianBergmann\Comparator\Factory;

class MyClassTest extends TestCase
{
    private $comparableComprator;

    ...

    public function setup()
    {
        $this->comparableComparator = new ComparableComparator();
        Factory::getInstance()->register($this->comparableComparator);
    }

    public function tearDown()
    {
        Factory::getInstance()->unregister($this->comparableComparator);
    }

    ...
}

このように独自の Comparator を追加した状態で単体テストを実行すると、先ほど作成した二つのテストはいずれもパスします。

余談: DateTime オブジェクトの場合

== 演算子の挙動を独自に変更しているクラスの例として、DateTime クラスがあります。DateTime オブジェクトはタイムゾーンを保持しますが、== の比較では、タイムゾーンが異なっていても同じ時刻であれば等価と判断されます。以下に例を示します。

<?php

$utc = new DateTime('2017-01-01 00:00:00 UTC');
$jst = new DateTime('2017-01-01 09:00:00 JST');

var_dump($utc == $jst); // true になります

ところが、DateTime オブジェクトでは == 演算子と assertEquals の結果は一致します。そもそも、sebastian/comparator パッケージの Usage で用いられている例が DateTime オブジェクトの比較になっています。

ここまで注意深くソースコードを追っていた方は気付いていたかもしれませんが、DateTime の比較については、専用の Comparator オブジェクトが登録されています。DateTimeComparator の assertEquals メソッドでは、比較される両者を UTC に揃えた状態で比較するように実装されています。
https://github.com/sebastianbergmann/comparator/blob/master/src/Factory.php#L130

*1:PHPUnit 6.5.5 のソースコードを参照しながら説明します。