In PHPUnit 13, how do you assert that code throws an exception, and what are the traps of expectException?
answer
- call before the throwing line
- nothing after the throw runs
- instanceof: subclasses match
- IsOrContains versus Is
- TypeError is not an Exception
basics
~10 sCall expectException(SomeException::class) before the code that should throw. The test passes only if a matching Throwable escapes; nothing after the throwing line runs. Message checks use expectExceptionMessageIs, expectExceptionMessageIsOrContains or expectExceptionMessageMatches.
solid answer
~40 s`$this->expectException(InvalidArgumentException::class)` registers an expectation. The test then calls the code, and PHPUnit catches whatever escapes the test method and checks it with `instanceof`, so subclasses match too. If nothing is thrown, the test fails with *Failed asserting that exception of type "InvalidArgumentException" is thrown*. The expectation must be set **before** the throwing call, and any assertion placed after that call never runs. Add `expectExceptionCode()` or a message expectation to make the check precise. In PHPUnit 13, `expectExceptionMessage()` is soft-deprecated (since 13.2) in favour of `expectExceptionMessageIsOrContains()`, a substring match, while `expectExceptionMessageIs()` requires the exact message and `expectExceptionMessageMatches()` takes a regex. `expectException()` accepts any `Throwable` class, and a `TypeError` does not satisfy `expectException(Exception::class)`.
code
php · 20 lines<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class DiscountCalculatorTest extends TestCase
{
public function testRejectsPercentAboveHundred(): void
{
$calculator = new DiscountCalculator();
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageIs('Percent must be between 0 and 100, got 150');
$calculator->apply(100, 150);
// never reached: the exception has already left the method
$this->assertTrue(false);
}
}go deeper
Know to call expectException with the class before the code that throws, and that the test fails if nothing is thrown.
Explain the instanceof matching, the message and code variants, and why nothing after the throwing line runs.
Pin exceptions precisely: specific classes, exact messages where they are a contract, Error versus Exception, and try/catch when state after the throw matters.
Decide how error contracts are tested across a codebase: domain exception hierarchies, which messages are stable, and when codes carry meaning.
## The basic pattern To test that `DiscountCalculator::apply(100, 150)` rejects a percentage above 100: 1. declare the expectation with **`$this->expectException(InvalidArgumentException::class)`**; 2. call the code that should throw; 3. let the exception escape the test method. PHPUnit wraps the test method in a `try`/`catch`. When a `Throwable` escapes, it checks the expectation; when the method returns normally, it asserts that the expected exception was raised and fails if not. ## The methods | Method | Checks | |---|---| | `expectException(string $class)` | the thrown object is an `instanceof` that class or interface (any `Throwable`) | | `expectExceptionCode(int\|string $code)` | `getCode()` equals the value | | `expectExceptionMessageIs(string $message)` | the message is exactly this string | | `expectExceptionMessageIsOrContains(string $message)` | the message equals or contains this string | | `expectExceptionMessageMatches(string $regex)` | the message matches the regular expression | | `expectExceptionObject(Throwable $e)` | class, message (is-or-contains) and code taken from a sample object | | `expectExceptionMessage(string $message)` | soft-deprecated since PHPUnit 13.2; delegates to `expectExceptionMessageIsOrContains()` | `expectExceptionMessageIsOrContains()` makes the old behaviour explicit. The old name suggested an exact match, but it has long been a substring check, so `expectExceptionMessage('percent')` passed for any message containing "percent". When the wording is part of the contract, use `expectExceptionMessageIs()`. ## The traps **1. Code after the throw never runs.** The exception leaves the test method at the throwing line. An assertion written after it, such as "and the calculator logged nothing", is silently skipped. Put follow-up assertions in a separate test, or use `try`/`catch` with explicit assertions: ```php try { $calculator->apply(100, 150); $this->fail('Expected InvalidArgumentException'); } catch (InvalidArgumentException $e) { $this->assertSame('Percent must be between 0 and 100, got 150', $e->getMessage()); $this->assertSame(0, $logger->count()); } ``` **2. Set the expectation first.** `expectException()` placed after the throwing call is never reached, and the test errors on an uncaught exception instead of passing. **3. Too broad a class passes for the wrong reason.** `expectException(Exception::class)` accepts any `Exception` subclass, including a `RuntimeException` thrown by a bug in the setup code. Expect the most specific class, and add a message or code expectation. **4. `Error` is not `Exception`.** PHP 8 throws `TypeError`, `ValueError` and `ArgumentCountError` from the engine, and those extend `Error`, not `Exception`. `expectException(Exception::class)` does not match a `TypeError`: the test fails, reporting that the thrown `TypeError` does not match the expected exception. To assert an engine error, expect it by name: `expectException(TypeError::class)`. **5. PHPUnit's own failures are not swallowed.** A failed assertion inside the test is a PHPUnit exception, and a broad `expectException(Exception::class)` does not turn it into a pass. The failure is still reported. ## Exceptions that carry context Domain exceptions often carry more than a message: the offending value, an error code for the API layer, or a previous exception. A calculator might throw `InvalidDiscount::percentOutOfRange(150)`, a named constructor that stores `150` on the exception. Checking that value needs the object itself: - `expectExceptionObject(InvalidDiscount::percentOutOfRange(150))` checks the class, the message (equal or contained) and the code of a sample object, but **not** custom properties; - a custom property such as `$e->percent` can only be asserted from a `try`/`catch` block, as in trap 1; - `expectExceptionCode()` compares `getCode()`, which is worth checking only when the code means something to callers. A useful rule: expectation methods for *that* it throws, `try`/`catch` for *what* it carries. ## Which style to choose - **Expectation methods** read well for the common case: one call, one exception, nothing more to check. - **`try`/`catch` with `$this->fail()`** fits when you need to inspect the exception object in detail (custom properties, the previous exception) or to assert state after the throw. Either way, name the test after the rule (`testRejectsPercentAboveHundred`) so a failure explains itself.
- What happens if the code under test throws nothing?The test method returns normally, and PHPUnit checks the registered expectation. It fails the test with *Failed asserting that exception of type "InvalidArgumentException" is thrown*. A message expectation set without a class fails with a similar message naming the expected text.
- Is expectExceptionMessage() an exact match?No. It has long matched when the actual message equals or contains the given string. PHPUnit 13.2 soft-deprecated it for that reason: `expectExceptionMessageIsOrContains()` names the behaviour honestly, and `expectExceptionMessageIs()` is the exact comparison.
saying these in an interview costs you the question
- expectException can be called after the throwing line.
- Assertions after the throwing call still execute.
- expectException(Exception::class) also catches a TypeError.
- expectExceptionMessage requires the exact message text.
- expectException matches only the exact class, not subclasses.