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.
Table of Contents
- 1. The Magento Test Pyramid
- 2. PHPUnit Unit Tests: Logic Without a Framework
- 3. PHPUnit Integration Tests: Bootstrap with a Database
- 4. REST API Tests: Verifying the Web Service Layer in Isolation
- 5. MFTF: Browser End-to-End Tests at a Glance
- 6. Decision Tree: Which Test for Which Problem?
- 7. The Four Test Types Head to Head
- 8. Summary
- 9. FAQ
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.