Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Unit Tests: den PointsCalculator-Service testen

Unit Tests: den PointsCalculator-Service testen

~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Kapitel 5 hat einen Grund genannt, warum PointsCalculator eine gewöhnliche PHP-Klasse ohne HTTP- oder Datenbank-Abhängigkeit ist: sie sollte "trivial unit-testbar" bleiben. Dieses Kapitel löst das Versprechen ein. Kein anderer Baustein des Moduls eignet sich besser als Einstieg in Unit Tests - calculatePoints() und determineTier() sind reine Funktionen: gleiche Eingabe, immer dieselbe Ausgabe, keine Seiteneffekte, kein Objekt Manager, keine Datenbank.

Wo Unit Tests in einem Magento-Modul liegen

Magentos Konvention: ein Test/Unit-Verzeichnis pro Modul, das exakt die Namespace-Struktur des getesteten Codes spiegelt. Model\Service\PointsCalculator wird zu Test\Unit\Model\Service\PointsCalculatorTest. Das eigene dev/tests/unit/phpunit.xml.dist von Magento bindet diese Test/Unit-Verzeichnisse aus jedem installierten Modul unter app/code automatisch als Testsuite ein - eine neue Datei an der richtigen Stelle reicht, ohne weitere Konfiguration.

Test/Unit spiegelt die Namespace-Struktur von Model/

app/code/Mironsoft/Loyalty/
├── Model/
│   └── Service/
│       └── PointsCalculator.php              (Kapitel 5)
└── Test/
    └── Unit/
        └── Model/
            └── Service/
                └── PointsCalculatorTest.php  (dieses Kapitel)

calculatePoints() testen

calculatePoints() hat keine einzige Abhängigkeit, die Test ist deshalb ein reiner Aufruf mit festen Werten. Ein @dataProvider deckt mehrere Fälle ab, ohne den Testkörper zu wiederholen - Standardrechnung, Abrundung statt kaufmännischer Rundung, und die max(0.0, ...)-Klammer aus Kapitel 5 gegen einen negativen Kategorie-Bonus.

app/code/Mironsoft/Loyalty/Test/Unit/Model/Service/PointsCalculatorTest.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Test\Unit\Model\Service;

use Magento\Framework\Serialize\Serializer\Json;
use Mironsoft\Loyalty\Model\Service\PointsCalculator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Unit tests for the pure business logic in PointsCalculator (chapter 5). No
 * Magento bootstrap, no database - the Json serializer is the only dependency,
 * and it's mocked below.
 */
class PointsCalculatorTest extends TestCase
{
    private PointsCalculator $pointsCalculator;

    /**
     * @var Json&MockObject
     */
    private Json $serializerMock;

    /**
     * Builds a fresh PointsCalculator with a mocked Json serializer before every test.
     *
     * @return void
     */
    protected function setUp(): void
    {
        $this->serializerMock = $this->createMock(Json::class);
        $this->pointsCalculator = new PointsCalculator($this->serializerMock);
    }

    /**
     * @param float $lineTotal Line total passed into calculatePoints().
     * @param float $pointsPerEuro Configured points-per-euro rate.
     * @param float $productMultiplier Product-level multiplier.
     * @param float $categoryBonus Category-level bonus rate.
     * @param int $expectedPoints Expected, already-floored result.
     * @return void
     *
     * @dataProvider calculatePointsDataProvider
     */
    public function testCalculatePoints(
        float $lineTotal,
        float $pointsPerEuro,
        float $productMultiplier,
        float $categoryBonus,
        int $expectedPoints
    ): void {
        $result = $this->pointsCalculator->calculatePoints(
            $lineTotal,
            $pointsPerEuro,
            $productMultiplier,
            $categoryBonus
        );

        self::assertSame($expectedPoints, $result);
    }

    /**
     * @return array<string, array{float, float, float, float, int}>
     */
    public static function calculatePointsDataProvider(): array
    {
        return [
            'plain calculation, no multiplier, no bonus' => [100.0, 1.0, 1.0, 0.0, 100],
            'product multiplier doubles the points' => [100.0, 1.0, 2.0, 0.0, 200],
            'category bonus adds on top of the base rate' => [100.0, 1.0, 1.0, 0.5, 150],
            'always floors, never rounds up' => [10.99, 1.0, 1.0, 0.0, 10],
            'negative category bonus never produces negative points' => [100.0, 1.0, 1.0, -5.0, 0],
        ];
    }

    /**
     * @param int $totalPointsEarned Total earned points passed into determineTier().
     * @param string $thresholdsJson JSON thresholds string, decoded by the mocked serializer.
     * @param array<string, int> $decodedThresholds What the mocked serializer returns for the JSON string above.
     * @param string $expectedTier Expected tier constant.
     * @return void
     *
     * @dataProvider determineTierDataProvider
     */
    public function testDetermineTier(
        int $totalPointsEarned,
        string $thresholdsJson,
        array $decodedThresholds,
        string $expectedTier
    ): void {
        $this->serializerMock->method('unserialize')
            ->with($thresholdsJson)
            ->willReturn($decodedThresholds);

        $result = $this->pointsCalculator->determineTier($totalPointsEarned, $thresholdsJson);

        self::assertSame($expectedTier, $result);
    }

    /**
     * @return array<string, array{int, string, array<string, int>, string}>
     */
    public static function determineTierDataProvider(): array
    {
        $thresholdsJson = '{"silver":500,"gold":2000}';
        $thresholds = ['silver' => 500, 'gold' => 2000];

        return [
            'below every threshold stays bronze' => [0, $thresholdsJson, $thresholds, PointsCalculator::TIER_BRONZE],
            'exactly on the silver threshold already counts as silver' => [500, $thresholdsJson, $thresholds, PointsCalculator::TIER_SILVER],
            'between silver and gold stays silver' => [1999, $thresholdsJson, $thresholds, PointsCalculator::TIER_SILVER],
            'exactly on the gold threshold already counts as gold' => [2000, $thresholdsJson, $thresholds, PointsCalculator::TIER_GOLD],
            'far above gold stays gold' => [50000, $thresholdsJson, $thresholds, PointsCalculator::TIER_GOLD],
        ];
    }
}

Tipp: Zwei Boundary-Fälle - genau auf der Silber- und genau auf der Gold-Schwelle - sind kein Zufall in der Datenliste: Kapitel 5 nutzt >=, nicht >. Ein Test, der nur "deutlich darüber" und "deutlich darunter" prüft, hätte einen Tippfehler wie ein versehentliches > statt >= nie bemerkt.

Test ausführen

Über den bin/cli-Wrapper dieses Projekts, nicht direkt im Container-unabhängigen PHP:

bin/cli vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
  app/code/Mironsoft/Loyalty/Test/Unit/Model/Service/PointsCalculatorTest.php

Achtung: createMock(Json::class) ruft den echten Konstruktor von Json nicht auf - PHPUnit deaktiviert ihn standardmäßig bei createMock(). Genau deshalb funktioniert das Mocken einer konkreten Klasse hier genauso einfach wie das eines Interfaces: der Testcode kümmert sich nie um Jsons eigene Abhängigkeiten.

PointsCalculator hatte nur eine einzige, mockbare Abhängigkeit. Kapitel 92 zeigt an einer Klasse mit deutlich mehr Abhängigkeiten - Repository und mehreren Services zugleich - wie Mocking im größeren Maßstab aussieht.