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

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.

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: 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.php

Achtung: 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.