skip to content

In PHPUnit 13, how do expects($this->once()) and with() check a mocked mailer's call, and when does a mismatch fail the test?

level: middleimportance: should knowfreq 50%

answer

  1. count rule first, then method(), then with()
  2. plain values compared by equality
  3. identicalTo, callback, stringContains constraints
  4. extra call fails at the call
  5. missing call fails after the test

basics

~20 s

expects($this->once()) sets how many calls must happen and with() constrains each call's arguments. A wrong argument or an extra call fails at the moment of the call; a missing call fails when PHPUnit verifies mocks after the test method.

solid answer

~30 s

You write `$mailer->expects($this->once())->method('send')->with('[email protected]', 'Welcome!', $this->anything())`. The count rule can be `never()`, `once()`, `exactly(n)`, `atLeast(n)`, `atLeastOnce()` or `atMost(n)`. In `with()`, plain values are wrapped in an equality constraint, so they compare like `assertEquals()`, not `===`; for identity use `$this->identicalTo()`, and for a custom check use `$this->callback(fn ($x) => ...)`. A call beyond the allowed count or an argument mismatch throws an `ExpectationFailedException` at the call itself; PHPUnit also stores it and rethrows it at verification, so a `catch` in the service cannot hide it. Too few calls are only detected when PHPUnit verifies mocks after the test method returns.

code

php · 20 lines
php
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class SignUpMailTest extends TestCase
{
    public function testDoesNotMailWhenAddressIsTaken(): void
    {
        $users = self::createStub(UserRepository::class);
        $users->method('existsByEmail')->willReturn(true);

        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->never())->method('send');

        $service = new SignUpService($users, $mailer);

        $this->assertFalse($service->register('[email protected]', 'pw'));
    }
}

go deeper

for a junior

Recall the order expects(), method(), with(), and the count rules once(), never() and exactly().

for a middle

Explain equality versus identicalTo() in with(), callback() constraints, and which failures surface at the call versus after the test.

for a senior

Show you keep expectations to the behaviour that matters and know how failures survive a catch block inside the code under test.

for a principal

Discuss how strict argument matching trades regression safety for churn when message text and payloads change often.

## Anatomy of an expectation An **expectation** on a PHPUnit mock has three parts, always written in this order: 1. **the count rule** passed to `expects()`; 2. **the method** named with `method()`; 3. **the argument constraints** passed to `with()`, which are optional. ```php $mailer->expects($this->once()) ->method('send') ->with('[email protected]', 'Welcome!', $this->stringContains('Ana')); ``` You can still chain an answer such as `willReturn()` after `with()` when the mocked method returns something. ## Count rules `TestCase` provides the count rules as methods: | Rule | Passes when the method is called | |---|---| | `never()` | zero times | | `once()` | exactly one time | | `exactly(3)` | exactly three times | | `atLeast(2)` | two or more times | | `atLeastOnce()` | one or more times | | `atMost(2)` | zero, one or two times | `any()` still exists but is hard-deprecated in PHPUnit 13 and slated for removal in 14: a rule that accepts any count checks nothing, so a stub is the honest choice. ## How with() compares arguments Each position in `with()` is a **constraint**. A plain value such as `'[email protected]'` is wrapped in an equality constraint, the same comparison `assertEquals()` uses. That has consequences: - two different `User` objects with equal properties match, even though they are not the same instance; - to require the same instance, or a strict `===` match for scalars, pass `$this->identicalTo($user)`; - `$this->callback(fn (string $body): bool => str_contains($body, 'confirm'))` runs your predicate; it must return `true` to match; - `$this->anything()` accepts any value in that position, and other constraints such as `$this->stringContains()` or `$this->isInstanceOf()` work too; - `with()` checks as many positions as you list; passing more constraints than the call has arguments fails with "Parameter count for invocation ... is too low". ## When a mismatch fails The timing is the part candidates most often get wrong. - **Wrong arguments** fail at the call. PHPUnit first applies the count rule and then evaluates the argument constraints as soon as the mocked method is invoked, and throws an `ExpectationFailedException` from inside the code under test. - **Too many calls** also fail at the call: the second call to a `once()` method throws "... was not expected to be called more than once". - **Too few calls** can only be detected afterwards. When the test method returns, PHPUnit verifies every registered mock; a `once()` rule that saw no call fails with "Mailer::send() was expected to be invoked once but was never invoked." Because the exception is thrown inside the service, a `catch (\Exception $e)` or `catch (\RuntimeException $e)` there could swallow it; PHPUnit's `ExpectationFailedException` is a `RuntimeException` subclass. PHPUnit guards against that: the invocation handler records the first assertion failure and throws it again during verification, so the test still fails. ## Verification and assertion counting Verification runs right after the test method returns and before `tearDown()` runs. Each mock that has a count rule is verified and adds to the test's assertion count. A test whose only check is `expects($this->once())` is therefore a real test, not an empty one. ## Deprecated shortcut: with() without expects() `$mock->method('send')->with(...)` without `expects()` used to be accepted. Since PHPUnit 13.0.2 it triggers a deprecation saying it will no longer be possible in PHPUnit 14. Either add a count rule, or, if you only wanted a canned answer, use a stub without `with()`. ## Reading a failure message PHPUnit's messages name the doubled class, the method and the call, which makes them easy to trace: - an argument mismatch starts with "Expectation for Mailer::send() failed." and then names the parameter that did not match, followed by the usual comparison diff; - an extra call prints the call with its arguments and return type, followed by "was not expected to be called more than once, actually called 2 times."; - a missing call reads "Mailer::send() was expected to be invoked once but was never invoked.". The first kind appears with a stack trace that runs through the service, which shows exactly which line made the wrong call. ## Practical advice for the sign-up test - Assert the recipient and subject exactly, and use `stringContains()` or `callback()` for the body, so copy edits to the mail text do not break the test. - Use `never()` on `send()` in the "address already taken" test: it documents that no mail goes out. - Keep one expectation per behaviour; stacking count rules on queries welds the test to the implementation.

  • Why might with() accept a User object that is not the instance the test created?
    Plain values in `with()` are wrapped in an equality constraint, so an object with equal property values matches. To require the very same instance, pass `$this->identicalTo($user)` in that position.
  • If the service wraps the mailer call in catch (Exception $e), can it hide an argument mismatch?
    It can catch the `ExpectationFailedException` thrown at the call, since that class extends `RuntimeException`. PHPUnit records the first assertion failure in the mock's invocation handler and rethrows it during verification after the test method, so the test still fails.
  • What replaces expects($this->any()) in PHPUnit 13?
    Nothing on the mock: `any()` is hard-deprecated and scheduled for removal in PHPUnit 14. If the call count does not matter, use a stub; if it does, state the real rule, such as `once()` or `atLeastOnce()`.

A mock with expects(once()) is like a doorman with a guest list for one person: a stranger or a second visit is turned away at the door, but only when the party ends does anyone notice the invited guest never arrived.

saying these in an interview costs you the question

  • with() compares plain values with === by default
  • All expectation failures are only reported after the test method
  • A catch block in the service can hide a failed once() expectation for good
  • expects($this->any()) is the normal way to configure a mock in PHPUnit 13
  • with() without expects() is fully supported in PHPUnit 13