lead
Every Magento 2 testing guide opens with the pyramid, and the pyramid is right — but it is drawn for applications where a class is a class. Magento is a dependency-injection framework with code generation, interceptors, and an application state that survives between tests unless you tell it not to, and three specific consequences follow: a unit test that mocks a repository is testing almost nothing about how the class behaves once plugins are wired around it, an integration test that hand-rolls its own fixtures and transactions will be slower and flakier than one that uses the framework’s, and the coverage number your CI prints is the least informative signal in the whole pipeline. This is the practical version — what to test at each level, the framework mechanics that make the middle layer work, and a complete pipeline at the end.
The single most common mistake in Magento 2 integration tests: extending PHPUnit\Framework\TestCase, calling ObjectManager::getInstance(), and building fixtures in setUpBeforeClass() with a hand-written transaction trait. Magento already ships @magentoDataFixture, @magentoDbIsolation and @magentoAppIsolation, and they do considerably more than a beginTransaction(). If your suite reimplements them, it will not run under dev:tests:run integration and it will leak state. Section III has the correct shape.
baseline: the testing pyramid, and where Magento bends it
A. The Pyramid Concept
/\
/E2E\ (Few, Expensive, Slow)
/-----\
/ API \ (Medium, Moderate, Medium speed)
/---------\
/ Unit \ (Many, Cheap, Fast)
/__________\
Ideal distribution:
- Unit tests: 70% (fast feedback, cheap to write)
- Integration/API tests: 20% (real scenarios, moderate cost)
- End-to-End tests: 10% (production-like, expensive)
Magento project typical:
- 50 unit tests
- 20 integration tests
- 5 E2E tests (JMeter, Selenium)
- Total: ~30 min CI run (acceptable)
B. Test Types Defined
Unit Test: Single class, all dependencies mocked
class InventoryServiceTest extends \PHPUnit\Framework\TestCase {
public function testReserveStock() {
$repo = $this->createMock(Repository::class);
$service = new InventoryService($repo);
$result = $service->reserveStock(1, 10);
$this->assertTrue($result);
}
}
// Fast (10ms), focused, isolates bugs
Integration Test: Multiple classes, real dependencies
class CheckoutIntegrationTest extends \PHPUnit\Framework\TestCase {
public function testCheckoutFlow() {
$objectManager = ObjectManager::getInstance();
$checkout = $objectManager->create(CheckoutService::class);
$order = $checkout->createOrder(123, 1);
$this->assertNotNull($order->getId());
}
}
// Slow (1000ms), realistic, catches integration bugs
End-to-End Test: Browser/HTTP, entire stack
// JMeter test: Add product to cart, checkout, pay
// Slow (5000ms+), production-like, expensive
II. Unit Testing Foundation (PHPUnit)
A. Anatomy of a Unit Test
namespace MyCompany\Inventory\Test\Unit;
class InventoryServiceTest extends \PHPUnit\Framework\TestCase {
private $inventoryService;
private $mockRepository;
// 1. Setup: Run before each test
protected function setUp(): void {
$this->mockRepository = $this->createMock(InventoryRepository::class);
$this->inventoryService = new InventoryService($this->mockRepository);
}
// 2. Test method (must start with "test")
public function testGetAvailableStockSuccess() {
// 3a. Arrange (setup mocks)
$this->mockRepository
->method('get')
->with(123)
->willReturn((new InventoryData())->setQty(100));
// 3b. Act (call the method)
$result = $this->inventoryService->getAvailableStock(123);
// 3c. Assert (verify result)
$this->assertEquals(100, $result);
}
// Another test (test failure case)
public function testGetAvailableStockNotFound() {
// 3a. Arrange
$this->mockRepository
->method('get')
->willThrowException(new \Exception('Not found'));
// 3c. Assert (expect exception)
$this->expectException(\Exception::class);
// 3b. Act
$this->inventoryService->getAvailableStock(999);
}
// Teardown: Run after each test (cleanup)
protected function tearDown(): void {
unset($this->inventoryService);
}
}
B. Mocking Patterns
Mock method returns value:
$mock->method('getValue')->willReturn(100);
Mock method is called with specific arguments:
$mock->expects($this->once())
->method('save')
->with($this->equalTo($product));
Mock method throws exception:
$mock->method('validate')
->willThrowException(new ValidationException('Invalid'));
Mock method called multiple times with different args:
$mock->method('process')
->will($this->onConsecutiveCalls(
$this->returnValue(true),
$this->returnValue(false),
$this->returnValue(true)
));
C. Common Pitfalls
❌ Wrong: Over-mocking (mocking too much)
$mockInventory = $this->createMock(InventoryService::class);
$mockPayment = $this->createMock(PaymentService::class);
$mockOrder = $this->createMock(OrderService::class);
$checkout = new CheckoutService($mockInventory, $mockPayment, $mockOrder);
// Not testing anything! All dependencies are mocks
✅ Right: Mock only external dependencies
$realInventory = new InventoryService($mockRepository);
$realPayment = new PaymentService($mockGateway);
$checkout = new CheckoutService($realInventory, $realPayment, $mockLogger);
// Test the real Checkout logic + integration with Inventory/Payment
❌ Wrong: Test naming is unclear
public function test1() { ... }
public function testFoo() { ... }
✅ Right: Test name describes the scenario
public function testReserveStockWhenSufficientQuantity() { ... }
public function testReserveStockThrowsWhenInsufficientQuantity() { ... }
III. Integration testing (use Magento’s framework, not your own)
This is the section where most home-grown Magento test setups go wrong, so it is worth being blunt: do not hand-roll fixture creation and transaction handling. Magento ships an integration test framework that already does both, and a test suite that reimplements them with ObjectManager::getInstance() will be slower, flakier, and will not run under dev:tests:run integration.
Two rules cover almost all of it.
A. Fixtures are annotations, not setUpBeforeClass()
Magento resolves fixtures through docblock annotations. The framework runs them before the test, tracks what they created, and reverts them afterwards — including the entities your code created as a side effect.
<?php
declare(strict_types=1);
namespace MyCompany\Checkout\Test\Integration;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class CheckoutTest extends TestCase
{
/**
* @magentoDataFixture Magento/Catalog/_files/product_simple.php
* @magentoDataFixture Magento/Customer/_files/customer.php
* @magentoDbIsolation enabled
* @magentoAppIsolation enabled
*/
public function testCheckoutCreatesOrder(): void
{
$objectManager = Bootstrap::getObjectManager();
$checkout = $objectManager->get(\MyCompany\Checkout\Api\CheckoutServiceInterface::class);
$order = $checkout->place('customer@example.com', ['simple']);
$this->assertNotNull($order->getEntityId());
$this->assertSame('pending', $order->getStatus());
}
}
Bootstrap::getObjectManager() is the sanctioned way to reach the container in a test — it is not the same thing as ObjectManager::getInstance() in production code, which stays banned. Core ships a large library of reusable fixture files under each module’s Test/_files/; reach for those before writing your own, and write project fixtures in the same shape when you must.
B. Isolation is a flag, not a trait
@magentoDbIsolation enabled wraps the test in a transaction and rolls it back. @magentoAppIsolation enabled reinitialises the application between tests so one test’s config or registry state cannot leak into the next — the cause of the classic "passes alone, fails in the suite" bug. Both are one line each.
Set the default for a whole suite in dev/tests/integration/phpunit.xml rather than annotating every method, and override per-test only where a test genuinely needs to commit.
# Run the integration suite the supported way
ddev exec php bin/magento dev:tests:run integration
# Or target one test class through the integration phpunit config
ddev exec vendor/bin/phpunit -c dev/tests/integration/phpunit.xml \
--filter CheckoutTest
What breaks if you hand-roll it instead: fixtures leak between tests, tearDown() cleanup silently misses entities created deep in a service call, @magentoConfigFixture and @magentoAppArea become unavailable, and the suite cannot run in CI through Magento’s own runner. The framework is doing considerably more than a beginTransaction().
C. Integration Test Best Practices
- Use real database (not mocked)
- Isolate with transactions (cleanup after each test)
- Seed fixture data (products, customers, orders)
- Keep fixtures minimal (only what test needs)
- Avoid database state dependencies (each test should work independently)
IV. Functional tests: MFTF, and whether it is worth it
Magento ships a fourth test type this article has so far ignored, and ignoring it silently would be the wrong kind of tidy. The Magento Functional Testing Framework drives a real browser through a real storefront using XML-declared test steps, and it is the only layer that can answer "does checkout work" rather than "does the checkout service return an order object".
It is also the layer teams most often abandon, and the reasons are worth stating plainly rather than discovered after two sprints.
What MFTF is good at: the handful of paths where a regression is unacceptable and no lower test can prove them — guest checkout end to end, customer registration, applying a coupon, placing an order against a real payment sandbox. Ten to twenty tests, not two hundred.
What it is bad at: everything else. Runs are slow (minutes per test), the XML step vocabulary is a language of its own that no other project will teach you, tests break on unrelated theme changes, and the flakiness tax on a large suite is high enough that teams start re-running red builds by reflex — which is worse than having no suite, because it trains everyone to distrust CI.
# Generate and run a single functional suite
ddev exec vendor/bin/mftf generate:tests
ddev exec vendor/bin/mftf run:group checkout
The pragmatic position: keep MFTF for the critical revenue paths and nothing else, run it nightly and pre-release rather than on every push, and treat a flaky MFTF test as a broken test to be fixed or deleted — never as noise to be re-run. If your storefront is a custom Hyvä build, check MFTF coverage assumptions before investing: much of the shipped test library targets Luma selectors, and porting it is real work that nobody budgets for.
Where MFTF costs more than it returns, the substitute is not "no coverage" — it is the staging smoke tests in Section VIII, which cover the same paths with a fraction of the maintenance and a fraction of the confidence. That is an honest trade, and worth making deliberately.
V. Mutation Testing (Verify Test Quality)
A. What is Mutation Testing?
Tool that deliberately breaks your code to verify tests catch it.
Example:
// Original code
public function isEligible($qty) {
if ($qty >= 10) {
return true;
}
return false;
}
// Mutation 1: Change >= to >
public function isEligible($qty) {
if ($qty > 10) { // MUTATED
return true;
}
return false;
}
// Does your test catch this? If not, you have a gap.
B. Using Infection (PHP Mutation Testing)
ddev composer require --dev infection/infection
# Run mutation testing
ddev exec vendor/bin/infection --min-covered-msi=80
Example output:
Mutation Testing Report
========================
Total: 50 mutations
Killed: 45 (your tests caught these)
Escaped: 5 (your tests DIDN'T catch these)
Coverage: 90%
MSI (Mutation Score Index): 90%
Interpretation:
- MSI 90% = strong tests (catching 90% of bugs)
- MSI <80% = weak tests (many bugs escape)
C. Common Mutations Infection Catches
1. Boundary changes: >= → >
2. Return value changes: true → false
3. Operator changes: + → -
4. Conditional removal: if (...) {...} → {...}
Example test gap:
public function testIsEligible() {
$this->assertTrue($this->service->isEligible(10)); // Only tests boundary, not below
}
// Mutation: >= to > ESCAPES (test passes, but code is wrong for edge case)
Fixed test:
public function testIsEligibleBoundary() {
$this->assertTrue($this->service->isEligible(10)); // Exactly at boundary
}
public function testIsEligibleAbove() {
$this->assertTrue($this->service->isEligible(11)); // Above boundary
}
public function testIsEligibleBelow() {
$this->assertFalse($this->service->isEligible(9)); // Below boundary
}
// Mutation: >= to > is caught by testIsEligibleBoundary
VI. Performance Regression Detection
A. Performance Benchmark Tests
Measure that code changes don’t slow down critical paths.
class CheckoutPerformanceTest extends \PHPUnit\Framework\TestCase {
private $timer;
protected function setUp(): void {
$this->timer = new Timer();
}
public function testCheckoutCreationPerformance() {
$this->timer->start();
// Create 100 orders in a loop
for ($i = 0; $i < 100; $i++) {
$order = $this->checkoutService->createOrder(
$i + 1, // different customer each time
$this->getSampleProducts()
);
}
$elapsed = $this->timer->elapsed();
$avgTimePerOrder = $elapsed / 100;
// Assert: average time < 100ms per order
$this->assertLessThan(100, $avgTimePerOrder,
"Checkout is too slow: {$avgTimePerOrder}ms per order (should be <100ms)"
);
}
}
B. Blackfire Integration (Production Profiling)
# Profile code in CI
blackfire-player run scenario.bkf \
--variables='{"base_url":"http://staging.local"}' \
--json-output=profile.json
# Compare to baseline
blackfire compare baseline.json current.json
Output:
Comparison Results
==================
Baseline: 1200ms
Current: 1500ms
Change: +25% ❌ REGRESSION!
C. New Relic Monitoring (Production)
# Automatic monitoring in production
# Detects regressions post-deployment
Action: If regression detected:
1. Check recent commits
2. Roll back if >20% slower
3. Investigate root cause (query, loop, algorithm)
VII. Static Analysis Gates (Automated Code Quality)
A. PHPStan (Static Type Checking)
ddev composer analyse
# Output:
# Line 42: Calling undefined method MyClass::invalidMethod()
# Line 55: Parameter $qty of type int, passed string
Fix issues before deployment:
# phpstan.neon
level: 6 # Levels 0-9; higher = stricter
paths:
- app/code
B. PHP Code Sniffer (Style & Best Practices)
ddev composer phpcs -- ./app/code
# Output:
# Line 10: Opening brace should be on the same line
# Line 25: Line exceeds 120 characters
C. Psalm (Probabilistic Type Checker)
ddev exec vendor/bin/psalm
# Output:
# Line 50: Cannot access property $data on null
# Line 75: Argument $qty is redundant, it is never used
D. CI Gate (Fail Build on Issues)
# .github/workflows/ci.yml
- name: Run static analysis
run: |
vendor/bin/phpstan analyse --memory-limit=1G ./app/code
vendor/bin/phpcs --standard=Magento2 --extensions=php,phtml ./app/code
vendor/bin/psalm
# If any fails, build fails ❌
VIII. Integration Tests on Staging (Pre-Deployment)
A. Smoke Tests (Critical Path Validation)
#!/bin/bash
# smoke-test.sh: Verify critical functionality before production deploy
echo "Testing Staging Environment..."
# 1. Homepage loads
curl -f http://staging.brocode.at/ > /dev/null
echo "✓ Homepage loads"
# 2. Product page loads
curl -f http://staging.brocode.at/product/test-product > /dev/null
echo "✓ Product page loads"
# 3. Search works
curl -f "http://staging.brocode.at/search?q=jacket" > /dev/null
echo "✓ Search returns results"
# 4. Add to cart (requires login)
curl -f -b cookies.txt -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"product_id": 1, "qty": 1}' \
http://staging.brocode.at/api/cart/add > /dev/null
echo "✓ Add to cart works"
# 5. Checkout (partial, no payment)
curl -f -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}' \
http://staging.brocode.at/api/checkout/validate > /dev/null
echo "✓ Checkout validation works"
echo "✅ All smoke tests passed!"
Run before deploying to production:
./smoke-test.sh || exit 1
# If any fails, abort deployment
B. JMeter for Load Testing (Throughput Validation)
# jmeter-checkout.jmx
Thread Groups:
- Users: 10
Ramp-up: 30s
Duration: 5m
Request Samplers:
- GET /product/test
- POST /cart/add (with product data)
- POST /checkout/validate (with email)
- Wait for payment response
Assertions:
- Response time < 1000ms (p95)
- Error rate < 1%
- Throughput > 5 checkout/sec
Run before production deploy:
jmeter -n -t jmeter-checkout.jmx -l results.jtl
jmeter-results-analyzer results.jtl
# If assertions fail, don't deploy
working_example: the whole pipeline, end to end
A. Complete CI Workflow
name: CI
on:
push:
branches: [develop, main]
pull_request:
branches: [develop]
jobs:
tests:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout code
- uses: actions/checkout@v3
# Step 2: Setup environment
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
- name: Install Composer dependencies
run: composer install
# Step 3: Lint & Static Analysis
- name: PHP Linting
run: vendor/bin/phplint app/code
- name: PHPStan Analysis
run: vendor/bin/phpstan analyse --memory-limit=1G ./app/code
- name: Code Style Check
run: vendor/bin/phpcs --standard=Magento2 --extensions=php,phtml ./app/code
# Step 4: Unit & Integration Tests
- name: Unit Tests
run: vendor/bin/phpunit --testsuite unit
- name: Integration Tests
run: vendor/bin/phpunit --testsuite integration
# Step 5: Mutation Testing
- name: Mutation Testing
run: vendor/bin/infection --min-covered-msi=80
# Step 6: Performance Tests
- name: Performance Benchmarks
run: vendor/bin/phpunit --testsuite performance
# Step 7: Deploy to Staging (if all pass)
- name: Deploy to Staging
if: success()
run: |
./deploy-to-staging.sh
./smoke-test.sh
# Step 8: Notify on failure
- name: Notify Slack (failure)
if: failure()
run: |
curl -X POST $SLACK_WEBHOOK \
-d '{"text": "CI Failed: ${{ github.repository }} ${{ github.ref }}"}'
B. Deployment Gate (Manual Approval for Production)
production-deploy:
needs: [tests] # Wait for tests to pass
runs-on: ubuntu-latest
environment: production # Requires approval
steps:
- uses: actions/checkout@v3
- name: Deploy to Production
run: ./deploy-to-production.sh
- name: Run Production Smoke Tests
run: ./smoke-test.sh --production
- name: Monitor for 5 minutes
run: sleep 300
- name: Check Error Rates
run: |
ERROR_RATE=$(curl http://monitoring.internal/error-rate)
if [ $ERROR_RATE -gt 2 ]; then
echo "Error rate too high, rolling back"
./rollback.sh
exit 1
fi
tradeoff: coverage targets, and the number that lies
A. Coverage Definition
% of code lines executed by tests
ddev exec vendor/bin/phpunit --coverage-html=coverage/
# Generates HTML report showing uncovered lines
Coverage targets:
- Unit tests: 80%+ coverage (achievable, practical)
- Integration tests: 20%+ (adds realistic scenario coverage)
- Total: 90%+ combined
Uncovered code (acceptable):
- Exception handlers (hard to test)
- Error logging (low value testing)
- Boilerplate getters/setters (auto-generated)
B. Coverage Tool Integration
# .github/workflows/ci.yml
- name: Generate Coverage Report
run: vendor/bin/phpunit --coverage-clover=coverage.xml
- name: Upload to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
flags: unittests
C. Failing Build on Low Coverage
<!-- phpunit.xml -->
<coverage>
<include>
<directory suffix=".php">app/code</directory>
</include>
<report>
<clover outputFile="coverage.xml" />
</report>
<threshold unit="percent">
<perItem>80</perItem> <!-- Each file must have 80% -->
<perClass>80</perClass>
<perMethod>75</perMethod>
<statement>85</statement>
<covered>85</covered>
</threshold>
</coverage>
If coverage drops below threshold, build fails.
XI. Test Debugging (When Tests Fail)
A. Print Debugging
public function testCheckoutFlow() {
var_dump($order->getData()); // Print order data
echo "Order ID: " . $order->getId() . "\n";
$this->assertTrue($order->getId() > 0);
}
Run single test:
ddev exec vendor/bin/phpunit --filter testCheckoutFlow
B. Xdebug Breakpoints
public function testCheckoutFlow() {
xdebug_break(); // Debugger stops here
$order = $this->checkoutService->createOrder(...);
xdebug_var_dump($order->getData()); // Pretty print
$this->assertTrue($order->getId() > 0);
}
Run with debugger:
XDEBUG_CONFIG="idekey=phpstorm" vendor/bin/phpunit
C. Test Output Modes
# Show test progress (verbose)
ddev exec vendor/bin/phpunit -v
# Show only failures
ddev exec vendor/bin/phpunit --testdox
# Show timings (slow tests first)
ddev exec vendor/bin/phpunit --order-by=duration
verification: the testing checklist
Before Committing
- [ ] Unit tests pass:
vendor/bin/phpunit --testsuite unit - [ ] Code quality:
ddev composer analyse && ddev composer phpcs - [ ] Coverage maintained:
vendor/bin/phpunit --coverage-text
Before Merging PR
- [ ] All tests pass (CI green ✅)
- [ ] Integration tests pass
- [ ] Mutation testing MSI >80%
- [ ] Code coverage not decreased
Before Deploying to Production
- [ ] Performance tests pass (no regressions)
- [ ] Smoke tests pass on staging
- [ ] JMeter load test passes
- [ ] Production monitoring is ready
XIII. Timeline for Testing Implementation
Week 1: Unit Tests Foundation
- [ ] Setup PHPUnit in project
- [ ] Write 10 basic unit tests
- [ ] Configure CI to run tests on each commit
Week 2–3: Integration Tests
- [ ] Setup test database
- [ ] Write 10 integration tests
- [ ] Configure fixture data management
Week 4: Coverage & Mutation
- [ ] Add code coverage reporting
- [ ] Measure baseline coverage (likely 40–60%)
- [ ] Add Infection mutation testing
- [ ] Identify test gaps (low coverage areas)
Week 5: Performance & Staging
- [ ] Write performance benchmarks
- [ ] Deploy to staging
- [ ] Write smoke test script
- [ ] Setup JMeter load tests
Week 6+: Continuous Improvement
- [ ] Increase coverage to 85%+
- [ ] Maintain MSI >80%
- [ ] Monitor production error rates
- [ ] Iterate on tests based on real bugs found
Related reading
- Magento 2 Module Architecture: From Monolith to Modules — testable code is mostly a design problem; this is the design half.
- Magento Root Cause Analysis: Five Whys That Stick — "what should have caught this?" is usually a missing test in this pipeline.
- Magento 2 Incident Recovery — Scenario 2 is what a pipeline without a deployment gate feels like.
- Magento Elasticsearch Tuning with Real Benchmarks — performance regression tests are only as good as the baseline you measured.
- When to Ship vs Build It Right in Magento 2 — the same judgement call, applied to how much test surface a change deserves.
Sources & References
- Adobe Commerce: application testing — the umbrella for unit, integration, functional and API tests.
- Adobe Commerce: integration testing — annotations, fixture files, and the isolation flags in Section III.
- PHPUnit documentation — assertions, test doubles, and suite configuration.
- Infection — PHP mutation testing — MSI and covered-MSI, the metrics behind Section IV.
- PHPStan and Magento coding standard — the static gates in Section VI.
Disclaimer
Timings and coverage targets here are starting points for a mid-size codebase, not universal thresholds — a module with heavy business rules deserves a higher bar than a thin integration adapter. The pipeline is written as GitHub Actions because it reads clearly; the stage order, not the syntax, is the transferable part.