Unit Tests: Testing the PointsCalculator Service
Unit Tests: Testing the PointsCalculator Service
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 5 gave a reason why PointsCalculator is an ordinary PHP class with no HTTP or database dependency: it was meant to stay "trivially unit-testable." This chapter cashes in that promise. No other building block of the module makes a better entry point into unit testing - calculatePoints() and determineTier() are pure functions: same input, always the same output, no side effects, no object manager, no database.
Where unit tests live in a Magento module
Magento's convention: one Test/Unit directory per module, mirroring the tested code's namespace structure exactly. Model\Service\PointsCalculator becomes Test\Unit\Model\Service\PointsCalculatorTest. Magento's own dev/tests/unit/phpunit.xml.dist automatically wires these Test/Unit directories from every installed module under app/code into the test suite - a new file in the right place is enough, no further configuration needed.
Test/Unit mirrors the namespace structure of Model/
app/code/Mironsoft/Loyalty/
├── Model/
│ └── Service/
│ └── PointsCalculator.php (chapter 5)
└── Test/
└── Unit/
└── Model/
└── Service/
└── PointsCalculatorTest.php (this chapter)Testing calculatePoints()
calculatePoints() has not a single dependency, so the test is a pure call with fixed values. A @dataProvider covers several cases without repeating the test body - the plain calculation, flooring instead of banker's rounding, and the max(0.0, ...) guard from chapter 5 against a negative category 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: Two boundary cases - exactly on the silver and exactly on the gold threshold - aren't in the data list by accident: chapter 5 uses >=, not >. A test that only checks "well above" and "well below" would never have caught a typo like an accidental > instead of >=.
Running the test
Through this project's bin/cli wrapper, not directly in container-independent 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) does not call Json's real constructor - PHPUnit disables it by default for createMock(). That's exactly why mocking a concrete class here works just as easily as mocking an interface: the test code never has to worry about Json's own dependencies.
PointsCalculator had only a single, mockable dependency. Chapter 92 shows what mocking looks like at a larger scale, on a class with far more dependencies - a repository and several services at once.