成功しているのに失敗？PHPUnitにおけるCarbonと文字列の比較

# 前書き

僕は最近、Laravelプロジェクトでテストを書いているときに不思議な現象に遭遇しました。  
Carbon（日時ライブラリ）と文字列を比較するテストに関してです。

同じような問題に遭遇する人もいると思うので、調査結果を共有します。

## バージョン情報

- PHP 7.1.3
- Laravel 5.8
- PHPUnit 7.5

# 何が起こったのか

Laravel のテストで、以下のような比較をしてみます。

```php
$carbon = Carbon::parse('2025-06-06 12:00:00');
$string = '2025-06-06 12:00:00';

$this->assertEquals($carbon, $string);
```

上記のコードは**成功します**。

しかし、この比較が配列に含まれる場合はどうでしょうか？

```php
$expected = ['created_at' => $carbon, 'name' => 'user'];
$actual   = ['created_at' => $string, 'name' => 'staff']; // nameが違う

$this->assertEquals($expected, $actual); // 失敗
```

## 問題の詳細

このテストが失敗したとき、PHPUnitは以下のような差分を表示します：

```
--- Expected
+++ Actual
@@ @@
 Array (
-    'created_at' => Carbon\Carbon Object (...)
-    'name' => 'user'
+    'created_at' => '2025-06-06 12:00:00'
+    'name' => 'staff'
 )
```

nameが違うのは明らかですが、created_atにも差分が出ています。

これ、nameを正しい値に修正したら、created_atの差分は出ずにテストが成功します。

つまり、`created_at`はもちろん一致しており、`name`フィールドで失敗しているのに、diff表示では両方のフィールドが「異なって見える」のです。

# なぜこんなことが起こるのか

PHPUnitで配列比較に、特に配列が要素としてCarbonオブジェクトと文字列を持つ場合、以下のような処理が行われています。

## 比較処理の流れ

PHPUnitで配列をassertEqualsで比較した場合、そして配列が要素としてCarbonオブジェクトと文字列を持つ場合、比較処理は以下のようになります。

### 1. Comparatorの選択

PHPUnitでは、比較する値の型に応じて適切なComparator(比較クラス)が選択されます。

### 2. ArrayComparatorが配列を比較

- 配列の場合は[ArrayComparator#assertEquals()](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ArrayComparator.php#L38)が使われます
- 全ての要素の比較は別のComparatorで行われます
- Carbonオブジェクトと文字列の比較も要素の一つなので別のComparatorで比較されます

### 3. ScalarComparatorがCarbonオブジェクトと文字列を比較

オブジェクトが`__toString()`メソッドを持っている場合(Carbonは持っている)、[ScalarComparator#assertEquals()](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ScalarComparator.php#L44)が使われます。

ScalarComparator::assertEquals()内で、以下の処理が行われます：

- まず両者を文字列に変換します（Carbonは`__toString()`が呼ばれる）
- その後、[以下の実装](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ScalarComparator.php#L65)で文字列同士として比較されます
- ComparisonFailureがthrowされなければ成功という意味です

### 4. 比較後に表示内容を作成

例えばCarbonオブジェクトと文字列の比較においてはScalarComparator#assertEquals()内でComparisonFailureがthrowされます。

throwされたComparisonFailureはArrayComparator#assertEquals()内でcatchされます。

catchした中でエラーの表示用のメッセージを作成します。(表示処理に続く)

- 値が正しい場合も[ここ](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ArrayComparator.php#L75-L85)で表示用文字列作成の処理は行われます


## 表示処理の詳細

表示処理は比較する期待値と実際の値の両方に対して行われます。

今回は配列内のCarbonオブジェクトと文字列の比較についてのみ言及します。

### 表示メッセージの作成

比較で正しくない場合は、ArrayComparator#assertEquals()内の[この部分](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ArrayComparator.php#L86-L100)で表示用のメッセージを作成するのですが、その際に`$exporter->shortenedExport($e->getExpected())`が実行されます。

$exporterは`new Exporter`と[こちら](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ArrayComparator.php#L52C29-L52C41)で定義されていますが、Exporterとして`use SebastianBergmann\Exporter\Exporter;`を使用しています。

### オブジェクトの表示処理

[shortenedExport](https://github.com/sebastianbergmann/exporter/blob/main/src/Exporter.php#L123)ではCarbonオブジェクトの場合以下が実行され、`Carbon\Carbon Object (...)`のような表示が作成されます。

```php
if (is_object($value)) {
    return sprintf(
        '%s Object (%s)',
        $value::class,
        $this->countProperties($value) > 0 ? '...' : '',
    );
}
```

文字列の場合は長すぎる場合、省略して表示するようになっています。

つまり、たとえ比較処理でcreated_atに問題がなかったとしても表示用の文字列が異なるということですね。

### 最終的な表示処理

この表示用の文字列を作成するロジックを各要素に対して実行し、期待値の配列と実際の値の配列に対応する、表示用の配列がそれぞれ作成されます。

そして作成された期待値用と実際の値用の表示用配列を用いて、再度失敗をthrowします。[ソースコード](https://github.com/sebastianbergmann/comparator/blob/b5c0243c20fa86291971148b6e99a5a81f36fb71/src/ScalarComparator.php#L69-L75) (ScalarComparator#assertEquals()内のComparisonFailureはcatchされていますからね。)

```php
throw new ComparisonFailure(
    $expected,
    $actual,
    $exporter->export($cutExpected),
    $exporter->export($cutActual),
    'Failed asserting that two strings are equal.',
);
```

で、なんやかんやあって、テスト結果の表示は以下のようになります。(diffライブラリ使って表示とかされているはずです。)

```
-    'created_at' => Carbon\Carbon Object (...)
+    'created_at' => '2025-06-06 12:00:00'
```

## まとめ

配列内のCarbonオブジェクトと文字列の比較は成功しているが、失敗した時には表示が異なるため、テストが時間の比較で失敗しているように見えるということが起きています。

## 最後に

誤りがあれば教えてください。

Carbonに限らずオブジェクトの時はこの挙動になるのでは？と思っていますが調査はまだできていません。
時間の比較でわかりにくかったので記事にしました。

久しぶりに真面目にOSSのソースまで追って疲れました。