Clarity Over Cleverness
Anyone who writes assertEquals where assertSame is meant writes tests that pass even though the software is broken. Assertions are not boilerplate, they are the language in which a test describes what "correct" means. The right choice between equality, identity, type and structure decides whether a test is a safety net or delivers false confidence.
Table of Contents
- 1. Why assertions are more than boilerplate
- 2. assertSame vs. assertEquals: the decisive difference
- 3. The right assertions for types and structures
- 4. Custom assertions: tests speak the domain language
- 5. Constraint objects: using assertions compositionally
- 6. Writing meaningful failure messages
- 7. Typical assertion mistakes and how to spot them
- 8. Assertions side by side
- 9. Summary
- 10. FAQ
1. Why assertions are more than boilerplate
An assertion is the one place in a test where it is stated explicitly what the system is supposed to do. Everything before it, creating fixtures, calling methods, preparing data, is setup. The assertion itself is the claim: "This result matches my expectation." Choose the wrong assertion and that claim becomes imprecise, and an imprecise test can pass green even though the system is broken.
The classic example: assertEquals(0, false) passes in PHPUnit, because assertEquals internally uses ==, and PHP evaluates 0 == false as true. What was actually meant was: "The return value is the integer zero, not false." Only assertSame(0, false) makes this difference visible, and the test correctly fails. The choice between these two methods is not a matter of style, it is a matter of precision. In a well-structured test suite, every assertion states exactly what it means, no more and no less.
Another frequently overlooked aspect: assertions are documentation. Anyone reading a test immediately understands, through the assertions, which properties of the system are being guaranteed. An assertion like assertSame('active', $user->getStatus()) is clearer than assertTrue($user->getStatus() === 'active'), both check the same thing, but the first variant produces a more meaningful failure message and signals the intent directly to the reader.
2. assertSame vs. assertEquals: the decisive difference
assertSame internally uses the identity operator === and checks value and type at the same time. assertEquals uses == and applies PHP's type coercion. This seemingly small difference has significant consequences: assertEquals(1, true) passes, assertEquals(0, '') passes, assertEquals(0, null) passes, all combinations that create false confidence with broken code. The rule of thumb is: always use assertSame when the type of the return value is known. assertEquals makes sense when type-agnostic equality is deliberately being checked, for example when comparing DTO objects that implement __equals.
For objects, assertSame checks object identity (the same instance), while assertEquals checks equality (equal properties). For value objects that should be compared by value, assertEquals is therefore correct. For service objects, where it needs to be checked whether a factory always returns the same instance (singleton, shared service), assertSame is required. Knowing the difference and applying it deliberately is the first step towards precise assertions.
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Domain\Money;
use App\Domain\User;
/**
* Demonstrates correct assertion selection for type-safe PHP tests.
*/
final class AssertionPrecisionTest extends TestCase
{
/** @test */
public function same_checks_type_and_value(): void
{
// WRONG: passes even though types differ
$this->assertEquals(0, false); // true, type coercion
$this->assertEquals('', null); // true, type coercion
// RIGHT: strict identity check
$this->assertSame(0, 0); // true
// $this->assertSame(0, false); // FAILS, correct behaviour
}
/** @test */
public function object_identity_vs_equality(): void
{
$a = new Money(100, 'EUR');
$b = new Money(100, 'EUR');
// Value equality (same properties): use assertEquals
$this->assertEquals($a, $b);
// Instance identity (same object in memory): use assertSame
$registry = new ServiceRegistry();
$this->assertSame($registry->get('mailer'), $registry->get('mailer'));
}
/** @test */
public function null_checks_need_explicit_assertion(): void
{
$result = findUserById(999);
// WRONG: assertEquals(null, $result) passes for false, 0, '' too
// RIGHT: dedicated assertion communicates intent clearly
$this->assertNull($result);
}
/** @test */
public function boolean_checks_must_be_strict(): void
{
$isActive = $this->getUserStatus();
// WRONG: assertTrue passes for any truthy value (1, 'yes', [1])
// RIGHT: assertSame communicates exactly what is expected
$this->assertSame(true, $isActive);
$this->assertIsBool($isActive); // type guard before value check
}
}
3. The right assertions for types and structures
PHPUnit offers specialized assertions for every PHP type, and using them is better than generic variants. assertIsString, assertIsInt, assertIsArray, assertIsFloat check the type explicitly and produce clear failure messages. assertCount is better than assertEquals(3, count($array)), because PHPUnit evaluates the counter internally and the failure message shows the actual element count. assertEmpty and assertNotEmpty are clearer than assertEquals([], $array), because they respond to all empty structures.
For arrays there is assertContains for values and assertArrayHasKey for keys. For associative arrays with a known structure, assertSame on the whole array is often more precise than several individual assertArrayHasKey calls. For strings there is assertStringContainsString, assertStringStartsWith, assertStringEndsWith and assertMatchesRegularExpression, each with a clear failure message that puts the actual string and the expectation side by side. These specialized assertions are not a luxury, they are tools that considerably speed up debugging of failing tests.
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* Shows specialized assertions for types and structures.
*/
final class SpecializedAssertionsTest extends TestCase
{
/** @test */
public function use_type_specific_assertions(): void
{
$order = $this->createOrder();
// Type guards communicate intent and give clear error messages
$this->assertIsString($order->getOrderNumber());
$this->assertIsInt($order->getItemCount());
$this->assertIsFloat($order->getTotalAmount());
$this->assertIsArray($order->getItems());
// Structural assertions
$this->assertCount(3, $order->getItems());
$this->assertNotEmpty($order->getOrderNumber());
$this->assertStringStartsWith('ORD-', $order->getOrderNumber());
$this->assertMatchesRegularExpression('/^ORD-\d{8}$/', $order->getOrderNumber());
}
/** @test */
public function array_assertions_pinpoint_failures(): void
{
$config = $this->loadConfig();
// Key existence before value access
$this->assertArrayHasKey('database', $config);
$this->assertArrayHasKey('host', $config['database']);
// Value membership
$this->assertContains('mysql', $config['supported_drivers']);
// Full structure assertion for small arrays
$this->assertSame([
'host' => 'localhost',
'port' => 3306,
], $config['database']);
}
/** @test */
public function exception_assertions_need_specificity(): void
{
// WRONG: only checks exception class, ignores message
$this->expectException(\InvalidArgumentException::class);
// RIGHT: also assert the message to catch wrong exceptions of same type
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Price must be positive');
$this->expectExceptionCode(422);
new Money(-1, 'EUR');
}
}
4. Custom assertions: tests speak the domain language
Custom assertions are one of the most effective techniques for keeping test code readable and maintainable. Instead of repeating the same cluster of three or four assertions in every test, they get encapsulated in a named method that speaks the domain language. The result: a test reads like a description of behaviour, not like a sequence of technical checks.
Custom assertions are best defined in a shared base class or in a trait that all affected TestCase classes use. The method conventionally starts with assert, accepts the object to be checked and internally delegates to PHPUnit's standard assertions. Important: custom assertions should not implement their own test logic, they only encapsulate assertions. Logic belongs in the production system, not in assertions.
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\TestCase;
use App\Domain\Order;
use App\Domain\User;
/**
* Provides domain-specific custom assertions for reuse across test cases.
*/
abstract class DomainTestCase extends TestCase
{
/**
* Asserts that an order is in a valid placed state.
*/
protected function assertOrderIsPlaced(Order $order): void
{
$this->assertSame('placed', $order->getStatus());
$this->assertNotNull($order->getPlacedAt());
$this->assertGreaterThan(0, $order->getItemCount());
$this->assertGreaterThan(0.0, $order->getTotalAmount());
$this->assertNotEmpty($order->getOrderNumber());
}
/**
* Asserts that a user has completed onboarding.
*/
protected function assertUserOnboardingComplete(User $user): void
{
$this->assertTrue($user->isEmailVerified());
$this->assertNotNull($user->getProfileCompletedAt());
$this->assertSame('active', $user->getStatus());
$this->assertNotEmpty($user->getDisplayName());
}
/**
* Asserts that a collection contains exactly the given IDs.
*
* @param list<int> $expectedIds
* @param list<object> $items
*/
protected function assertCollectionContainsIds(array $expectedIds, array $items): void
{
$actualIds = array_map(static fn($item) => $item->getId(), $items);
sort($expectedIds);
sort($actualIds);
$this->assertSame($expectedIds, $actualIds, 'Collection does not contain expected IDs.');
}
}
// Usage in a test:
final class OrderPlacementTest extends DomainTestCase
{
/** @test */
public function placing_an_order_transitions_status_correctly(): void
{
$order = Order::draft();
$order->place(items: $this->createItems(3), customer: $this->createCustomer());
// Domain language, reads like a spec, not like assertions
$this->assertOrderIsPlaced($order);
}
}
5. Constraint objects: using assertions compositionally
PHPUnit's assertion methods are internally built on constraint objects. The class PHPUnit\Framework\Constraint\Constraint defines the interface, and PHPUnit provides an extensive library of ready-made constraints: IsEqual, IsIdentical, IsType, IsNull, Contains and many more. With assertThat($value, $constraint) these can be used directly or combined.
Constraints can be combined with logicalAnd, logicalOr and logicalNot. This creates a compositional assertion language: "Check that the value is a string AND starts with 'ORD-' AND has at least 12 characters." Custom constraints are implemented by extending Constraint and implementing the methods matches() and toString(). The toString() method provides the failure message that appears in the failure output.
<?php
declare(strict_types=1);
namespace Tests\Constraint;
use PHPUnit\Framework\Constraint\Constraint;
/**
* Custom constraint: asserts that a value is a valid order number.
*/
final class IsValidOrderNumber extends Constraint
{
/**
* Returns whether the constraint is matched by the given value.
*/
protected function matches(mixed $other): bool
{
if (!is_string($other)) {
return false;
}
return (bool) preg_match('/^ORD-\d{8}-[A-Z]{3}$/', $other);
}
/**
* Returns a string representation of the constraint.
*/
public function toString(): string
{
return 'matches order number format ORD-YYYYMMDD-XXX';
}
}
// Composing constraints in a test
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Constraint\IsType;
final class OrderNumberTest extends TestCase
{
/** @test */
public function order_number_matches_format(): void
{
$orderNumber = $this->service->generateOrderNumber();
// Composing built-in and custom constraints
$this->assertThat(
$orderNumber,
$this->logicalAnd(
new IsType('string'),
new IsValidOrderNumber()
)
);
}
/** @test */
public function price_is_within_valid_range(): void
{
$price = $this->pricing->calculate($product);
$this->assertThat(
$price,
$this->logicalAnd(
$this->greaterThan(0.0),
$this->lessThanOrEqual(9999.99)
)
);
}
}
6. Writing meaningful failure messages
Every PHPUnit assertion accepts, as its last parameter, an optional message that is displayed on failure. This option is used far too rarely. Yet a meaningful failure message is often the decisive difference between "test failed, cause immediately clear" and "test failed, twenty minutes of debugging". The message should describe why this specific value is expected in this context, not what PHPUnit already shows in the failure output anyway.
A bad example: $this->assertSame(3, $count, 'count is wrong'). That says nothing new. A good example: $this->assertSame(3, $count, 'Cart should contain exactly 3 items after adding product twice to empty cart'). This message explains the context of the test and makes clear under which conditions this failure occurred. In test suites with hundreds of tests, this information is worth its weight in gold when CI reports failures after a change.
7. Typical assertion mistakes and how to spot them
The most common mistake is using assertTrue for comparisons: assertTrue($a === $b) instead of assertSame($a, $b). The problem: on assertTrue failures, PHPUnit only shows "Failed asserting that false is true", without showing what $a and $b actually contained. With assertSame, on the other hand, PHPUnit shows the actual and expected value side by side. The failure message is thus immediately actionable, without having to start the debugger.
A second widespread mistake: asserting on objects without considering equality semantics. If an object does not implement an equality method, assertEquals compares all properties recursively, which is slow for large object graphs and fails on circular references. Explicitly checking the relevant properties is then more robust. A third mistake: assertContains on associative arrays behaves differently than on lists. On associative arrays, assertContains checks values, not keys, for keys, assertArrayHasKey is the right choice.
| Scenario | Wrong / Weak | Right / Precise | Why |
|---|---|---|---|
| Type check | assertEquals(0, false) |
assertSame(0, 0) |
Type coercion masks bugs |
| Boolean check | assertTrue($isActive) |
assertSame(true, $isActive) |
assertTrue accepts truthy values |
| Null check | assertEquals(null, $r) |
assertNull($r) |
Clear failure message, no coercion risk |
| Element count | assertEquals(3, count($a)) |
assertCount(3, $a) |
Failure message shows elements |
| String content | assertTrue(str_contains($s, 'X')) |
assertStringContainsString('X', $s) |
Shows needle and haystack on failure |
8. Assertions side by side
Choosing the right assertion affects not only correctness but also the quality of failure messages and the readability of the test. The overview below shows the most important pairings and when to use which variant.
9. Summary
Writing better PHPUnit assertions means: assertSame instead of assertEquals when the type is known. Specialized assertions like assertCount, assertNull, assertIsString instead of generic variants. Custom assertions in base classes for domain-specific checks that keep the test readable. Constraint objects for compositional assertions. And always: a failure message that explains why this value is expected in this context.
The most important principle remains clarity over cleverness. An assertion that is understandable at a glance and immediately shows what went wrong on failure is always preferable to a clever one-liner assertion. Tests are documentation, assertions are its most precise form.
PHPUnit Assertions: The Essentials at a Glance
Type precision
assertSame uses ===, always use it when the type of the return value is known. assertEquals only for deliberate type-agnostic checks.
Specialized assertions
assertNull, assertCount, assertIsString, assertStringContainsString, produce clear failure messages instead of a generic "false is not true".
Custom assertions
Move domain-specific checks into base classes. The test reads like a spec, not like technical checks.
Failure messages
Use the last parameter: describe the context, do not repeat what PHPUnit already shows. Failures must be understandable at a glance.
Mironsoft
PHP development, testing infrastructure and code quality assurance
Test suites that actually protect you?
We analyze existing PHPUnit test suites, identify weak assertions and weak coverage, and refactor them into precise, readable tests that provide real confidence.
Assertion audit
Analysis of all assertions for precision, type safety and failure message quality
Custom assertions
Domain-specific base classes and constraints for readable, maintainable tests
Test refactoring
Replacing weak tests with precise, meaningful alternatives