Skip to content
beginner Phase 12 · Testing with PHPUnit

Writing Assertions

Verify return values, exceptions, and state changes.

40m
0 problems
Topic Progress 0%

assertSame or assertEquals: Where PHP's Type Juggling Hides

A billing test goes green in review and the same calculation ships a string where an integer belongs. The test was green because it asked the wrong question.

$this->assertEquals(1200, $cart->total());  // passes for 1200, "1200", and 1200.0
$this->assertSame(1200, $cart->total());    // passes only for int 1200

assertSame checks value and type with ===; assertEquals compares loosely with == and then recurses through the structure with Sebastian Bergmann's comparator — objects are compared attribute by attribute, floats by value, DateTimeInterface by instant. Neither is "the strict one you should always use"; they answer different contract questions.

Assertion Roughly equivalent to Reach for it when
assertSame($expected, $actual) $actual === $expected scalars, null, arrays where order and type are part of the promise
assertEquals($expected, $actual) $actual == $expected, deep legacy loose data, objects compared by attribute
assertEqualsWithDelta($e, $a, $d) abs($a - $e) <= $d floats from division, tax math, anything computed
assertEqualsCanonicalizing($e, $a) sorted == sets where iteration order is not part of the contract
assertInstanceOf(X::class, $a) $a instanceof X objects returned by factories, doubles passed in
assertTrue / assertFalse / assertNull exact boolean or null check control flags, where a truthy value must be rejected

Two traps live in the loose path.

Floating point. 0.1 + 0.2 evaluates to 0.30000000000000004, so both assertSame(0.3, 0.1 + 0.2) and assertEquals(0.3, 0.1 + 0.2) fail — == applies no tolerance. assertEquals used to take a $delta argument; it was removed, and assertEqualsWithDelta is the supported spelling:

$this->assertEqualsWithDelta(0.3, 0.1 + 0.2, 1e-9);

Version-dependent comparison rules. PHP 8 changed how numbers compare against non-numeric strings: on PHP 7, 0 == 'abc' was true; on PHP 8 it is false. A suite full of assertEquals on mixed-type data changes meaning the day you upgrade the runtime — a green build on 7.4 can be red on 8.1 for reasons that have nothing to do with your code. For money and identifiers, compare in integer cents and use assertSame, so the test fails on "1200" before your ledger does.

Arrays deserve the same discipline. assertSame on arrays demands identical types and order, which is correct for a sorted result the API promises. When the contract is membership rather than order — rows pulled from a database with no ORDER BY — assertEqualsCanonicalizing compares sorted copies and stops the suite from failing on a harmless reordering:

$this->assertEqualsCanonicalizing([1050, 1200], $totals);   // passes for [1200, 1050]
$this->assertSame([1050, 1200], $totals);                   // fails on the order

Objects force you to ask a second question: equal, or the same instance?

$a = new Money(1200);
$b = new Money(1200);

$this->assertEquals($a, $b);   // passes — same attributes, compared recursively
$this->assertSame($a, $b);     // fails — two distinct instances
$this->assertSame($a, $a);     // passes — reference identity

assertEquals walks both objects attribute by attribute; assertSame is ===, which for objects means identity. When a test must prove the collaborator received the very object the caller constructed, only assertSame can express it; when two DTOs are built independently from the same payload, only assertEquals will. The boolean helpers obey the same rule — assertTrue($flag) insists on a real boolean, while assertEquals(true, 'yes') passes, which is never what a flag check meant.

The failure output is the payoff. assertEquals prints a --- Expected / +++ Actual diff, which is unreadable when the only difference is a type; assertSame prints both values plus their types, so 1200 (integer) versus "1200" (string) is diagnosable from the CI log alone. That difference is why reviewers ask for assertSame on scalars, and it is the first thing interviewers probe in this area.

Value checks are half the job. The other half is proving the code refuses to go on.

Proving the Code Fails Correctly with expectException

A validation method that is supposed to throw has a way of never throwing — and the test stays green anyway.

public function testRejectsExpiredCoupon(): void
{
    try {
        $this->checkout->applyCoupon('EXPIRED-2024');
    } catch (ExpiredCouponException $e) {
        $this->assertSame('EXPIRED-2024', $e->couponCode());
    }
    // no exception thrown? the test still passes
}

This is the most common false-green in PHP testing: if the guard disappears, the catch block never runs, zero assertions execute, and CI reports success. Delete the catch and let the runner own the expectation instead:

public function testRejectsExpiredCoupon(): void
{
    $this->expectException(ExpiredCouponException::class);
    $this->expectExceptionMessage('EXPIRED-2024');

    $this->checkout->applyCoupon('EXPIRED-2024');
}

Three mechanics matter.

Order is load-bearing. expectException() must be called before the line that throws. It configures the runner's state for this test method; if you set it after, the exception escapes as an error with the same name but a different meaning (uncaught exception versus an expected outcome).

The message matcher is a substring match. expectExceptionMessage('EXPIRED-2024') passes when the message is Coupon EXPIRED-2024 is no longer valid, which is usually what you want — you are pinning the identifying detail, not the prose. When the shape matters, expectExceptionMessageMatches('/^Coupon [A-Z0-9-]+ expired on \d{4}-\d{2}-\d{2}$/') pins it with a regex. Assert on stable, identifying fragments; asserting the full sentence means a copy-editing pass breaks the build.

The class check is instanceof. A test expecting InvalidArgumentException passes if the code throws a subclass such as DomainException extends InvalidArgumentException. That is normally correct — substitutability is the point — but if you must pin the exact class, add $this->expectException(ExpiredCouponException::class) and assert the class name in a separate happy-path check of the exception object itself.

What expectException does not buy you: a test proving no exception occurs. That comes for free — if the happy path throws, the runner catches it and fails the test as an error. So you never write "expect nothing"; you just assert the returned value.

When a test legitimately has no assertions — a smoke test that only checks a class boots — say so with expectNotToPerformAssertions() or #[DoesNotPerformAssertions], otherwise it is flagged risky and, with the failOnRisky flag from Configuring PHPUnit, fails the build.

What expectException cannot do is worth stating plainly, because it tells you when to fall back to the explicit form: it asserts the type (and optionally the message) of one exception, and nothing about the exception's payload. When the values matter — the coupon code, the offending field, the expiry date — you need the object itself:

try {
    $this->checkout->applyCoupon('EXPIRED-2024');
    $this->fail('expected ExpiredCouponException, none thrown');
} catch (ExpiredCouponException $e) {
    $this->assertSame('2024-12-31', $e->expiredOn());
    $this->assertSame('EXPIRED-2024', $e->couponCode());
}

The fail() line is not optional — it is what makes the no-exception case red instead of silently green. Use this shape only for payload assertions; for "does it throw at all?", expectException() is shorter and impossible to get backwards.

And one expected exception per test. Two expectException() calls do not mean "either is acceptable": PHPUnit records a single expected exception per method, so assert the common parent type or your own marker interface, or split into two tests. When the wrong type shows up, the runner names it:

Failed asserting that exception of type "DomainException" is thrown.

Note the class check behaves like instanceof: expecting InvalidArgumentException passes when a subclass is thrown, because a subtype is a valid instance of the expected type.

Expectations tell the runner what should happen. Next: making the failure message tell you what actually did.

Failures You Can Read at 2 a.m.: Custom Messages, Diffs, and TestDox Names

The test failed in CI. The log says Failed asserting that 119.99 matches expected 120.0. Which input, which rule, which feature? Sixty seconds later you are reproducing locally to answer a question the assertion could have answered itself.

Every assertion takes a custom message as its last argument, and it is printed first.

$this->assertSame(
    1200,
    $cart->totalWithTax(rate: 0.20),
    'total for a 1000-cent item at 20% tax must round to integer cents'
);

The message earns its place when the numbers are not self-explanatory — money, pagination offsets, retry counts, IDs generated by a contract you are not testing. Write it as the invariant, not a restatement: 'total must round to integer cents' beats 'total is wrong', because the reader can evaluate it against the code without opening anything.

Pick the assertion that produces the most useful failure. A short catalogue you will use constantly:

Situation Assertion
Size of a collection assertCount(3, $lines)
Key presence in a payload assertArrayHasKey('total', $payload)
Substring in a response body assertStringContainsString('already taken', $body)
Ordering guarantee assertGreaterThan($min, $max)
Regex-shaped input assertMatchesRegularExpression('/^\d{5}$/', $zip)
A file written by a job assertFileExists($path)

Each of these fails with an exact, domain-shaped message. assertTrue($total === 1200) fails with Failed asserting that false is true — the assertion threw away the two values you needed.

Reading diffs is a skill worth naming in an interview. assertEquals on arrays prints a unified diff, so a swapped pair shows as - 'b' / + 'a' at a specific offset; assertSame prints value and type, which is how you catch a string sneaking past a loose comparison. When the diff is long, the custom message is the one line that tells you which rule to read.

Watch the summary line in every run:

OK (14 tests, 41 assertions)

That count is not a score — it is a smell test. A test method that reaches the end with zero assertions is reported risky. Assertion counts also expose a lazy habit: calling assertNotNull() on a mocked collaborator to "add an assertion" when the real check was whether that collaborator was called at all. Interaction checks live later on this roadmap; the immediate consequence of precise assertions is that copying a test method for a twentieth input stops being attractive. Growing one check across many inputs without copy-paste is what Parameterized Tests with dataProvider solves.

Since the phase before this one built JSON APIs, three assertions will carry most of your integration work:

$this->assertJsonStringEqualsJsonString($expected, $response->getBody());
$this->assertStringContainsString('"status":"failed"', $body);
$this->assertContains('already-taken', $errors);      // strict: in_array with ===

assertContains is strict in exactly the way assertSame is strict — assertContains('1', [1, 2]) fails because the types differ, and assertContains(1, ['1']) fails for the same reason. assertStringContainsString is the substring form for bodies and messages, while assertStringEqualsFile() snapshots an entire fixture. Which one you pick decides how much of the payload the failure output can show you.

Here is the shape of a well-armed failure in a CI log:

1) OrderTest::testTotalIncludesTax
Failed asserting that 119.99 matches expected 120.0.

total for 3 items at 20% tax must round to integer cents

Both lines matter: the first gives you the numbers, the custom message gives you the rule that broke. Set that beside Failed asserting that false is true and the reason teams standardize on explicit assertions is obvious. One more runner detail: PHPUnit 10+ prints counts first and defers the rest, so --display-deprecations --display-warnings --display-incomplete --display-skipped are what surface the bodies of anything that did not fail outright.

Finally, name tests so the human-readable report reads like a spec: run vendor/bin/phpunit --testdox and the output becomes a checklist of guarantees (✔ Rejects an expired coupon) instead of a wall of method names. Reviewers read the checklist; missing behavior shows up as a missing line.

Quiz

1. A method is documented to return an integer number of cents. Which assertion best enforces that contract?

Question 1 options

2. You need to assert that a computed rate of 0.1 + 0.2 equals 0.3. Which call is correct?

Question 2 options

3. A colleague writes a test that catches ExpectedException, asserts on the message, and has no other code. What happens if the production guard that throws is accidentally deleted?

Question 3 options

4. expectExceptionMessage('EXPIRED-2024') is used, and the code throws Coupon EXPIRED-2024 is no longer valid. What is the result, and what does the matcher actually check?

Question 4 options

Flashcards

Question

What is the difference between assertSame and assertEquals?

Answer

assertSame requires value AND type to be identical (as with ===) and prints both types on failure. assertEquals compares loosely (as with ==) and recurses through arrays and objects with a comparator — it passes for 1200 vs "1200" and for objects equal by attribute but not identical.

Question

When do you need assertEqualsWithDelta instead of assertEquals?

Answer

Whenever the expected value comes from floating-point arithmetic: 0.1 + 0.2 yields 0.30000000000000004, so exact comparisons fail. assertEqualsWithDelta(expected, actual, tolerance) accepts a difference within the tolerance; the old delta argument of assertEquals was removed in PHPUnit 9.

Question

Where must expectException() be placed, and what does it change?

Answer

It must be called BEFORE the statement that throws, because it configures the runner's expectation for the whole test method. When the exception is thrown, the test passes; when the method completes without it, the test fails. Placing it after the call leaves the exception uncaught and reports an error instead.

Question

Why is try/catch around a throwing call a false-green test?

Answer

If the code stops throwing, the catch block never executes, zero assertions run, and PHPUnit reports success. The fix is expectException() before the call, or $this->fail('expected an exception') immediately after it so reaching that line fails the test.

Question

What does the third argument of an assertion do, and what belongs in it?

Answer

It is a custom failure message printed first when the assertion fails. Put the invariant in domain terms — 'total for a 1000-cent item at 20% tax must round to integer cents' — so the CI log alone identifies which rule broke, rather than restating that the values differ.

Revision Notes

Key Takeaways

  • 1. assertSame enforces type as well as value, so a string sneaking past a billing calculation fails in the test instead of in the ledger
  • 2. assertEquals recurses with a comparator and prints a unified diff — useful for objects and loose legacy data, misleading when a type is the actual bug
  • 3. 0.1 + 0.2 is 0.30000000000000004: exact float comparison always fails, so assertEqualsWithDelta expresses the tolerance your domain allows
  • 4. expectException must run before the throwing statement, and expectExceptionMessage is a substring match — pin the identifying fragment, not the whole sentence
  • 5. A try/catch with no fail() passes when the exception never fires; a custom failure message is what turns a CI red into a diagnosis

Interview Tips

  • • "assertSame versus assertEquals?" — answer with the contract: type matters (IDs, counts, money in cents) → assertSame; object graphs or loose legacy data → assertEquals; mention that PHP 8 changed loose string/number comparison, which is why loose assertions can change meaning across runtime upgrades
  • • "How do you test that invalid input is rejected?" — expectException set before the call, plus a substring or regex message matcher; explicitly say you never rely on try/catch alone because deleting the throw leaves a green, assertion-free test
  • • "How do you make failures debuggable?" — the message argument states the invariant in domain terms, the assertion choice preserves the values (diff or types), and --testdox maps each test to a sentence a non-PHP reviewer can read
  • • "Your suite is green but a bug shipped. What could assertions have missed?" — loose comparisons hiding a type change, happy-path-only coverage, and mocks asserting call shape instead of real output; lead into mutation testing as the follow-up

Cheat Sheet

Writing Assertions

Pick the assertion by contract:

Contract Assertion Failing output shows
value + type assertSame both values and their types
deep/loose equality assertEquals --- Expected / +++ Actual diff
float with tolerance assertEqualsWithDelta($e, $a, 1e-9) values vs tolerance
order-insensitive set assertEqualsCanonicalizing sorted diff
exact type assertInstanceOf actual class
size / key / substring assertCount / assertArrayHasKey / assertStringContainsString the missing or wrong element

Exceptions — the only correct order:

$this->expectException(ExpiredCouponException::class);   // before the call
$this->expectExceptionMessage('EXPIRED-2024');          // substring match
$this->expectExceptionMessageMatches('/^Coupon .* expired on \d{4}-\d{2}-\d{2}$/');

$this->checkout->applyCoupon('EXPIRED-2024');

The false-green pattern (never ship this):

try { $this->checkout->applyCoupon('bad'); }
catch (ExpiredCouponException $e) { $this->assertSame('bad', $e->couponCode()); }
// fix: expectException() above, or $this->fail('expected an exception') after the call

Floats: assertSame(0.3, 0.1 + 0.2) fails — 0.1 + 0.2 is 0.30000000000000004. Money in integer cents + assertSame.

Readable failures: last argument of any assertion = message printed first; assert the invariant, not the disappointment. Run vendor/bin/phpunit --testdox for a sentence-per-test checklist.

Continue Learning