50 PHPUnit Patterns for Productive PHP and Magento Projects
AI generated
@test
assert
PHPUnit · PHP · Magento · TDD
50 PHPUnit Patterns for PHP and Magento
from data providers to a complete mocking strategy

Anyone who writes tests without a clear pattern ends up with a test suite that is slow, brittle and hard to understand. Data providers, meaningful assertions, isolated mocks and a sensible coverage strategy separate tests that immediately catch a refactoring mistake from green tests that stay green despite bugs.

22 min read Data Providers · Mocks · Assertions · Coverage · Magento PHPUnit 10/11 · PHP 8.2+

1. What PHPUnit patterns actually solve

A PHPUnit pattern is not a syntactic rule but a proven solution structure for a recurring testing problem. The difference from a quickly written test is that the pattern is deliberately designed for maintainability, clarity and robustness. Tests that follow no clear patterns tend to break at the first refactoring, not because the logic is wrong, but because the test is too tightly coupled to implementation details.

In practice, four typical problems show up repeatedly: tests that check too much in a single test case and, on failure, do not reveal what exactly went wrong. Tests that fail to isolate dependencies and therefore become slow or depend on external state. Tests that provide no meaningful failure messages, forcing you to read the code to understand the failure. And tests that only check the happy path while ignoring failure states entirely. The following sections address these problems with concrete PHPUnit patterns.

2. Test structure: using Arrange, Act, Assert and setUp correctly

The AAA pattern (Arrange, Act, Assert) is the foundation for readable tests. Every test consists of three clearly separated phases: in the Arrange phase, the state is prepared, objects are instantiated, mocks are configured, data is built up. In the Act phase, exactly one action is executed, the method is called, the command is dispatched. In the Assert phase, the result is checked, ideally with a single assert call or several assertions about the same fact. Tests with many assertions covering different aspects signal that the test carries multiple responsibilities.

The setUp() and tearDown() methods are meant for initialization logic that is identical across every test in the class. They are frequently overloaded with logic that is only relevant to a subset of the tests, which leads to new tests inheriting unclear state. The PHPUnit pattern is to keep setUp() as minimal as possible and to move test-specific preparation into private helper methods such as createProductWithPrice(9.99). That way every test is self-explanatory without having to look at setUp().


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Mironsoft\Catalog\Model\PriceCalculator;
use Mironsoft\Catalog\Model\TaxProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Unit tests for PriceCalculator using AAA pattern and minimal setUp.
 */
final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;
    private MockObject&TaxProvider $taxProvider;

    protected function setUp(): void
    {
        // setUp: only shared, truly universal initialization
        $this->taxProvider = $this->createMock(TaxProvider::class);
        $this->calculator  = new PriceCalculator($this->taxProvider);
    }

    public function testNetPriceIsCalculatedCorrectly(): void
    {
        // Arrange
        $this->taxProvider->method('getRate')->willReturn(0.19);
        $grossPrice = 119.00;

        // Act
        $netPrice = $this->calculator->calculateNet($grossPrice);

        // Assert
        self::assertEqualsWithDelta(100.00, $netPrice, 0.001, 'Net price must equal gross / (1 + tax rate)');
    }

    public function testZeroTaxRateReturnsGrossUnchanged(): void
    {
        // Arrange
        $this->taxProvider->method('getRate')->willReturn(0.0);

        // Act
        $result = $this->calculator->calculateNet(99.00);

        // Assert
        self::assertSame(99.00, $result, 'Zero tax rate must return gross price as-is');
    }
}

3. Data providers: varying test cases systematically

The #[DataProvider] attribute (PHPUnit 10+) is one of the most effective PHPUnit patterns for systematic test coverage. Instead of copying the same test logic with different inputs into separate methods, you define a static method that returns an array of test cases. PHPUnit runs the test for each data set separately and, on failure, shows exactly which data set failed. This prevents the first bad data set from hiding all the ones that follow it.

A good data provider names every data set with a descriptive string key: 'negative price returns zero', 'price with maximum precision'. PHPUnit uses this key in the failure output, which speeds up diagnosis considerably. The PHPUnit pattern for data providers: the method is public static, returns an associative array, and contains both valid and edge-case inputs. Boundary values, the smallest, the largest and the threshold, belong in every good data provider.

4. Mocks and stubs: replacing dependencies in a controlled way

The difference between a mock and a stub matters conceptually: a stub replaces a dependency with a simple return value without any behavior verification. A mock additionally expects certain methods to be called in a certain way, and fails the test if that does not happen. The most common failure pattern: using mocks where stubs would suffice. That leads to tests that break because the internal call order changed, even though the behavior from the caller's perspective stays correct.

PHPUnit 10+ offers a clean separation with createMock(), createStub() and the intersection-type syntax MockObject&InterfaceType. The PHPUnit pattern: stubs for read access on dependencies (willReturn, willReturnMap), mocks only when the test explicitly needs to verify that an action was performed (expects($this->once())). willReturnCallback() enables complex stub logic without having to write a full fake implementation.


<?php

declare(strict_types=1);

namespace Mironsoft\Order\Test\Unit\Model;

use Mironsoft\Order\Api\EmailSenderInterface;
use Mironsoft\Order\Model\OrderConfirmationService;
use Mironsoft\Order\Model\OrderRepository;
use PHPUnit\Framework\TestCase;

/**
 * Demonstrates the stub vs. mock distinction in PHPUnit.
 */
final class OrderConfirmationServiceTest extends TestCase
{
    /**
     * Stub: repository returns order, no call-count verification needed.
     * Mock: email sender must be called exactly once, we verify the side effect.
     */
    public function testConfirmationEmailIsSentOnSuccessfulOrder(): void
    {
        // Arrange: stub for read dependency
        $repository = $this->createStub(OrderRepository::class);
        $repository->method('getById')->willReturn($this->buildOrder(42, 'pending'));

        // Mock for write/action dependency: verify it is actually called
        $emailSender = $this->createMock(EmailSenderInterface::class);
        $emailSender->expects($this->once())
            ->method('sendOrderConfirmation')
            ->with($this->equalTo(42));

        $service = new OrderConfirmationService($repository, $emailSender);

        // Act
        $service->confirm(42);

        // Assert: verified via mock expectation above
    }

    #[\PHPUnit\Framework\Attributes\DataProvider('orderStatusProvider')]
    public function testOnlyPendingOrdersAreSentConfirmation(string $status, bool $expectEmail): void
    {
        $repository  = $this->createStub(OrderRepository::class);
        $repository->method('getById')->willReturn($this->buildOrder(1, $status));
        $emailSender = $this->createMock(EmailSenderInterface::class);
        $emailSender->expects($expectEmail ? $this->once() : $this->never())
            ->method('sendOrderConfirmation');

        (new OrderConfirmationService($repository, $emailSender))->confirm(1);
    }

    public static function orderStatusProvider(): array
    {
        return [
            'pending order receives email'    => ['pending', true],
            'processing order receives email' => ['processing', false],
            'complete order skips email'      => ['complete', false],
            'canceled order skips email'      => ['canceled', false],
        ];
    }

    private function buildOrder(int $id, string $status): object
    {
        return new readonly class($id, $status) {
            public function __construct(public int $id, public string $status) {}
        };
    }
}

5. Assertions: precise and meaningful failure messages

Weak assertions are the main reason developers have to dig into the code when a test fails instead of just reading the test output. assertTrue($result) only tells you that something was wrong. assertSame('expected', $result, 'Discount calculation returned wrong value for 10% tier') tells you what was wrong and why it matters. The PHPUnit pattern: always use the most specific assertion available, always add a descriptive message as the third argument, and never use assertTrue for comparisons when assertSame, assertEquals or assertInstanceOf communicate directly what is being checked.

PHPUnit offers more than 60 assertion methods. assertEqualsWithDelta() for floating-point comparisons, assertMatchesRegularExpression() for patterns, assertJsonStringEqualsJsonString() for API responses, assertSameSize() for collections. The PHPUnit pattern for custom assertions: an abstract base class for tests within the same module that encapsulates domain-specific assertValidProduct() methods as reusable assertions. This prevents duplicated assertion code spread across many test classes.

6. Testing exceptions and failure scenarios

Failure scenarios are the most commonly neglected area in PHP test suites. The PHPUnit pattern for exception tests in PHPUnit 10+ is the method $this->expectException(InvalidArgumentException::class) called before the Act step, optionally combined with $this->expectExceptionMessage(). Important: right after the expectException() call comes the code that triggers the exception. No further assertion is needed afterward, PHPUnit fails the test automatically if no exception is thrown.

A common failure pattern: the exception is caught with a try/catch block and manually confirmed with assertTrue(true). That is not only cumbersome but also masks failures when the wrong piece of code throws the exception. The correct PHPUnit pattern: combine expectException and expectExceptionMessage to verify both type and message. For error codes: expectExceptionCode(). For complex exception data: catch the exception, check its properties, and then use $this->fail() to make sure no further assertion runs without an exception having been thrown.

7. PHPUnit in Magento: separating integration and unit tests

Magento ships two phpunit.xml configurations: dev/tests/unit/phpunit.xml for unit tests without a bootstrap and dev/tests/integration/phpunit.xml for integration tests with a full Magento bootstrap. The PHPUnit pattern for Magento: unit tests for all pure PHP classes, ViewModels, helpers, model logic, that require no Magento infrastructure. Integration tests only for code that touches the database, the ObjectManager or layout rendering. This separation keeps the unit test suite under one second per class.

In a Magento context, calling ObjectManager::getInstance() directly inside tests is an anti-pattern. Instead, use the ObjectManagerHelper from the Magento testing framework, or avoid the ObjectManager dependency in production code entirely through consistent constructor injection. Tests for ViewModels are especially straightforward: the ViewModel receives all dependencies as mocks, and the business logic is fully testable without a Magento bootstrap. That is one of the main advantages of the ViewModel pattern over block classes.


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\ViewModel;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\ViewModel\ProductBadgeViewModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Unit test for ViewModel, no Magento bootstrap required.
 */
final class ProductBadgeViewModelTest extends TestCase
{
    private MockObject&ProductRepositoryInterface $productRepository;
    private ProductBadgeViewModel $viewModel;

    protected function setUp(): void
    {
        $this->productRepository = $this->createMock(ProductRepositoryInterface::class);
        $this->viewModel         = new ProductBadgeViewModel($this->productRepository);
    }

    #[\PHPUnit\Framework\Attributes\DataProvider('badgeDataProvider')]
    public function testBadgeLabelIsCorrectForPrice(float $price, string $expectedBadge): void
    {
        $product = $this->createStub(ProductInterface::class);
        $product->method('getFinalPrice')->willReturn($price);

        $this->productRepository->method('getById')->willReturn($product);

        $badge = $this->viewModel->getBadgeLabel(1);

        self::assertSame($expectedBadge, $badge, sprintf(
            'Expected badge "%s" for price %.2f',
            $expectedBadge,
            $price
        ));
    }

    public static function badgeDataProvider(): array
    {
        return [
            'free product gets FREE badge'     => [0.0,  'FREE'],
            'budget product gets DEAL badge'   => [4.99, 'DEAL'],
            'standard product has empty badge' => [9.99, ''],
            'premium product has empty badge'  => [99.0, ''],
        ];
    }
}

8. Test performance: fast suites through clear isolation

A slow test suite does not get run. That is the basic principle behind the PHPUnit pattern for test performance. Every unit test that takes more than 100 ms is a sign that a real dependency is not mocked, whether that is a database query, an HTTP call, or file system access. --testdox and --log-junit provide the data needed to identify slow tests. PHPUnit 10 offers the stopOnDefect attribute, which stops the suite after the first failure, saving time in CI pipelines that are already blocked.

The PHPUnit pattern for test groups: annotate tests with #[Group('slow')] and exclude them either in phpunit.xml or via a command-line argument (--exclude-group slow). Integration tests run in a dedicated CI job, unit tests run as a pre-commit hook in under five seconds. Shared fixtures via #[BeforeClass] reduce setup overhead when many tests need the same expensive initialization, for example reading a large test file or building an object graph.

9. PHPUnit patterns compared directly

Many everyday testing tasks can be solved in different ways, with significant differences in readability, diagnosability and maintenance effort. Choosing the right PHPUnit pattern is not a matter of style, it directly affects how quickly a failing test can be diagnosed.

Task Anti-Pattern Recommended PHPUnit Pattern Benefit
Testing multiple inputs Duplicated test methods #[DataProvider] All cases visible, clear failure naming
Checking equality assertTrue($a === $b) assertSame($a, $b, 'msg') Shows actual and expected value on failure
Checking exceptions try/catch + assertTrue(true) expectException() Clear, PHPUnit manages the assertion
Isolating read access Mock + expects($this->once()) createStub() Test does not break during refactoring
Comparing floats assertEquals(1.1+2.2, 3.3) assertEqualsWithDelta() Accounts for floating-point imprecision

The most common cause of brittle test suites is over-specifying mocks. When every method on every mock is wrapped in expects($this->once()), the test fails on every internal refactoring, even when the externally visible behavior stays unchanged. The correct PHPUnit pattern: only verify what the test genuinely needs to know from its own perspective. Everything else should be a stub.

Mironsoft

PHPUnit consulting, test strategy and CI integration for PHP and Magento

A test suite that catches problems the moment you refactor?

We analyze existing PHPUnit suites, identify brittle patterns and replace them with robust PHPUnit patterns, including a complete mocking strategy, systematic data providers and CI integration for Magento and PHP projects.

Test Audit

Analysis of existing tests for anti-patterns, over-specified mocks and missing edge cases

Refactoring

Introducing data-provider conversion, a mocking strategy and meaningful assertions

CI Integration

Setting up PHPUnit in pipelines, coverage reports and automatic quality thresholds

10. Summary

The most important PHPUnit patterns for PHP and Magento always solve the same underlying problem: tests written without a clear pattern turn into a maintenance burden that nobody wants to touch. The AAA pattern provides readability. Data providers give systematic test coverage without code duplication. Stubs instead of mocks for read access prevents brittle tests during refactoring. Meaningful assertions with descriptive messages speed up diagnosis. And a clear separation of unit and integration tests keeps the suite fast enough to run on every commit.

The biggest lever lies in applying these patterns consistently across every test in a project. A single test with a fully fledged data provider next to ten tests with copied methods creates uneven maintainability. A shared abstract test base class within the module, a phpunit.xml with clear groups, and a CI job that runs unit tests in under ten seconds, these are the structural measures that turn a chaotic test suite into a productive one.

50 PHPUnit Patterns, the essentials at a glance

Test structure

AAA pattern (Arrange, Act, Assert) in every test. Keep setUp() minimal. Move test-specific preparation into private helper methods.

Data Providers

#[DataProvider] for every variant of a piece of test logic. Name data sets with descriptive string keys. Always include boundary values.

Mocks vs. Stubs

Stubs for read access, mocks only to verify actions. Avoid over-specified mocks, they break on every refactoring.

Magento specifics

Unit tests without a bootstrap for ViewModels and pure PHP classes. Integration tests only for database access and ObjectManager-dependent code.

11. FAQ: PHPUnit Patterns for PHP and Magento

1Mock vs. stub in PHPUnit?
A stub returns controlled values without verification. A mock additionally verifies call count and arguments. Use stubs for read access, mocks only for actions that need verification.
2Data providers in PHPUnit 10?
Attribute #[DataProvider('methodName')] above the test method. Provider method is public static, returns an associative array. Each data set runs as its own test.
3When integration instead of unit tests?
Integration for database access, ObjectManager and layout rendering. For pure PHP logic always unit tests, faster, easier to write and to debug.
4Testing exceptions in PHPUnit 10?
$this->expectException() before the triggering code. Optionally expectExceptionMessage(). No try/catch, PHPUnit fails if no exception occurs.
5Preventing brittle tests during refactoring?
Stubs instead of mocks for read access. Only verify the public contract, do not mock internal method calls. Use expects($this->once()) sparingly.
6assertSame vs. assertTrue for comparisons?
assertSame shows the actual and expected value on failure. assertTrue only outputs "false is not true". Always choose the most specific assertion.
7Testing Magento ViewModels without a bootstrap?
Constructor injection allows mocking all dependencies. PHPUnit instantiates ViewModels directly, no Magento bootstrap needed. Main advantage over block classes.
8What is the AAA pattern?
Arrange-Act-Assert: prepare state, execute one action, check the result. Every test follows exactly this structure. More than one Act phase signals too many responsibilities.
9Speeding up a slow test suite?
Mark slow tests with #[Group('slow')] and exclude them from the standard suite. Unit tests must run in under five seconds. Always mock real I/O access.
10assertEqualsWithDelta for floats?
Floating-point arithmetic produces minimal imprecision. assertEqualsWithDelta(expected, actual, 0.0001) allows a tolerance and prevents random failures caused by IEEE-754 rounding.