Custom Assertions: Writing Your Own Assertions for Domain Objects
AI generated
@test
assert
PHPUnit · Custom Assertions · Domain Testing
Custom Assertions for Domain Objects
Why assertMoneyEquals reads better than a chain of assertEquals calls

A chain of multiple assertEquals calls comparing individual fields of a domain object is tedious to write and only delivers fragmentary information on failure. Custom, domain specific assertions such as assertMoneyEquals or assertValidOrderState bundle these comparisons in one place and produce failure messages that immediately show what went wrong from a business perspective. This article explains how such assertions come together and when the effort pays off.

15 min read Custom Assertions Testing Domain Objects

1. The problem with generic assertEquals chains

Domain objects like a Money value object or an Order entity often consist of several fields that together describe a business state. A Money object typically has an amount and a currency, an Order has a status, a customer number, and a list of line items. A naive test compares each of these fields individually with assertEquals or assertSame, which quickly turns into five or six assertion lines for a single business level comparison.

The real problem only shows up on failure: if one of these lines fails, PHPUnit merely reports that two individual scalar values do not match, something like 'Failed asserting that 1900 matches expected 1990'. Without context on which field this was or which object it belonged to, the developer has to open the test code to reconstruct the meaning of that number, an unnecessary detour for what should be a simple diagnosis.

2. The first custom assertion: a simple helper method

The simplest entry point is a protected helper method in a shared base test class that compares all relevant fields of a domain object and throws a meaningful failure message on mismatch. PHPUnit's own assertion methods, such as assertSame, already accept an optional failure message as their last argument, which can be used to add business context without building a full constraint class right away.

This simple variant is a good first step but has a limitation: on failure, PHPUnit only shows the one extra message, not automatically which of the compared fields actually differed when several fields are checked inside a single method. For simple objects with few fields this is often enough, for more complex objects the next step, a real PHPUnit constraint, is worth the investment.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests;

use PHPUnit\Framework\TestCase;

abstract class DomainTestCase extends TestCase
{
    /**
     * Simple helper method: compares amount and currency of a Money object
     * and produces a business readable message on mismatch.
     */
    protected static function assertMoneyEquals(Money $expected, Money $actual, string $message = ''): void
    {
        $description = sprintf(
            'Failed asserting that money %s %s equals expected %s %s',
            $actual->getAmountInCents(),
            $actual->getCurrency(),
            $expected->getAmountInCents(),
            $expected->getCurrency()
        );

        self::assertTrue($expected->equals($actual), $message !== '' ? $message : $description);
    }
}

3. A real PHPUnit constraint for precise failure messages

The next step is a dedicated class that extends PHPUnit\Framework\Constraint\Constraint. A constraint encapsulates the comparison logic in matches() and the failure message formatting in failureDescription(), which lets PHPUnit automatically show the full expected and actual state of the object on failure, including a clean diff representation, provided both objects can export to a printable representation.

This investment is worth it especially for domain objects compared repeatedly across many tests, for example in a large test suite for a checkout process. Written once, the constraint delivers the same high quality failure message everywhere, without every test method reinventing its own comparison logic, which simultaneously reduces code duplication and ensures consistency across different test files.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Constraint;

use PHPUnit\Framework\Constraint\Constraint;

final class IsSameMoney extends Constraint
{
    public function __construct(private readonly Money $expected)
    {
    }

    public function matches($other): bool
    {
        return $other instanceof Money && $this->expected->equals($other);
    }

    public function toString(): string
    {
        return sprintf('equals %s %s', $this->expected->getAmountInCents(), $this->expected->getCurrency());
    }

    protected function failureDescription($other): string
    {
        if (!$other instanceof Money) {
            return 'value ' . $this->exporter()->export($other) . ' ' . $this->toString();
        }

        return sprintf(
            'money %s %s %s',
            $other->getAmountInCents(),
            $other->getCurrency(),
            $this->toString()
        );
    }
}

4. The assertion method as a readable facade over the constraint

The constraint class itself is rarely used directly in test code, the common approach is a slim static assertion method that applies the constraint internally through assertThat(). This facade is the part actually called inside the real test, and its name is what drives readability, assertMoneyEquals($expected, $actual) reads like a natural statement of expectation, with no technical constraint details exposed at all.

This separation between constraint and assertion facade follows the same pattern PHPUnit itself uses internally for its own assertions such as assertEquals, assertEquals is ultimately just a thin facade over the IsEqual constraint too. Adopting this convention produces custom assertions that feel just like the built in PHPUnit assertions to other developers on the team, which noticeably lowers the barrier to entry.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests;

use Mironsoft\Tests\Constraint\IsSameMoney;
use PHPUnit\Framework\TestCase;

abstract class DomainTestCase extends TestCase
{
    protected static function assertMoneyEquals(Money $expected, mixed $actual, string $message = ''): void
    {
        static::assertThat($actual, new IsSameMoney($expected), $message);
    }
}

// Usage inside the actual test:
final class InvoiceTest extends DomainTestCase
{
    public function testItCalculatesTheGrandTotal(): void
    {
        $invoice = Invoice::fromLines([100_00, 50_00]);

        self::assertMoneyEquals(Money::fromCents(150_00, 'EUR'), $invoice->getTotal());
    }
}

5. assertValidOrderState: custom assertions for composite states

For more complex domain objects like an Order, a good custom assertion goes beyond plain field comparison and checks a business invariant as a whole. An assertValidOrderState assertion could, for example, ensure that the sum of all line item prices matches the grand total, that the status fits the existing timestamps, and that no contradictory states occur, all inside a single, readable method instead of scattered across multiple test lines.

Such composite assertions are especially valuable because they bundle business knowledge in one central place instead of reimplementing it in every test method. If the business rule for what makes a valid order state changes, only the assertion needs adjusting, not every single test that implicitly checks that rule, a clear maintainability advantage over scattered individual comparisons.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests;

use PHPUnit\Framework\Assert;

trait OrderAssertionsTrait
{
    protected static function assertValidOrderState(Order $order): void
    {
        $sumOfLines = array_sum(array_map(
            static fn (OrderLine $line): int => $line->getTotalInCents(),
            $order->getLines()
        ));

        Assert::assertSame(
            $order->getTotalInCents(),
            $sumOfLines,
            sprintf(
                'Order total %d does not match the sum of its lines %d',
                $order->getTotalInCents(),
                $sumOfLines
            )
        );

        Assert::assertTrue(
            $order->getStatus()->isConsistentWithTimestamps($order->getStatusHistory()),
            'Order status is inconsistent with its recorded status history'
        );
    }
}

6. Providing custom assertions for the negative case too

An often forgotten addition is the counterpart to a positive assertion, for example assertOrderIsInvalid or assertMoneyNotEquals. Without this addition, developers commonly fall back to assertFalse($order->isValid()) for negative checks, which brings back the same loss of business context that the custom assertion was originally meant to avoid.

When building the negative counterpart, extra care with the failure message pays off: it should not simply negate the positive message, but explain why a state that should be invalid was wrongly recognized as valid, that is the information that actually helps during debugging, especially when the assertion is used in a regression test for a past production bug.

7. Reuse through traits instead of inheritance

Instead of collecting every custom assertion in a single, ever growing base class, a proven approach is splitting them into topic focused traits, such as MoneyAssertionsTrait, OrderAssertionsTrait, and CustomerAssertionsTrait, that each test class includes selectively. This avoids the classic problem of a bloated base class that eventually holds dozens of unrelated assertions, most of which any given test class only ever needs a fraction of.

Traits also have an advantage over a deep inheritance hierarchy: a test class can combine several topic focused assertion packages at once without running into a diamond problem or an unwieldy multi level base class chain, which is especially helpful for integration tests that touch several domain areas at once.

8. When the effort for custom assertions truly pays off

Not every test class needs custom assertions, the effort of a full constraint class with a clean failure message is real and only pays off past a certain reuse threshold. As a rule of thumb: once the same business comparison repeats across more than three or four test methods, the benefit of the custom assertion clearly outweighs the one time cost of building it.

For a one off, very specific comparison in a single test, a plain assertEquals chain remains entirely legitimate, a dedicated constraint class there would create pure overhead with no real reuse benefit. The art lies in identifying exactly the domain objects that keep reappearing across the whole test suite, value objects and central entities are usually the best candidates.

9. Takeaway: investing in readability pays off with every test run

Custom assertions are not an end in themselves, they are a targeted tool against two concrete problems: repetitive comparison chains in test code and cryptic failure messages on failure. Used correctly, they make tests more readable because they express business intent instead of technical field comparisons, and they speed up debugging because the message immediately delivers the business context.

The entry point is gradual: first a simple helper method with a failure message string, and once reuse becomes frequent, an upgrade to a real constraint class with a structured failure description. The table below compares the three approaches presented here across the most important criteria.

Approach Effort Failure message quality Reusability
Generic assertEquals chain None Low, scalar values only None
Simple helper with message text Low Medium, one line of context Within the base class
Dedicated constraint class Medium to high High, structured description Suite wide via assertThat
Trait with multiple assertions Medium High Selectively includable per test class

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

Custom Assertions: The Essentials at a Glance

Core idea

Domain specific assertions bundle field comparisons into one readable method instead of an assertEquals chain.

Biggest benefit

Failure messages instantly show business context instead of only diverging scalar values.

Technical core

Custom constraint classes extend PHPUnit\Framework\Constraint\Constraint for structured diffs.

Rule of thumb

Past three or four repeats of the same comparison, the benefit outweighs the cost of building it.

11. FAQ: Custom Assertions: The Essentials at a Glance

1Does a custom assertion always need its own constraint class?
No, for simple cases a static helper method with a meaningful failure message text is entirely sufficient. A dedicated constraint class only pays off once structured diffs or frequent reuse via assertThat are desired.
2Where should custom assertions live in a project?
A common approach is a dedicated namespace inside the test directory, for example Tests\Constraint for constraint classes and Tests\Assertions for the associated traits, kept separate from the actual test classes.
3Can custom assertions be combined with built in PHPUnit assertions?
Yes, there is no conflict, generic assertions such as assertCount and domain specific assertions such as assertMoneyEquals combine without issue inside the same test method.
4Does PHPUnit automatically recognize custom assertions for test coverage?
Yes, as long as the assertion ultimately calls one of PHPUnit's own assert methods internally, the call counts normally toward the assertion count in the test report, with no difference from direct usage.
5What is the difference between matches() and failureDescription() in a constraint?
matches() holds the actual comparison logic and returns a boolean, failureDescription() is only called on failure and generates the readable failure text shown to the developer.
6Is a custom assertion worth it for an object that appears in only one test?
Usually not, here the cost of building it outweighs the benefit. A plain assertEquals chain or a direct field comparison remains the more pragmatic choice for one off cases.
7Should custom assertions also offer negative checks like assertMoneyNotEquals?
Yes, missing the negative counterpart often leads developers back to generic assertions with no business context for negative checks, which undoes the original readability gain.
8How do I structure many custom assertions without an overloaded base class?
Traits instead of a growing base class are the proven approach, each test class only includes the topically relevant traits instead of inheriting access to every assertion ever written.
9Do custom assertions work with typed objects that have no equals() method?
Yes, the comparison logic inside matches() can be implemented however needed, for example via reflection on individual properties, when the domain object provides no equals() method of its own.
10Do custom assertions change how assertThat behaves with nested constraints?
No, custom constraints can be combined with logical combinators such as logicalAnd or logicalOr just like built in PHPUnit constraints, as long as they correctly extend the base constraint class.