In Pest, how do expect()->toThrow() and a test's ->throws() differ, and why must toThrow() receive a closure rather than a call?
answer
- the closure is called inside the expectation
- class: instanceof; plain string: message substring
- ->throws() ends the whole test
- code after the throw never runs
- not->toThrow passes on any other exception
basics
~20 stoThrow() 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.
solid answer
~40 s`expect(fn () => $cart->add('SKU-1', 0))->toThrow(InvalidArgumentException::class)` hands Pest a closure; `toThrow()` invokes it inside a try/catch. A class name is checked with `instanceof`, so subclasses pass; a plain string that is not a class is treated as a message that must be contained in the exception message; both can be given together; with neither thrown it fails with "Exception [...] not thrown". The test keeps running after it, so you can assert the cart is still empty. `->throws(InvalidArgumentException::class, 'quantity')` is chained onto `it()`/`test()` and registers PHPUnit's exception expectations for the whole body: the test passes only if the body ends by throwing, and nothing after the throwing line runs. Writing `expect($cart->add('SKU-1', 0))` evaluates the call first, so the exception escapes before `toThrow()` exists and the test errors.
code
php · 17 lines<?php
declare(strict_types=1);
use App\Shop\Cart;
it('rejects a zero quantity and stays empty', function () {
$cart = new Cart();
expect(fn () => $cart->add('SKU-1', 0))
->toThrow(InvalidArgumentException::class, 'quantity');
expect($cart->count())->toBe(0);
});
it('rejects a negative quantity', function () {
(new Cart())->add('SKU-1', -1);
})->throws(InvalidArgumentException::class);go deeper
Recall that toThrow() needs a closure, and that ->throws() on the test expects the body to end with the exception.
Explain the accepted arguments - class, message fragment, instance, typed closure - and why arguments are evaluated before the call.
Show when whole-test expectations give false positives and how toThrow() plus a state check makes rejection tests trustworthy.
Set a team convention for exception tests so reviewers can see exactly which call is expected to fail and what state must survive.
## Two tools for one question Pest gives you two ways to assert that code throws: 1. **`expect($closure)->toThrow(...)`** - an expectation on a callable. Pest calls it and inspects what it throws. The test continues afterwards. 2. **`->throws(...)`** - a modifier on the test itself, chained onto `it()` or `test()`. It tells PHPUnit that the **whole test body** is expected to end with that exception. ## Why `toThrow()` needs a closure PHP evaluates function arguments before the call. In ```php expect($cart->add('SKU-1', 0))->toThrow(InvalidArgumentException::class); // wrong ``` `$cart->add('SKU-1', 0)` runs first, throws, and the exception propagates out of the test before `expect()` or `toThrow()` exist. The test is reported as an error, not a failed expectation. Wrapping the call defers it: ```php expect(fn () => $cart->add('SKU-1', 0))->toThrow(InvalidArgumentException::class); ``` Pest's implementation of `toThrow()` literally does `($this->value)()` inside a `try`/`catch`. ## What `toThrow()` accepts | Argument | How it is checked | |---|---| | a class name, `InvalidArgumentException::class` | `instanceof`, so subclasses also pass | | a string that is not a class, `'quantity'` | the exception message must **contain** it | | class plus message | both checks | | an exception instance, `new InvalidArgumentException('Quantity must be positive')` | same class family and **exactly** equal message | | a closure with one typed parameter | the parameter type is the class; the closure then receives the caught exception for extra checks | If nothing is thrown, the expectation fails with `Exception [InvalidArgumentException] not thrown.` (or "Exception with message [...] not thrown." when you passed only a message). ## What `->throws()` does ```php it('rejects a zero quantity', function () { (new Cart())->add('SKU-1', 0); })->throws(InvalidArgumentException::class, 'quantity'); ``` `->throws()` registers PHPUnit's `expectException()`, and with a message `expectExceptionMessage()`, which also checks that the message contains the text. An integer argument sets an expected code. Because the expectation is on the test as a whole: - the test passes only if the body ends with that exception; - statements after the throwing line never run, so you cannot assert on state afterwards; - if an earlier line throws the same class for a different reason, the test still passes - the classic false positive of whole-test exception expectations. `->throwsIf()` and `->throwsUnless()` add a condition, and `->throwsNoExceptions()` asserts the opposite. ## Choosing between them The decision is about precision: how narrowly the test pins down *where* the exception comes from, and whether anything must be checked after it. - Prefer **`toThrow()`** when the exception comes from one specific call and you want to check state afterwards - for example that a rejected `add()` left the cart empty. - **`->throws()`** reads well for short tests whose only purpose is the exception, and it maps directly from PHPUnit's `expectException()` when converting a test class. ## The `not->toThrow()` subtlety `expect(fn () => $cart->add('SKU-1', 1))->not->toThrow(InvalidArgumentException::class)` passes when nothing is thrown - and also when a **different** exception is thrown, because `not` works by running the positive expectation and requiring it to fail. If the goal is "this call must not throw at all", check it without a class restriction or assert on the result instead. ## Converting PHPUnit exception tests PHPUnit tests usually call `$this->expectException()` at the top of the method. The direct translation keeps the same semantics: | PHPUnit | Pest | |---|---| | `expectException(X::class)` | `->throws(X::class)` | | `expectExceptionMessage('qty')` | `->throws(X::class, 'qty')` | | `expectExceptionCode(42)` | `->throws(X::class, null, 42)` | A conversion is also the moment to tighten such tests. If the old method built a cart, added items and only then triggered the exception, rewriting the last call as `expect(fn () => ...)->toThrow(...)` makes sure the exception comes from the line you mean, and lets you add an assertion on the state afterwards. ## Checklist 1. Wrap the call: `fn () => ...`. 2. Pass the class, and the message fragment if it matters. 3. Use a typed-parameter closure when you need to inspect properties of the exception. 4. Assert on state after `toThrow()` when the rejection must be side-effect free. 5. Keep `->throws()` for tests whose entire body is one call, where the whole-test scope cannot hide an earlier failure.
- In Pest, how do you check a custom property of the thrown exception with toThrow()?Pass a closure whose single parameter is typed with the exception class: `->toThrow(function (OutOfStock $e) { expect($e->sku)->toBe('SKU-1'); })`. Pest reads the class from the parameter type, checks the thrown exception against it, and then calls the closure with the caught exception so you can assert on its properties.
- With Pest's ->throws(), why can a test pass even though the line you meant to test never threw?The expectation covers the whole test body. If an earlier line - building the cart, loading a fixture - throws the same exception class, the test ends there and passes. `toThrow()` around the single call under test avoids that false positive.
saying these in an interview costs you the question
- Passes the method call itself to expect() before toThrow()
- Believes toThrow() requires the exact class, not subclasses
- Expects code after the throwing line to run with ->throws()
- Thinks a message given to toThrow() must match exactly
- Assumes not->toThrow(X) fails when a different exception is thrown