MFTF vs. PHPUnit vs. API Tests: What Fits When
AI generated
@test
assert
Magento 2 · MFTF · PHPUnit · API Tests · Test Strategy
MFTF vs. PHPUnit vs. API Tests:
What Fits When

In Magento 2 there are three fundamentally different test types with very different runtimes, maintenance costs, and areas of use. PHPUnit for unit and integration tests, MFTF for end-to-end browser tests, and REST API tests for the web service layer. The most common mistakes in Magento test strategies: too many MFTF tests, too few unit tests, and API tests that should really be logic tests.

17 min read MFTF · PHPUnit Unit · Integration · REST API · Test Pyramid Magento 2.4.8 · PHP 8.4 · PHPUnit 11

1. The Magento Test Pyramid

Mike Cohn's test pyramid applies to Magento too: many fast, cheap unit tests form the base. Fewer integration tests in the middle. Even fewer slow, expensive end-to-end tests at the top. Magento 2 has four concrete test types that should follow this pyramid: PHPUnit unit tests (seconds, no bootstrap), PHPUnit integration tests (minutes, full Magento stack with DB), REST API tests (minutes, real HTTP stack), and MFTF tests (many minutes to hours, browser plus Selenium).

In practice you often see inverted pyramids: teams have many MFTF tests that break at the slightest UI change, barely any integration tests, and no unit tests. The result is a slow, fragile CI pipeline that spends more time fixing tests than writing new features. The right split for a mid-sized Magento module: 70-80% unit tests, 15-20% integration tests, 5-10% API or MFTF tests for critical end-to-end paths.

2. PHPUnit Unit Tests: Logic Without a Framework

PHPUnit unit tests are the fastest feedback loop in Magento 2. They are configured via dev/tests/unit/phpunit.xml and run without a Magento bootstrap. The runtime for a complete module test suite is typically under 5 seconds. These tests are suited to every class that receives its dependencies through the constructor: view models, service classes, simple plugins, price calculation logic, validators, and data transformers.

The decisive criterion for a unit test is that all dependencies can be replaced with mocks. If a class makes a database call, the repository interface must be mocked, not the implementation. If a class makes an HTTP call, the HTTP client must be mocked. If these mocks become so elaborate that the test itself is barely readable, that is a signal: either the class is too large (refactor it), or an integration test is the right choice.


<?php
// tests/Unit/Service/DiscountCalculatorTest.php, pure unit test, no bootstrap
declare(strict_types=1);

namespace Tests\Unit\Service;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Mironsoft\Sales\Service\DiscountCalculator;
use Mironsoft\Sales\Api\CustomerTierRepositoryInterface;

class DiscountCalculatorTest extends TestCase
{
    #[DataProvider('discountScenarioProvider')]
    public function testCalculatesCorrectDiscount(
        string $tier,
        float  $subtotal,
        float  $expectedDiscount
    ): void {
        $tierRepo = $this->createMock(CustomerTierRepositoryInterface::class);
        $tierRepo->method('getByCustomerGroup')->willReturn(
            $this->buildTier($tier, ['bronze' => 5.0, 'silver' => 10.0, 'gold' => 15.0][$tier] ?? 0.0)
        );

        $calculator = new DiscountCalculator($tierRepo);
        $discount   = $calculator->calculate($subtotal, 'customer_group_' . $tier);

        $this->assertEqualsWithDelta($expectedDiscount, $discount, 0.01);
    }

    public static function discountScenarioProvider(): array
    {
        return [
            'bronze 100€'  => ['bronze', 100.00, 5.00],
            'silver 200€'  => ['silver', 200.00, 20.00],
            'gold 500€'    => ['gold',   500.00, 75.00],
            'unknown tier' => ['guest',  100.00, 0.00],
        ];
    }

    private function buildTier(string $name, float $rate): object
    {
        return new class($name, $rate) {
            public function __construct(
                public readonly string $name,
                public readonly float $discountRate
            ) {}
        };
    }
}

3. PHPUnit Integration Tests: Bootstrap with a Database

PHPUnit integration tests in Magento run via dev/tests/integration/phpunit.xml and initialize the full Magento stack including the database connection, DI container, and event system. Runtime is in the minutes range, typically 1-10 minutes for a module test suite, depending on the number of fixture setups and DB operations. Magento offers a fixtures system for integration tests with #[Fixture] attributes that establish database state before a test and roll it back afterwards.

Integration tests are the right test type for repository implementations (which test real SQL queries), for observer behavior (which requires event dispatch), for plugin chains (which need the DI container and interceptor generation), and for setup patches (which create database schemas). A common mistake is writing integration tests for logic that could also be tested with a unit test, which unnecessarily increases runtime and makes the tests more prone to infrastructure problems.


<?php
// dev/tests/integration/testsuite/Mironsoft/Sales/Model/DiscountRepositoryTest.php
declare(strict_types=1);

namespace Mironsoft\Sales\Model;

use Magento\TestFramework\Helper\Bootstrap;
use Magento\TestFramework\Fixture\DbIsolation;
use PHPUnit\Framework\TestCase;
use Mironsoft\Sales\Api\DiscountRepositoryInterface;
use Mironsoft\Sales\Api\Data\DiscountInterface;

#[DbIsolation(enabled: true)]
class DiscountRepositoryTest extends TestCase
{
    private DiscountRepositoryInterface $repository;

    protected function setUp(): void
    {
        $objectManager    = Bootstrap::getObjectManager();
        $this->repository = $objectManager->get(DiscountRepositoryInterface::class);
    }

    public function testSavesAndRetrievesDiscount(): void
    {
        /** @var DiscountInterface $discount */
        $discount = Bootstrap::getObjectManager()->create(DiscountInterface::class);
        $discount->setCode('TEST10');
        $discount->setRate(10.0);
        $discount->setIsActive(true);

        $saved    = $this->repository->save($discount);
        $retrieved = $this->repository->getByCode('TEST10');

        $this->assertSame($saved->getId(), $retrieved->getId());
        $this->assertEqualsWithDelta(10.0, $retrieved->getRate(), 0.001);
        $this->assertTrue($retrieved->isActive());
    }

    public function testThrowsOnDuplicateCode(): void
    {
        $this->expectException(\Magento\Framework\Exception\AlreadyExistsException::class);

        foreach (range(1, 2) as $_) {
            $discount = Bootstrap::getObjectManager()->create(DiscountInterface::class);
            $discount->setCode('DUPLICATE');
            $discount->setRate(5.0);
            $this->repository->save($discount);
        }
    }
}

4. REST API Tests: Verifying the Web Service Layer in Isolation

Magento 2 provides a complete REST API and a dedicated test framework for web API tests under dev/tests/api-functional. These tests use Magento's real HTTP stack, but hit the PHP process directly, no browser, no Selenium. They are ideal for checking whether REST endpoints return correct HTTP status codes, whether authentication and authorization work, whether API responses have the correct JSON schema, and whether pagination and filters are implemented correctly.

API tests are faster than MFTF tests but slower than integration tests because they go through the HTTP request-response cycle. Their scope is clearly bounded: the web service layer. Whether the underlying business logic is correct should be covered by unit and integration tests. API tests check whether that logic is correctly exposed over HTTP, not the logic itself. This avoids duplicate coverage and keeps the API test suite small and fast.

5. MFTF: Browser End-to-End Tests at a Glance

The Magento Functional Testing Framework (MFTF) is built on Selenium and drives a real browser. MFTF tests are XML based and describe user interactions: fill in a form, click a button, assert on visible text. They are the only test type that verifies the complete frontend rendering, including JavaScript, Alpine.js components, and CSS interactions.

The price for this completeness is high: MFTF tests are typically 10-100x slower than integration tests, break with every UI refactor, and require a fully running Magento instance with a browser driver. In Hyva projects there is an added complication: the standard MFTF framework is designed for Luma, so Hyva-specific Alpine.js interactions require their own MFTF adaptations or Playwright-based alternatives. MFTF should be limited to critical end-to-end paths: the complete checkout scenario, the login flow, basic cart functionality.

6. Decision Tree: Which Test for Which Problem?

The choice of test type follows a simple chain of questions: Are you testing logic within a single class? Unit test. Are you testing the interaction of several classes with a database? Integration test. Are you testing an HTTP endpoint without a browser? API test. Are you testing a complete user interaction in the browser? MFTF. Each answer largely rules out the other options, the only sensible reason to deviate from this rule is when a given test type is technically not feasible for the specific problem.

A common mistake in Magento projects: writing MFTF tests for checkout price calculations because you want to "play it safe". The result is a slow, fragile test that breaks on every CSS class change in the template, even though the pricing logic itself is correct. Pricing logic belongs in unit tests (calculation algorithm) and integration tests (database interaction with real products and price rules). The MFTF test only checks whether the overall process works, and only for exactly one happy path.

7. The Four Test Types Head to Head

Each test type has a clearly defined role in the Magento test strategy. The choice depends on what you want to test, not on what is technically possible.

Criterion Unit Test Integration Test API Test MFTF
Runtime ms-sec. Minutes Minutes 10min-hrs.
Bootstrap None Full HTTP stack Browser + Magento
Tests Class logic DB + DI container HTTP endpoints Browser interaction
Maintenance cost Low Medium Medium High
Recommended share 70-80% 15-20% 3-5% 1-5%

The recommended shares are not rigid rules, they are guideline values for a healthy test portfolio. In a module that mainly encapsulates database operations, the share of integration tests will be higher. In a module with complex price calculation logic and a simple DB schema, the unit test share will be higher. MFTF tests remain limited to critical happy paths in every case.

8. Summary

Magento's test strategy follows the general test pyramid, adapted to the four Magento-specific test types. PHPUnit unit tests form the base: fast, deterministic, no bootstrap, for every class with mockable dependencies. PHPUnit integration tests verify the interaction with the database and the DI container, repository implementations, observer chains, and plugin sequences. REST API tests check the HTTP interface, not the logic behind it. MFTF covers critical end-to-end browser paths and is kept deliberately small.

The biggest lever for a robust Magento test suite is consistently splitting tests along this pattern. Every MFTF test that is really a logic test ties up resources in the CI pipeline and slows down feedback. Every unit test that gets replaced by an integration test unnecessarily increases runtime. The right tool for the right problem, this rule matters more for test type selection than for any other aspect of Magento quality assurance.

MFTF vs. PHPUnit vs. API Tests, the Essentials at a Glance

Maintain the test pyramid

70-80% unit tests, 15-20% integration tests, at most 5% MFTF. An inverted pyramid means a slow, fragile CI pipeline.

Limit MFTF to happy paths

Complete checkout scenario, login flow. No MFTF tests for pricing logic or database operations, those belong in unit and integration tests.

API tests for the HTTP layer

REST endpoints, HTTP status codes, auth, pagination, not the business logic behind them. That belongs in unit tests.

Decision criterion

What is being tested? Logic -> unit, DB+DI -> integration, HTTP -> API, browser -> MFTF. The right tool for the right problem.

9. FAQ: MFTF vs. PHPUnit vs. API Tests in Magento 2

1What is MFTF in Magento 2?
Browser-based end-to-end framework built on Selenium. Tests in XML, checks JavaScript and CSS. Slow and maintenance-heavy, only for real browser interactions.
2When MFTF instead of PHPUnit?
Only for real browser interactions: checkout flow, JavaScript validations, Alpine.js behavior. For logic and DB, always PHPUnit.
3What do API tests verify?
The HTTP layer: status codes, JSON schema, auth, pagination. Not the business logic, that belongs in unit and integration tests.
4How long do integration tests take?
1-10 minutes per module. Bootstrap: 30-60 seconds. Move to a separate job in CI pipelines.
5MFTF for Hyva themes?
Limited. Luma selectors don't match Hyva. Playwright is the more modern alternative for JavaScript-heavy frontends like Hyva.
6Typical test split for a module?
70-80% unit tests, 15-20% integration tests, 3-5% API tests, 1-5% MFTF. Inverted pyramid means a slow, fragile CI pipeline.
7Unit test vs. integration test?
Constructor injection without framework calls means a unit test. DB or DI container access means an integration test. Separate phpunit.xml files.
8Setting up API tests in Magento?
dev/tests/api-functional, phpunit_rest.xml. A running Magento instance is required. Base class WebApiAbstract for HTTP assertions.
9MFTF or Playwright for Hyva?
Playwright for new projects: better JS support, faster, more modern API. MFTF has better Magento fixture integration, but Luma selectors.
10Which tests on commit, which nightly?
Commit/PR: unit tests (seconds). Separate stage: integration tests (minutes). Nightly: API tests and MFTF. Fast feedback on every commit, full coverage nightly.