Solution: 5

We create a new test case for this so that the whole thing remains clear. There are four different cases that we have to consider, namely the comparison of centimeters with each other, the comparison of meters with each other and the comparison of meters with centimeters and vice versa.

Normally in TDD I would write the test cases one after the other, but I don't want you to have to click through so many solutions. So here are all four test cases at once:

<?php declare(strict_types=1);

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(Meter::class)]
#[CoversClass(Centimeter::class)]
class CompareMeasurementsTest extends TestCase
{
    #[Test]
    public function meters_can_be_compared(): void
    {
        $meter = new Meter(1.23);

        $this->assertTrue($meter->equals($meter));
        $this->assertFalse($meter->equals(new Meter(1.24)));
    }

    #[Test]
    public function centimeters_can_be_compared(): void
    {
        $centimeter = new Centimeter(142);

        $this->assertTrue($centimeter->equals($centimeter));
        $this->assertFalse($centimeter->equals(new Centimeter(197)));
    }

    #[Test]
    public function centimeters_can_be_compared_with_meters(): void
    {
        $centimeter = new Centimeter(142);
        $meter = new Meter(1.42);

        $this->assertTrue($centimeter->equals($meter));
        $this->assertFalse($centimeter->equals(new Meter(1.97)));
    }

    #[Test]
    public function meters_can_be_compared_with_centimeters(): void
    {
        $centimeter = new Centimeter(142);
        $meter = new Meter(1.42);

        $this->assertTrue($meter->equals($centimeter));
        $this->assertFalse($meter->equals(new Centimeter(197)));
    }

    #[Test]
    public function exception_when_comparing_centimeter_to_unsupported_measure(): void
    {
        $centimeter = new Centimeter(142);
        $unsupportedMeasurement = $this->createMock(Measurement::class);

        $this->expectException(RuntimeException::class);

        $centimeter->equals($unsupportedMeasurement);
    }

    #[Test]
    public function exception_when_comparing_meter_to_unsupported_measure(): void
    {
        $meter = new Meter(1.42);
        $unsupportedMeasurement = $this->createMock(Measurement::class);

        $this->expectException(RuntimeException::class);

        $meter->equals($unsupportedMeasurement);
    }
}
CompareMeasurementsTest.php

Wait a minute, there are six different cases. That's because we've been comparing apples and oranges so far, because the Centimeter and Meter classes had nothing in common apart from structural similarity. For the signature of a uniform equals() method, however, we need something in common, which is why we had to introduce the Measurement interface:

<?php declare(strict_types=1);

interface Measurement
{
    public function equals(Measurement $measurement): bool;
}
Measurement.php

Since we are no longer limited to Centimeter and Meter, because someone can create other implementations of Measurement in the future, we have to think about what happens when we compare with such a new Measurement. We don't yet know how to convert this. If that was confusing, it will become clear below.

First the last two test cases:

#[Test]
public function exception_when_comparing_centimeter_to_unsupported_measure(): void
{
    $centimeter = new Centimeter(142);
    $unsupportedMeasurement = $this->createMock(Measurement::class);

    $this->expectException(RuntimeException::class);

    $centimeter->equals($unsupportedMeasurement);
}

#[Test]
public function exception_when_comparing_meter_to_unsupported_measure(): void
{
    $meter = new Meter(1.42);
    $unsupportedMeasurement = $this->createMock(Measurement::class);

    $this->expectException(RuntimeException::class);

    $meter->equals($unsupportedMeasurement);
}
CompareMeasurementsTest.php

We simply mock a measurement in each of these tests. Okay, strictly speaking these are stubs and not mocks, but people are often relatively sloppy with these designations in everyday life. The important thing is that we follow the interface segregation principle.

This is what the comparison looks like in meters:

public function equals(Measurement $measurement): bool
{
    if ($measurement instanceof Meter) {
        return $this->meters == $measurement->meters;
    }

    if ($measurement instanceof Centimeter) {
        return $this->equals($measurement->asMeter());
    }

    throw new RuntimeException('Unsupported measurement');
}
Meter.php

We have to convert correctly between the supported units. If you have developed this solution test-driven, then you have developed the body of this method step by step, which has probably made things easier than it looks here now.

You may be wondering why the call in line 27 works, even though meters is private? This is a special feature of PHP. In the latest versions of PHP, you could also declare meters public readonly. Our value object should be immutable.

We also have to implement the comparison in Centimeter:

public function equals(Measurement $measurement): bool
{
    if ($measurement instanceof Centimeter) {
        return $this->centimeters === $measurement->centimeters;
    }

    if ($measurement instanceof Meter) {
        return $this->equals($measurement->asCentimeter());
    }

    throw new RuntimeException('Unsupported measurement');
}
Centimeter.php

Does that look like code duplication to you again? No, if we take a closer look, then we are working with different types in both classes and the conversions are "the other way around".

Let's see if everything works as expected:

PHPUnit 10.2.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.14
Configuration: phpunit.xml

......                                                              6 / 6 (100%)

Time: 00:00.003, Memory: 24.41 MB

Compare Measurements
 ✔ Meters can be compared
 ✔ Centimeters can be compared
 ✔ Centimeters can be compared with meters
 ✔ Meters can be compared with centimeters
 ✔ Exception when comparing centimeter to unsupported measure
 ✔ Exception when comparing meter to unsupported measure

OK (6 tests, 10 assertions)
Output of phpunit --filter CompareMeasurementsTest --testdox
execute phpunit --filter CompareMeasurementsTest --testdox