The Test Types

Magento has five test suites — unit, integration, API-functional, MFTF and static — and each one answers a different question.

Syntaxbin/magento dev:tests:run <unit|integration|integration-all|static|static-all|integrity|legacy|all|default>

Magento ships five suites of automated tests. They are not alternatives to each other. Each boots a different amount of the application, and that is what decides what it can prove.

The five

  • Unit — your tests live in Vendor/Module/Test/Unit and are collected by dev/tests/unit/phpunit.xml.dist. No database, no application, every dependency replaced by a mock. Milliseconds per test.
  • Integration — Test/Integration. Boots the real application against a separate test database that the framework installs for you. Seconds per test.
  • API functional — Test/Api. Sends real HTTP requests to a running store and asserts on the decoded response.
  • Functional, called MFTF — Test/Mftf. Steps written in XML drive a real browser. Minutes per test.
  • Static — dev/tests/static. Reads the source without running it: PHP_CodeSniffer, PHPMD, PHPStan and a set of integrity checks.

There are also JavaScript unit tests under dev/tests/js/jasmine.

Choosing one

A calculation with no dependencies is a unit test. Anything that saves through a repository, reads an EAV attribute, or depends on merged XML belongs in an integration test, because a mocked repository only proves the mock behaves. A new REST or GraphQL endpoint gets an API-functional test. Reserve MFTF for the few journeys where a silent break costs money.

One version detail

Magento 2.4.9 requires PHPUnit 12, which no longer reads metadata from doc comments: @dataProvider must become the #[DataProvider] attribute. Magento's own annotations, such as @magentoDataFixture, are parsed by Magento rather than PHPUnit, so those still work.

Example

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

namespace Vendor\Loyalty\Test\Unit\Model;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Vendor\Loyalty\Model\Tier;

class TierTest extends TestCase
{
    #[DataProvider('spendProvider')]
    public function testNameForSpend(float $spend, string $expected): void
    {
        self::assertSame($expected, (new Tier())->nameForSpend($spend));
    }

    public static function spendProvider(): array
    {
        return [
            'no orders yet' => [0.0, 'bronze'],
            'mid range' => [250.0, 'silver'],
            'top of the range' => [1000.0, 'gold'],
        ];
    }
}

// vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
//     app/code/Vendor/Loyalty/Test/Unit
// OK (3 tests, 3 assertions)

When to use it

  • Deciding whether a cart price rule bug needs a unit test or an integration test.
  • Adding the first automated test to a module that has none.
  • Cutting a 40-minute pipeline by moving assertions out of MFTF and into integration tests.
  • Explaining in review why a test built on a mocked repository proves nothing.

More examples

Why the directory name decides whether your test runs

The unit suite is a glob over directories, not a scan for classes ending in Test. A test file in Test/Units, or in Test next to the class, is never collected, and the run still reports OK. Reach for this when a test you just wrote appears to pass without ever printing a dot: compare your path with this glob.

Example · xml
<!-- dev/tests/unit/phpunit.xml.dist (extract) -->
<testsuites>
    <testsuite name="Magento_Unit_Tests_App_Code">
        <directory>../../../app/code/*/*/Test/Unit</directory>
        <directory>../../../vendor/magento/module-*/Test/Unit</directory>
        <exclude>../../../app/code/Magento/Indexer/Test/Unit</exclude>
    </testsuite>
</testsuites>

The same question asked as an integration test

This test can only exist at the integration level: it asks whether the preference declared in your di.xml actually won after every module's configuration was merged. A unit test cannot answer that, because it never reads di.xml. Write one of these after adding a preference, so a later module overriding you fails a test instead of being discovered in production.

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

namespace Vendor\Loyalty\Test\Integration\Model;

use Magento\TestFramework\Fixture\DbIsolation;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Vendor\Loyalty\Api\PointsCalculatorInterface;
use Vendor\Loyalty\Model\PointsCalculator;

class PointsCalculatorTest extends TestCase
{
    #[DbIsolation(true)]
    public function testInterfaceResolvesToOurImplementation(): void
    {
        $calculator = Bootstrap::getObjectManager()->get(PointsCalculatorInterface::class);

        self::assertInstanceOf(PointsCalculator::class, $calculator);
    }
}

// cd dev/tests/integration
// ../../../vendor/bin/phpunit ../../../app/code/Vendor/Loyalty/Test/Integration
// OK (1 test, 1 assertion)

A browser test, for comparison

Three steps, no PHP, and roughly a minute of wall-clock time once Selenium and Chrome have started. Generate and run it with vendor/bin/mftf run:test StorefrontLoyaltyBannerTest. Compare the cost with the integration test above and you have the rule of thumb: push an assertion as far down the list as it will go.

Example · xml
<!-- app/code/Vendor/Loyalty/Test/Mftf/Test/StorefrontLoyaltyBannerTest.xml -->
<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd">
    <test name="StorefrontLoyaltyBannerTest">
        <annotations>
            <features value="Loyalty"/>
            <stories value="Points banner on the storefront"/>
            <title value="Guest sees the loyalty banner on the home page"/>
            <description value="Opens the home page as a guest and checks the banner text"/>
            <severity value="MINOR"/>
            <group value="Loyalty"/>
        </annotations>
        <amOnPage url="/" stepKey="openHomePage"/>
        <waitForPageLoad stepKey="waitForHomePage"/>
        <see userInput="Earn points on every order" stepKey="seeBanner"/>
    </test>
</tests>

Discussion

  • Be the first to comment on this lesson.