Share
November 10, 2026

Sealed mocks and mutation testing in CI

TL;DR: In my view, sealed mocks in PHPUnit 13 push tests toward a clean split. Stubs carry data and mocks cover only the calls that matter. My bet is that Infection on changed code in the pull request, plus a nightly full run, is the CI setup that survives.

Tests should fail for a reason you can name. A loose mock fails for reasons nobody remembers, so I don’t trust it.

Stubs and mocks are different tools

A stub feeds data to the code under test, and a mock checks that something was called. Test suites blur the two, so one double often answers questions and verifies calls in the same test. I get why it happens. It’s still a mess, and the double should be split before it grows a third job.

My rule is simple. If a double only returns a value, it’s a stub, and the test shouldn’t care how often the code asks. If the call itself is the behaviour, such as sending an email, it’s a mock, and the test should say exactly how many times.

What sealing changes

PHPUnit 13 came out on 6 February 2026 and needs PHP 8.4 or later, so the PHP upgrade comes first. It adds seal(), and the name is a bit misleading. You don’t call seal() on the mock, because it’s the last call in a method() or expects() chain.

PHP
$mailer = $this->createMock(Mailer::class);

$mailer->expects($this->once())
    ->method('send')
    ->with('[email protected]')
    ->willReturn(true)
    ->seal();

After that the double is frozen. Another method() or expects() call throws a TestDoubleSealedException, which errors the test. On a mock from createMock(), every method you didn’t configure also gets an implicit never(). That is the whole point of sealing for me. Say the mailer also has a queue() method. If the code under test starts calling it, the test fails with “was not expected to be called”.

That’s annoying during a refactor. I’d still take the noise, because it shows the code does something the test never mentioned, and it’s better to learn that before the review does.

A stub is different. Unconfigured methods on a sealed stub still return default values, so only mocks break on a new call. That’s the right split, and it works as a rule for new tests.

Now to any(). The matcher says a method may be called zero or more times, so it reads like a check and checks nothing. A test that uses it passes whether the call happens or not. Call it a trap. A red test is better.

PHPUnit 13 hard-deprecates it, and I’m glad. Each use emits a deprecation, the test still runs, and the message points to removal in PHPUnit 14, which is planned but not released. If a test needs any(), ask one question first. Is this double really a stub, or do we need a mock? Often it’s a stub, and then createStub() replaces the matcher. When the count matters, once(), exactly(), atLeast(), atMost() or never() say it plainly.

One more detail. Setting requireSealedMockObjects to true in phpunit.xml marks an unsealed mock as risky. It doesn’t fail the test, even though the announcement says it does. For this one the code is the better source.

Infection on changed code

Mutation testing asks a simple thing. Do our tests notice when the code changes? It’s a more honest number than coverage. Infection makes many small edits, like turning a > into >=, and runs the suite against each one. A mutant that survives points at a missing assertion.

My suspicion is that loose mocks create a lot of surviving mutants, because a double that accepts any call proves little. That’s a guess, not a measurement, and I’d want data before saying it twice.

My concern is time. Running Infection on a whole project in every pull request is probably too slow, so teams tend to switch it off. It can run on changed code only. Use –git-diff-lines for added and modified lines, or –git-diff-filter=AM for whole changed files. The two don’t combine, and either one needs –git-diff-base to name the branch you compare against.

The minimum scores follow the filter, which I like. With –min-msi and –min-covered-msi, only mutants from changed code count, so we get a score for changed code. That’s what makes a gate realistic.

What I’d put in the pipeline

The CI job needs the full history, so set fetch-depth: 0. In a shallow clone git can’t find the fork point. Infection then diffs straight against the base branch, and that pulls in files somebody else changed there, so our pull request would get mutants it never earned.

YAML
on: pull_request
jobs:
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      # set up PHP 8.4+ with a coverage driver here
      - run: composer install --no-interaction --no-progress
      - run: >
          vendor/bin/infection
          --git-diff-lines
          --git-diff-base=origin/$GITHUB_BASE_REF
          --min-msi=80 --min-covered-msi=90
          --ignore-msi-with-no-mutations
          --threads=max

Treat the 80 and 90 as placeholders, not advice. A project-wide number would never pass on a legacy codebase, and a gate that always fails gets ignored. So start strict on changed code and loosen it only with a reason.

Keep –ignore-msi-with-no-mutations in the config. Without it, a pull request with changed code but no mutants can fail the gate, and nobody needs that noise.

There is one downside, and I’ll admit it: old weak tests stay hidden until someone touches that code. A nightly full run covers that gap, so we need one. Infection 0.35.8 is tested against PHPUnit 13.2 and 13.3. I’d check 13.4 before upgrading rather than assume it works.

How do you handle mutation testing on a big codebase? Can you see a hole in this setup?