The Test Types
Magento has five test suites — unit, integration, API-functional, MFTF and static — and each one answers a different question.
bin/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/Unitand are collected bydev/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
<?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.
<!-- 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.
<?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.
<!-- 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