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.
<?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.phpAchtung: 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.