Lösung: 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)
Ausgabe von phpunit --testdox

Wir schreiben zuerst einen Test für Centimeter, der eine Exception erwartet, wenn das übergebene Längenmaß negativ ist. Um das Beispiel kompakt zu halten, verwenden wir keine eigene Exception, sondern die in PHP eingebaute RuntimeException.

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

    new Centimeter(-1);
}
MeasurementsTest.php

Dieser Test schlägt erst mal noch fehl, weil die erwartete Exception nicht geworfen wird.

Daher fügen wir nun im Konstruktor den Aufruf einer entsprechenden ensure-Methode ein:

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

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

Diese Methode ist einfach zu implementieren:

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

Unser Test wird nun grün. Damit ist der erste Teil erledigt.

Wir machen mit Meter weiter und gehen dabei genauso vor:

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

    new Meter(-1);
}
MeasurementsTest.php

Wir fügen den entsprechenden Code in Meter ein:

<?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

Haben wir nun Code-Duplikation erzeugt? Sollten wir eine gemeinsame abstrakte Basisklasse für Centimeter und Meter einführen?

Was auf den ersten Blick wie Duplikation aussieht, ist in Wirklichkeit nur strukturell ähnlicher Code. Sehen wir einmal genauer hin: wir arbeiten einmal mit int und einmal mit float. Um das in eine Methode zu vereinheitlichen, müssten wir mit Typecasts arbeiten und die Ganzzahlen zu Fließkommazahlen konvertieren. Das würde Probleme mit der Rechengenauigkeit nach sich ziehen, die wir tunlichst vermeiden sollten.

Die einzige Zeile, die aktuell tatsächlich in beiden Klassen identisch ist, ist das Werfen der Exception. Das ist aber auch nur deshalb der Fall, weil wir eine relativ generische Exception-Message verwenden. Würden wir hier konkret von Zentimeter beziehungsweise Metern sprechen, wäre auch diese Zeile in beiden Klassen nicht mehr identisch.

Vermeide niemals Code-Duplikation, die noch gar nicht entstanden ist.

Die Lösung im Überblick:

<?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