skip to content

Pest

The closure-based PHP layer on PHPUnit: test and it functions, a chained expect API, datasets, architecture rules and plugins for parallel runs and mutation. Interviewers probe what it adds.

on this pageshow

explore

questions

12

In Pest, how do expect()->toBe() and toEqual() differ, and how do the not and and() modifiers change an expectation chain?

level: juniorimportance: must knowfreq 58%

answer

  1. toBe maps to assertSame
  2. toEqual maps to assertEquals
  3. objects: same instance versus equal state
  4. not flips only the next expectation
  5. and() switches the value under test

basics

~20 s

toBe() checks type and value and, for objects, the same instance; toEqual() checks equal value loosely, so '1' equals 1 and two equal objects pass. not inverts only the next expectation, and and() starts a new expectation on another value.

solid answer

~30 s

`expect($value)` wraps a value; each `toX()` call asserts and returns the expectation so calls chain. `toBe()` delegates to PHPUnit's `assertSame()`: same type and value, and for objects the very same instance, so `expect('1')->toBe(1)` fails. `toEqual()` delegates to `assertEquals()`: `expect('1')->toEqual(1)` passes and two distinct objects with equal properties pass. For money totals, ids and flags I use `toBe()`; `toEqual()` for value objects compared by state. `->not` inverts only the expectation right after it, then the chain continues on the same value: `->not->toBeNull()->toBeInt()`. `->and($other)` switches the subject, so one chain can check several values: `expect($cart->total())->toBe(1998)->and($cart->count())->toBe(2)`.

code

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

use App\Shop\Cart;
use App\Shop\Money;

it('totals two items', function () {
    $cart = new Cart();
    $cart->add('SKU-1', 2, new Money(999));

    expect($cart->total())->toBe(1998)
        ->not->toBe(0)
        ->and($cart->count())->toBe(2)
        ->and($cart->subtotal())->toEqual(new Money(1998));
});

go deeper

for a junior

Recall that toBe() is strict and toEqual() is loose, that not inverts an expectation, and that and() moves to a new value.

for a middle

Explain the mapping to assertSame() and assertEquals(), identity versus state for objects, and exactly how far not and and() reach in a chain.

for a senior

Show where loose equality would hide real bugs in money or id handling and set team defaults for toBe() versus toEqual().

for a principal

Frame assertion strictness as a suite-wide policy that reviewers can check, rather than a per-test preference.

## The expectation object In Pest, `expect($value)` returns an **expectation** that wraps the value. Every expectation method, such as `toBe()` or `toBeInt()`, performs one assertion and returns the expectation again, so calls chain: ```php expect($cart->total()) ->toBeInt() ->toBeGreaterThan(0) ->toBe(1998); ``` Under the hood these methods call PHPUnit's assertions, so failures, assertion counts and reports work exactly as in a PHPUnit test. ## `toBe()` versus `toEqual()` | Expectation | Delegates to | Scalars | Objects | |---|---|---|---| | `toBe($expected)` | `assertSame()` | same type **and** value | the same instance | | `toEqual($expected)` | `assertEquals()` | equal value, loosely | equal class and properties | Concretely: ```php expect(1)->toBe(1); // passes expect('1')->toBe(1); // fails: string vs int expect('1')->toEqual(1); // passes expect(new Money(500))->toBe(new Money(500)); // fails: two instances expect(new Money(500))->toEqual(new Money(500)); // passes: same state ``` The practical rule: use `toBe()` for scalars where type matters - totals in cents, counts, booleans, identifiers - because a `'1998'` string slipping through a type-juggling bug should fail the test. Use `toEqual()` when comparing value objects by state or when order-sensitive array equality is what you mean. For arrays with different key order, `toEqualCanonicalizing()` compares regardless of order, and for floats `toEqualWithDelta()` accepts a tolerance. ## `not`: invert the next expectation `->not` (also callable as `->not()`) wraps the expectation so that the **next** expectation must fail for the test to pass. Only that one call is inverted; the chain then continues positively on the same value: ```php expect($cart->total()) ->not->toBeNull() // inverted ->toBeInt() // positive again ->not->toBe(0); // inverted ``` A common misreading is that `not` stays in force for the rest of the chain. It does not, and the example above would be wrong if it did. ## `and()`: switch the subject `->and($otherValue)` returns a new expectation for a different value, so one fluent chain can check several related values: ```php expect($cart->total())->toBe(1998) ->and($cart->count())->toBe(2) ->and($cart->isEmpty())->toBeFalse(); ``` After `and()`, every expectation applies to the new value; there is no way back to the previous one except starting another `expect()`. ## Related modifiers worth knowing - `->each` applies the next expectation to every item of an iterable: `expect($cart->prices())->each->toBeInt()`. - **Higher-order expectations**: accessing a property or calling a method on the expectation reads it from the value, as in `expect($cart)->count()->toBe(2)`. - `->sequence(...)` applies one closure per item, in order. ## Mapping from PHPUnit assertions Every common PHPUnit assertion has an expectation counterpart, which matters when converting tests or reading mixed code: | PHPUnit | Pest expectation | |---|---| | `assertSame($e, $a)` | `expect($a)->toBe($e)` | | `assertEquals($e, $a)` | `expect($a)->toEqual($e)` | | `assertCount(2, $a)` | `expect($a)->toHaveCount(2)` | | `assertTrue($a)` | `expect($a)->toBeTrue()` | | `assertNull($a)` | `expect($a)->toBeNull()` | | `assertInstanceOf(C::class, $a)` | `expect($a)->toBeInstanceOf(C::class)` | | `assertContains($x, $a)` | `expect($a)->toContain($x)` | Note the argument order: PHPUnit takes the expected value first, while in Pest the actual value goes into `expect()` and the expected value into the matcher. Most expectation methods also accept an optional failure message as their last argument, for example `->toBe(1998, 'total is in cents')`, which is printed when the assertion fails. ## Mistakes that pass review 1. Using `toEqual()` everywhere "because it is more forgiving". It lets a string total or a `null` that equals `0` loosely slip through. 2. Expecting `toBe()` to compare two freshly built value objects. It checks identity for objects. 3. Assuming `not` negates the rest of the chain. 4. Forgetting that after `and()` the original value is no longer under test. 5. Mixing `$this->assertSame()` and `expect()` randomly in one file. Both work, but a team usually standardises on one style for readability. ## Summary In an interview, tie the answer to a concrete bug: a cart total that comes back as the string `'1998'` after passing through a form or a JSON decode passes `toEqual(1998)` and fails `toBe(1998)`, and only the second outcome is what you want from a test guarding money. `toBe()` is strict (`assertSame`), `toEqual()` is loose (`assertEquals`). `not` flips exactly one expectation, and `and()` moves the chain to a new value.

  • In Pest, why can expect($cart->total())->toEqual(1998) hide a bug that toBe(1998) would catch?
    `toEqual()` uses PHPUnit's loose `assertEquals()`, so a total returned as the string `'1998'` or the float `1998.0` still passes. `toBe()` uses `assertSame()` and fails on the type difference, which is exactly the kind of type-juggling bug a money calculation should never hide.
  • In Pest, what does expect($x)->not->toBeNull()->toBeInt() assert?
    That `$x` is not null and that `$x` is an int. `not` inverts only `toBeNull()`; the chain then continues with a positive `toBeInt()` on the same value. To invert both, write `not` before each expectation.

saying these in an interview costs you the question

  • Says toBe() compares objects by their property values
  • Thinks not inverts every expectation after it in the chain
  • Believes and() adds another check on the same value
  • Uses toEqual() for money totals where the type matters
  • Claims Pest expectations bypass PHPUnit's assertions
open as a page

In a Pest test closure, what is $this, how does beforeEach() share state through it, and what does pest()->extend()->in() change?

level: middleimportance: must knowfreq 50%

basics

~20 s

$this is the PHPUnit test case instance the closure is bound to, a fresh one per test. beforeEach() stores state on it, such as $this->cart. pest()->extend(Base::class)->in('Feature') makes tests in that folder bind to your base class instead.

open as a page

In Pest, how do test() and it() differ, and what does describe() add to the tests declared inside it?

level: juniorimportance: should knowfreq 50%

basics

~20 s

test() and it() both register one test from a description and a closure; it() only prefixes the description with 'it'. describe() groups tests under a shared name prefix and scopes the hooks and datasets declared inside it to that group.

open as a page

In Pest, how would you write an arch() test that forbids controllers from using the database layer directly?

level: middleimportance: should knowfreq 30%

basics

~10 s

Write arch()->expect('App\Http\Controllers')->not->toUse([...]) naming the database namespace and classes such as PDO, or turn it around with arch()->expect('App\Database')->toOnlyBeUsedIn('App\Repositories'); Pest fails the test listing every file that breaks the rule.

open as a page

What do Pest's --parallel and --tia options each do to a slow suite, and why does only one belong in the CI command?

level: middleimportance: should knowfreq 35%

basics

~20 s

--parallel runs the whole suite across several processes through ParaTest, so it belongs in CI; --tia (Pest 5) records which files each test touches and later runs only affected tests, replaying cached results, so it is meant for local runs.

open as a page

In Pest, how do ->with() and dataset() feed a test, and how does a bound dataset differ from a PHPUnit data provider?

level: middleimportance: should knowfreq 45%

basics

~20 s

->with([...]) runs the test once per row, passing the row's values as arguments; dataset('name', [...]) defines a reusable set referenced by name. A bound dataset row is a closure Pest calls after beforeEach(), unlike a static PHPUnit provider that runs before setUp().

open as a page

In Pest, how do expect()->toThrow() and a test's ->throws() differ, and why must toThrow() receive a closure rather than a call?

level: middleimportance: should knowfreq 40%

basics

~20 s

toThrow() calls the closure you pass and checks what it throws, so the test continues afterwards; ->throws() on the test expects the whole test body to end with that exception. Passing a call instead of a closure throws before expect() can catch anything.

open as a page

In Pest, what do the --mutate option and the covers() and mutates() functions do together, and how do you gate CI on the result?

level: seniorimportance: should knowfreq 28%

basics

~20 s

--mutate makes Pest mutate the classes named by covers() or mutates() in your test files, rerun the tests that cover each mutation, and report which survived as untested; --mutate --min=N fails the run when the score is below N percent.

open as a page

When converting a PHPUnit CartTest class to Pest, how do setUp(), a #[DataProvider] and expectException() map, and what behaviour changes along the way?

level: seniorimportance: should knowfreq 28%

basics

~20 s

setUp() becomes beforeEach() storing state on $this, a #[DataProvider] becomes ->with() or a named dataset(), and expectException() becomes ->throws() or toThrow(). Private helpers must move to a base class wired with pest()->extend(), and datasets gain post-setup evaluation.

open as a page

Your team maintains a large PHPUnit 13 suite; how would you decide whether to adopt Pest 5, and how would you roll it out?

level: principalimportance: should knowfreq 22%

basics

~20 s

Pest 5 runs on PHPUnit 13, so existing TestCase classes keep running and phpunit.xml still applies; adopt it for what it adds (arch rules, built-in mutation, type coverage, Tia) against its costs: a PHP 8.4 floor and PHPUnit upgrades tied to Pest releases.

open as a page

What does Pest's --type-coverage option measure, and how does it differ from running Pest with --coverage?

level: middleimportance: nice to knowfreq 18%

basics

~10 s

--type-coverage, from pestphp/pest-plugin-type-coverage, reports the percentage of parameters, return types and properties that carry type declarations, analysing the code without running tests; --coverage runs the tests and reports which lines executed.

open as a page

What do Pest's php, security and strict arch presets each enforce, and how do you exempt one function from a preset?

level: seniorimportance: nice to knowfreq 20%

basics

~10 s

arch()->preset()->php() bans debug and output calls such as var_dump and die, security() bans risky calls such as eval, md5 and unserialize, and strict() demands declare(strict_types=1), strict equality and final classes; ->ignoring('md5') exempts one entry.

open as a page