Solution: 2

PHPUnit 10.2.2 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.14
Configuration: phpunit.xml

....                                                                4 / 4 (100%)

Time: 00:00.001, Memory: 24.41 MB

Measurements
 ✔ Centimeters can be converted to int
 ✔ Centimeters cannot be negative
 ✔ Meters can be converted to float
 ✔ Meters cannot be negative

OK (4 tests, 4 assertions)
Output of phpunit --testdox
Execute phpunit --testdox

We first write a test for centimeters that expects an exception if the passed length dimension is negative. To keep the example compact, we do not use our own exception, but the RuntimeException built into PHP.

#[Test]
public function centimeters_cannot_be_negative(): void
{
    $this->expectException(RuntimeException::class);

    new Centimeter(-1);
}
MeasurementsTest.php

This test still fails because the expected exception is not thrown.

Therefore, we now add the call of a corresponding ensure method in the constructor:

public function __construct(int $centimeters)
{
    $this->ensureNotNegative($centimeters);

    $this->centimeters = $centimeters;
}
Centimeter.php

This method is easy to implement:

private function ensureNotNegative(int $centimeters): void
{
    if ($centimeters < 0) {
        throw new RuntimeException('Measure cannot be negative');
    }
}
Centimeter.php

Our test now turns green. This completes the first part.

We continue with meters and proceed in the same way:

#[Test]
public function meters_cannot_be_negative(): void
{
    $this->expectException(RuntimeException::class);

    new Meter(-1);
}
MeasurementsTest.php

We insert the corresponding code into Meter:

<?php declare(strict_types=1);

class Meter
{
    private readonly float $meters;

    public function __construct(float $meters)
    {
        $this->ensureNotNegative($meters);

        $this->meters = $meters;
    }

    public function asFloat(): float
    {
        return $this->meters;
    }

    private function ensureNotNegative(float $meters): void
    {
        if ($meters < 0) {
            throw new RuntimeException('Measure cannot be negative');
        }
    }
}
Meter.php

Have we now created code duplication? Should we introduce a common abstract base class for Centimeter and Meter?

What looks like duplication at first glance is actually just structurally similar code. Let's take a closer look: we work once with int and once with float. To standardize this into one method, we would have to work with typecasts and convert the integers to floating point numbers. This would cause problems with the accuracy of the calculation, which we should avoid as far as possible.

The only line that is actually identical in both classes is the throwing of the exception. However, this is only the case because we use a relatively generic exception message. If we were talking specifically about centimetres or meters here, this line would no longer be identical in both classes.

Never avoid code duplication that has not yet been created.

The solution at a glance:

<?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 MeasurementsTest extends TestCase
{
    #[Test]
    public function centimeters_can_be_converted_to_int(): void
    {
        $centimeters = 23;
        $measure = new Centimeter($centimeters);
        $this->assertEquals($centimeters, $measure->asInt());
    }

    #[Test]
    public function centimeters_cannot_be_negative(): void
    {
        $this->expectException(RuntimeException::class);

        new Centimeter(-1);
    }

    #[Test]
    public function meters_can_be_converted_to_float(): void
    {
        $meters = 1.5;
        $measure = new Meter($meters);
        $this->assertEquals($meters, $measure->asFloat());
    }

    #[Test]
    public function meters_cannot_be_negative(): void
    {
        $this->expectException(RuntimeException::class);

        new Meter(-1);
    }
}
MeasurementsTest.php
<?php declare(strict_types=1);

class Centimeter
{
    private readonly int $centimeters;

    public function __construct(int $centimeters)
    {
        $this->ensureNotNegative($centimeters);

        $this->centimeters = $centimeters;
    }

    public function asInt(): int
    {
        return $this->centimeters;
    }

    private function ensureNotNegative(int $centimeters): void
    {
        if ($centimeters < 0) {
            throw new RuntimeException('Measure cannot be negative');
        }
    }
}
Centimeter.php
<?php declare(strict_types=1);

class Meter
{
    private readonly float $meters;

    public function __construct(float $meters)
    {
        $this->ensureNotNegative($meters);

        $this->meters = $meters;
    }

    public function asFloat(): float
    {
        return $this->meters;
    }

    private function ensureNotNegative(float $meters): void
    {
        if ($meters < 0) {
            throw new RuntimeException('Measure cannot be negative');
        }
    }
}
Meter.php