Skip to content

Migrating from PHPUnit ​

For suites using PHPUnit's own createMock() / createStub(). Verified against PHPUnit 12.5.33; the mapping holds for 10 through 13.

Also rendered outside the site

This guide and Migrating from Mockery are the same document as MIGRATION.md in the repository.

The mapping ​

PHPUnitUnderstudy
createMock(BookRepository::class) / createStub(…)Understudy::for(…)
->expects($this->once())->method('find')expect(fn () => $repo->find(…))
$this->once() / never() / exactly(n)times(1) / times(0) / times(n)
$this->any()no expect() at all — use when()
$this->atLeast(n)times(n, null)
->with(123, $this->anything())in the closure: find(123, Arg::any())
$this->equalTo($v)the literal $v
$this->identicalTo($v)Arg::same($v)
$this->isInstanceOf(Foo::class)Arg::instanceOf(Foo::class)
$this->callback(fn ($x) => …)Arg::satisfies(fn ($x) => …)
$this->isType('int')Arg::int()
$this->stringContains($s)Arg::string(matches: '/…/')
->willReturn($v)->returns($v)
->willReturnCallback(fn …)->answers(fn (Invocation $i) => …)
->willThrowException($e)->throws($e)
->willReturn(1, 2) / willReturnOnConsecutiveCalls(1, 2)->returns(1, 2) — one form for both
->willReturnSelf()->returns($double)
->willReturnMap([...])several when() calls, or one answers() with a match
->willReturnArgument(0)->answers(fn (Invocation $i) => $i->args[0])
strict about stubsverifyAll(strictStubs: true)
getMockForAbstractClass() / onlyMethods()a class double plus delegate()
automatic reset between teststhe PHPUnit adapter trait
Pest's own expect()expect as expectCall, or Understudy::expect()

Three differences that are not one-to-one ​

willReturnMap() has no single equivalent ​

PHPUnit's map is a table of argument-lists to return values. Understudy says the same thing as several stubs, which reads better and analyses better:

php
when(fn () => $repo->find(1))->returns($a);
when(fn () => $repo->find(2))->returns($b);
when(fn () => $repo->find(Arg::any()))->returns(null);   // the fallback

A specification that pins an argument beats a catch-all whichever order the two were declared in, and within a tier a later stub wins — so the fallback above answers everything but find(1) and find(2) however it is ordered. See Stubbing.

Where the mapping is genuinely tabular, one answers() with a match is closer to the original.

expects() is both count and behaviour; here they can be separate ​

php
// PHPUnit
$repo->expects($this->once())->method('find')->with(7)->willReturn($book);

// Understudy — one registration, both things
expect(fn () => $repo->find(7))->returns($book);

An expectation needs no returns() at all: the mode's default supplies the value. That is why $this->any() translates to no expect() — a call you do not count is a stub, and writing it as an expectation with an unbounded range says something the test does not mean.

Partial doubles ​

getMockForAbstractClass() and onlyMethods() produce an object where the un-named methods run for real. The understudy shape is a class double in forwarding mode:

php
$service = Understudy::delegate(Checkout::class, new Checkout($deps));
when(fn () => $service->rate())->returns(0.2);   // this one is stubbed

Everything not stubbed runs for real and is recorded, which the PHPUnit form does not give you.

Two rules PHPUnit does not have ​

Arm before the run. expect() counts only calls arriving after it is declared. PHPUnit's expects() is also declared up front, so a straight translation is fine — but a test that had moved its assertions to the end will need the expectation moved back to the top, or converted to verify().

One registration per call. A when() and an expect() for the exact same call are refused with ConflictingExpectation. PHPUnit allows a stub and a separate expectation on one mock, so this is the shape most likely to need rewriting:

php
// was: a stub for the value, plus an expectation for the count
expect(fn () => $repo->find(7))->returns($book)->times(2);   // now: one registration

Strict stubs ​

PHPUnit 10+ can be configured to fail on a stub that was never used. Understudy's equivalent is verifyAll(strictStubs: true), set once on a base test case.

Coming from Prophecy ​

PHPUnit 10 dropped Prophecy from the box, so this is a common second hop.

ProphecyUnderstudy
$this->prophesize(Foo::class)Understudy::for(Foo::class)
$prophecy->reveal()nothing — for() already returns the double
->willReturn($v)->returns($v)
->willThrow($e)->throws($e)
Argument::any() / type() / that()Arg::any() / Arg::instanceOf() / Arg::satisfies()
->shouldBeCalled()expect(…)
->shouldBeCalledTimes(n)expect(…)->times(n)
->shouldNotBeCalled()expect(…)->times(0)
->shouldHaveBeenCalled()verify(…)
$prophet->checkPredictions()Understudy::verifyAll(), or the adapter

The structural difference: Prophecy keeps a separate object from the revealed double. Understudy has one object, and the specification is a call made on it — so there is no reveal step and no pair of variables to keep straight.