Magento 2 Testing in CI/CD: Unit Tests to Confidence

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

Sources & References

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.

Related Topics

← Previous

Written in collaboration with AI (Claude, by Anthropic). Ideas, verification, and accountability are mine; research and drafting are AI-assisted. Full disclosure → · Found an error? Tell me.