Pest Expectations
Pest expectations express assertions through expect($value) followed by methods that describe the required contract. Good expectations are precise enough to catch the wrong behavior and narrow enough to produce a useful failure message.
Fluent syntax is not the objective by itself. The objective is evidence that the observable result has the correct value, type, structure, or failure behavior.
Distinguish Identity From Equality
Use toBe() when both value and type matter. It performs a strict comparison:
<?php
expect(42)->toBe(42);
expect(true)->toBeTrue();
expect(null)->toBeNull();
Use toEqual() when semantic equality is the intended contract and strict identity is not required. Do not choose it merely to make a failing test pass.
The distinction matters with values such as 42 and '42'. If an identifier must remain an integer, a loose comparison hides a serialization or validation defect.
Prefer dedicated expectations when they communicate intent clearly:
<?php
expect($statusCode)->toBeInt()->toBe(201);
expect($message)->toBeString()->toContain('created');
expect($items)->toBeArray()->not->toBeEmpty();
A type assertion followed by an exact value can produce a clearer failure than a broad comparison alone.
Assert The Contract, Not Every Field
For a returned product array:
<?php
$product = [
'id' => 42,
'name' => 'Desk lamp',
'internal_score' => 91,
];
expect($product)
->toHaveKeys(['id', 'name'])
->and($product['id'])->toBe(42)
->and($product['name'])->toBe('Desk lamp');
This checks the public fields relevant to the behavior without freezing an unrelated internal field. Asserting a complete structure is appropriate when the complete payload is itself the contract, such as a stable API response.
toMatchArray() can check a meaningful subset of an array. toHaveKeys() proves presence but not values. Choose the method that matches the guarantee being tested.
Keep Chains Coherent
Chaining works best when each expectation describes the same result or one tightly related structure.
Do not build a single chain that verifies an order, a mail message, a database count, and a log entry. Those are separate observations and deserve named variables or separate expectations so a failure identifies the broken boundary.
Use and() to move deliberately to another value:
<?php
expect($response['status'])
->toBe('confirmed')
->and($response['total_pence'])->toBe(5_200);
If a chain requires careful punctuation decoding, split it. Concision should not make review harder.
Assert Collections Deliberately
For a homogeneous collection, each() can apply an expectation to every item:
<?php
expect(['ORD-1001', 'ORD-1002'])
->each->toBeString()->toStartWith('ORD-');
Use sequence() when order is part of the contract. Do not use an ordered assertion for a collection whose order is intentionally unspecified; sort it through a documented rule or use an order-insensitive assertion instead.
Collection assertions should still expose useful domain intent. A test named returns newest orders first should verify the relevant order values, not only that each item is an array.
Test Exceptions Around The Call
An exception assertion must wrap the operation expected to fail:
<?php
expect(
fn () => new DateTimeImmutable('not-a-real-date'),
)->toThrow(Exception::class);
For application behavior:
<?php
expect(
fn () => $basket->addQuantity(-1),
)->toThrow(InvalidArgumentException::class, 'Quantity must be positive.');
The class is usually the stable contract. Assert the complete message only when callers or users depend on that exact message; otherwise a wording improvement can break a test without changing behavior.
Pest also supports attaching throws() to a test. Follow the repository's established style and keep the failing operation obvious.
Do not place additional important assertions after a call that is expected to throw. They will never run.
Check Objects Through Public Behavior
Use toBeInstanceOf() when the concrete or interface type is part of the result. Then assert public methods or properties that express the behavior.
Avoid reflection or private-property assertions unless the private representation is the explicit subject of a specialized test. Refactoring private storage should not invalidate tests when public behavior remains correct.
For value objects, equality may be appropriate if the class defines a stable value contract. For mutable service objects, assert the returned result or observable state instead of comparing entire object graphs.
Use Negative Expectations Carefully
Pest's not modifier can express exclusions:
<?php
expect($token)->not->toBeEmpty();
A negative assertion often permits too many incorrect results. not->toBeEmpty() allows any non-empty value, while toMatch('/^[A-F0-9]{16}$/') can describe a required format. Prefer a positive contract when one is available.
Make Failures Explain The Domain
A precise test label and well-named variables give context that an expectation method cannot supply alone:
<?php
test('confirmed order exposes its integer total in pence', function (): void {
$summary = $this->orders->summary('ORD-1001');
expect($summary['total_pence'])
->toBeInt()
->toBe(5_200);
});
If a failure would show only expected true, got false, consider asserting the underlying value rather than a precomputed boolean. The failure will then show the actual status, amount, or identifier.
Avoid Overspecification
Tests become brittle when they assert incidental detail:
- exact whitespace in an internal string
- full arrays when only two public keys matter
- object identity when value equality is sufficient
- generated timestamps without controlling the clock
- random identifiers without controlling randomness
- exception wording that is not part of the contract
- query counts in tests that are not about performance
Assert enough to detect the defect the test is meant to prevent. Add another assertion only when it protects another meaningful part of the same behavior.
What To Remember
Choose expectations from the actual contract: strict value and type with toBe(), semantic equality with toEqual(), structural checks for arrays and collections, and toThrow() around the failing call. Keep chains coherent, prefer positive guarantees, and avoid freezing internal implementation details.
Before moving on, make sure you can explain why toBe(42) is stronger than accepting '42', when toMatchArray() is better than full equality, and why an exception assertion must wrap the operation that throws.
Practice
Task: Assert An Order Summary Precisely
$summary = [
'reference' => 'ORD-1001',
'status' => 'confirmed',
'total_pence' => 5200,
'items' => [
['sku' => 'LAMP-01', 'quantity' => 1],
['sku' => 'BULB-02', 'quantity' => 2],
],
'generated_at' => '2026-06-09T12:00:00+00:00',
];
Write Pest expectations that:
- prove the required public keys exist
- check the reference, status, and integer total strictly
- prove every item has
skuandquantity - avoid asserting the generated timestamp because it is outside this test's contract
- separately prove that an unknown reference throws
OrderNotFoundwithout over-coupling to message wording
Explain why full-array equality and expect($total === 5200)->toBeTrue() would produce weaker maintenance and failure information.
Show solution
<?php
declare(strict_types=1);
use App\Orders\OrderNotFound;
use App\Orders\OrderSummaryService;
test('returns the confirmed order summary contract', function (): void {
$service = new OrderSummaryService();
$summary = $service->forReference('ORD-1001');
expect($summary)
->toHaveKeys(['reference', 'status', 'total_pence', 'items'])
->and($summary['reference'])->toBe('ORD-1001')
->and($summary['status'])->toBe('confirmed')
->and($summary['total_pence'])->toBeInt()->toBe(5_200);
expect($summary['items'])->each(
fn ($item) => $item
->toBeArray()
->toHaveKeys(['sku', 'quantity']),
);
});
test('rejects an unknown order reference', function (): void {
$service = new OrderSummaryService();
expect(
fn () => $service->forReference('ORD-9999'),
)->toThrow(OrderNotFound::class);
});
The timestamp is not asserted because this test is about the summary contract, not clock behavior. Full-array equality would freeze generated_at and any future internal field. A boolean assertion such as expect($total === 5200)->toBeTrue() reports only a failed boolean, while toBe(5_200) shows the expected and actual totals directly and checks the integer type.