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.
Table of Contents
- 1. What PHPUnit patterns actually solve
- 2. Test structure: using Arrange, Act, Assert and setUp correctly
- 3. Data providers: varying test cases systematically
- 4. Mocks and stubs: replacing dependencies in a controlled way
- 5. Assertions: precise and meaningful failure messages
- 6. Testing exceptions and failure scenarios
- 7. PHPUnit in Magento: separating integration and unit tests
- 8. Test performance: fast suites through clear isolation
- 9. PHPUnit patterns compared directly
- 10. Summary
- 11. FAQ
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.