Testing exceptions — expectException, expectExceptionMessage
Concept
Testing that code throws exceptions correctly is as important as testing its happy path. PHPUnit provides several mechanisms for this, and using the wrong one leads to tests that pass even when the exception isn't thrown.
$this->expectException(ClassName::class) tells PHPUnit to expect a specific exception type to be thrown during the test. If no exception is thrown, or the wrong exception type is thrown, the test fails. This must be called before the code that triggers the exception.
$this->expectExceptionMessage('partial message') asserts that the exception message contains the given string. Don't assert on the full message unless you own the exception class—third-party library messages can change between minor versions. Assert on the meaningful part: 'connection refused' rather than 'PDOException: SQLSTATE[HY000] [2002] Connection refused to host...'.
$this->expectExceptionCode(404) asserts on the exception's integer code. This is useful for domain exceptions where the code carries semantic meaning (HTTP status code, error category).
$this->expectExceptionMessageMatches('/regex/') (PHPUnit 9.1+) uses a regular expression, which is more flexible than substring matching for messages that include variable data like IDs or timestamps.
A critical gotcha: after calling expect*, PHPUnit only catches the first exception and then ends the test. This means you cannot test two different exception scenarios in one test method—you need separate test methods for each exception path.
Never use try/catch in tests to assert on exceptions. The classic mistake:
// WRONG — if MyException is never thrown, the test passes silently
try {
$service->doSomething();
} catch (MyException $e) {
$this->assertSame('expected', $e->getMessage());
}If doSomething() never throws, the catch block is skipped and PHPUnit sees a test with no assertions—which it may mark "risky" but not as a failure unless beStrictAboutTestsThatDoNotTestAnything is enabled.
Code Example
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Age;
use App\Domain\Email;
use App\Exceptions\DomainException;
use App\Exceptions\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DomainExceptionTest extends TestCase
{
// Basic exception type assertion
#[Test]
public function age_rejects_negative_values(): void
{
$this->expectException(\InvalidArgumentException::class);
new Age(-1);
}
// Assert type + message together
#[Test]
public function age_rejects_values_over_150(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Age cannot exceed 150');
new Age(151);
}
// Assert on custom exception code
#[Test]
public function domain_exception_carries_correct_code(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionCode(422);
throw new DomainException('Unprocessable entity', 422);
}
// Regex match — handles variable content in message
#[Test]
public function email_includes_invalid_value_in_message(): void
{
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/not-an-email/');
new Email('not-an-email');
}
// Testing exception chaining — asserting on previous exception
#[Test]
public function service_wraps_database_exception_in_domain_exception(): void
{
$pdo = $this->createStub(\PDO::class);
$pdo->method('prepare')->willThrowException(
new \PDOException('Connection refused')
);
$repo = new \App\Repositories\UserRepository($pdo);
try {
$repo->find(1);
$this->fail('Expected DomainException was not thrown');
} catch (DomainException $e) {
$this->assertSame('Failed to fetch user', $e->getMessage());
$this->assertInstanceOf(\PDOException::class, $e->getPrevious());
$this->assertStringContainsString('Connection refused', $e->getPrevious()->getMessage());
}
// Using try/catch here IS correct because we need to inspect
// both the exception AND its previous — expectException cannot do that
}
// Data provider for multiple invalid inputs that each throw
public static function invalidEmails(): array
{
return [
'missing @' => ['notanemail'],
'missing domain' => ['user@'],
'missing tld' => ['user@domain'],
'empty string' => [''],
'whitespace' => [' '],
];
}
#[Test]
#[DataProvider('invalidEmails')]
public function email_rejects_invalid_formats(string $input): void
{
$this->expectException(ValidationException::class);
new Email($input);
}
}Interview Q&A
Q: When is it acceptable to use try/catch in a test instead of expectException(), and what must you do to avoid false-positive tests?
The only legitimate use of try/catch in tests is when you need to assert on properties of the exception that expectException() cannot reach—specifically, the previous exception ($e->getPrevious()), custom exception properties, or when you need to continue the test after catching. When you use try/catch, you must always add a $this->fail('Expected exception was not thrown') line immediately before the try block, or at the end of the try block before the catch. Without it, if the exception is never thrown, the catch is skipped and the test passes with no assertions.
Q: What happens if you call $this->expectException() after the code that throws?
The exception is thrown and propagates immediately, before PHPUnit has registered the expectation. PHPUnit catches the unexpected exception and marks the test as an error (not a failure—there's a difference). The expectException() call you wrote after the throw is never reached. Always call all expect*() methods before the code that triggers the exception—they register expectations in PHPUnit's internal state so it knows to catch and inspect the exception rather than letting it propagate as an error.
Q: How do you test that a method does NOT throw an exception?
Use $this->expectNotToPerformAssertions() if there are truly no other assertions, or simply don't call any expectException*() method and let the test pass naturally. If the code does throw, PHPUnit will catch it and mark the test as an error. You can also wrap the call in try/catch and call $this->fail() inside the catch: try { $this->sut->doSomething(); } catch (\Exception $e) { $this->fail('Unexpected exception: ' . $e->getMessage()); }. However, having a meaningful positive assertion (that the method returned the expected value) is always preferable to asserting absence of exceptions.