Unit Tests

How to unit test a Magento class: mock the interfaces it was given, and leave anything that needs the database to integration tests.

Syntaxvendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Vendor/Module/Test/Unit

A unit test runs one class with every dependency replaced. Nothing is installed, nothing is cached, nothing is indexed. So a unit test can check only the logic the class performs itself, which is exactly why it runs in milliseconds.

Where the file goes

The test mirrors the class path under Test/Unit: Model/PointsCalculator.php is tested by Test/Unit/Model/PointsCalculatorTest.php, in namespace Vendor\Module\Test\Unit\Model. The suite collects app/code/*/*/Test/Unit by directory, so the path is what makes the test run at all.

Mocking Magento's dependencies

Take interfaces in the constructor and replace them with $this->createMock(SomeInterface::class). Mock the interface, not a concrete model: a model extends AbstractModel and drags in a resource, a registry and an event manager.

Magento\Framework\TestFramework\Unit\Helper\ObjectManager offers getObject($className, array $arguments = []), which builds the class and auto-mocks any constructor argument you leave out. In 2.4.x that class is marked deprecated, and the stated guidance is to instantiate the subject with new and list its dependencies. The long form is also honest: a new constructor argument breaks the test instead of being silently mocked.

What not to unit test

Repositories, collections, EAV reads, layout, and plugins. Interception runs in generated code that does not exist here, so a unit test of a plugin tests a plain method call. Those belong in integration tests.

No command is needed for a unit test to see your edit. The running store is different: after changing a constructor or di.xml, run bin/magento setup:di:compile so the application matches the code the test just verified.

Example

Example · php
<?php
// app/code/Vendor/Loyalty/Test/Unit/Model/PointsCalculatorTest.php
declare(strict_types=1);

namespace Vendor\Loyalty\Test\Unit\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Vendor\Loyalty\Model\PointsCalculator;

class PointsCalculatorTest extends TestCase
{
    private ScopeConfigInterface&MockObject $scopeConfig;

    private PointsCalculator $calculator;

    protected function setUp(): void
    {
        $this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
        $this->calculator = new PointsCalculator($this->scopeConfig);
    }

    public function testAwardsConfiguredPointsPerCurrencyUnit(): void
    {
        $this->scopeConfig->method('getValue')
            ->with('loyalty/points/per_unit')
            ->willReturn('2');

        self::assertSame(50, $this->calculator->award(25.40));
    }
}

// vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
//     app/code/Vendor/Loyalty/Test/Unit/Model/PointsCalculatorTest.php
// OK (1 test, 1 assertion)

When to use it

  • Covering a price or points calculation before refactoring it.
  • Proving a validator rejects the three inputs a bug report named.
  • Checking that a failed external call is logged rather than swallowed.
  • Deciding, in review, that a test full of mocked repositories should be an integration test instead.

More examples

The class that makes the test possible

One injected interface, one pure method, no object manager call anywhere. Reach for this shape whenever you want logic covered cheaply: the moment a class fetches a collaborator itself, with ObjectManager::getInstance() or a static call, it stops being unit-testable and only an integration test can reach it. Constructor promotion (the private readonly in the signature) is the short form of declaring the property and assigning it in the body; both produce the same class.

Example · php
<?php
// app/code/Vendor/Loyalty/Model/PointsCalculator.php
declare(strict_types=1);

namespace Vendor\Loyalty\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;

class PointsCalculator
{
    private const XML_PATH_PER_UNIT = 'loyalty/points/per_unit';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig
    ) {
    }

    /**
     * Whole points awarded for a spend, rounded down.
     */
    public function award(float $spend): int
    {
        $perUnit = (float)$this->scopeConfig->getValue(self::XML_PATH_PER_UNIT);

        return (int)floor($spend * $perUnit);
    }
}

// Output: award(25.40) with per_unit = 2 returns 50

Two ways to build the subject under test

You will meet both forms in core and in third-party modules, so you need to recognise each. The helper's array keys are the constructor parameter names, which is how it knows where your mock goes. It is deprecated in 2.4.x, and the reason is visible here: add a second constructor argument and the longhand breaks loudly while the shorthand quietly injects a mock whose methods all return null.

Example · php
<?php
// app/code/Vendor/Loyalty/Test/Unit/Model/BuildSubjectTest.php
declare(strict_types=1);

namespace Vendor\Loyalty\Test\Unit\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager;
use PHPUnit\Framework\TestCase;
use Vendor\Loyalty\Model\PointsCalculator;

class BuildSubjectTest extends TestCase
{
    public function testBothFormsProduceTheSameObject(): void
    {
        $scopeConfig = $this->createMock(ScopeConfigInterface::class);

        // Longhand: every dependency named.
        $explicit = new PointsCalculator($scopeConfig);

        // Shorthand: the helper reflects the constructor and mocks
        // anything not listed. Same object, fewer keystrokes.
        $helper = new ObjectManager($this);
        $viaHelper = $helper->getObject(
            PointsCalculator::class,
            ['scopeConfig' => $scopeConfig]
        );

        self::assertInstanceOf(PointsCalculator::class, $explicit);
        self::assertInstanceOf(PointsCalculator::class, $viaHelper);
    }
}

// Output: OK (1 test, 2 assertions)

Asserting that a failure is logged, not swallowed

createMock gives you a stub whose return value you set; expects() turns it into an expectation that PHPUnit verifies at the end of the test. Use expects($this->once()) when the call itself is the behaviour you promised, as with logging or dispatching an event, and plain method() when you only need the collaborator to answer. Psr\Log\LoggerInterface is the logger Magento injects, so mocking it needs no Magento classes at all.

Example · php
<?php
// app/code/Vendor/Loyalty/Test/Unit/Model/PointsCalculatorLogTest.php
declare(strict_types=1);

namespace Vendor\Loyalty\Test\Unit\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Psr\Log\LoggerInterface;
use PHPUnit\Framework\TestCase;
use Vendor\Loyalty\Model\PointsCalculator;

class PointsCalculatorLogTest extends TestCase
{
    public function testMisconfiguredRateIsLoggedOnce(): void
    {
        $scopeConfig = $this->createMock(ScopeConfigInterface::class);
        $scopeConfig->method('getValue')->willReturn('not-a-number');

        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())
            ->method('error')
            ->with($this->stringContains('loyalty/points/per_unit'));

        $calculator = new PointsCalculator($scopeConfig, $logger);

        self::assertSame(0, $calculator->award(25.40));
    }
}

Discussion

  • Be the first to comment on this lesson.