Testing Exceptions in PHPUnit Correctly
AI generated
@test
assert
PHPUnit · PHP 8.4 · Exception Testing · Unit Tests
Testing Exceptions in PHPUnit Correctly
expectException, codes, messages, and custom hierarchies

Anyone who does not deliberately test exceptions leaves error handling unverified, which is the most dangerous blind spot in a PHP test suite. PHPUnit provides precise methods to ensure that the correct exception type, the right message, and the correct error code are actually raised.

12 min read expectException · expectExceptionMessage · expectExceptionCode PHPUnit 11 · PHP 8.4

1. Why exception tests are indispensable

Every PHP application running in production contains code paths that should raise an exception when given invalid input, missing resources, or violated business rules. Whether that exception is actually raised, what type it has, and whether the error message is understandable to the caller remains completely untested without deliberate tests. Developers then rely on manual checking or only discover the bug in production, when a stack trace shows up in the logs.

PHPUnit offers a clean, declarative API for exactly this requirement. The methods expectException(), expectExceptionMessage(), and expectExceptionCode() are placed before the call to the code under test and define what PHPUnit expects. If no exception, or a different one, is raised, the test fails, precisely and traceably. That makes exception tests a core part of a complete test suite, not an optional extra.

In modern PHP projects with strict types and domain-driven exception hierarchies, testing exceptions is especially valuable. An InvalidArgumentException thrown for invalid constructor arguments, or a domain-specific InsufficientStockException, both represent explicit system behavior that must be tested and protected against regression.

2. expectException: checking the type correctly

The most basic form of exception testing in PHPUnit is $this->expectException(ExceptionClass::class). This method must come before the call to the code that is supposed to throw the exception. PHPUnit registers the expectation, runs the test body, and checks whether an exception of the given type was thrown. If no exception is thrown, PHPUnit marks the test as failed. If an exception of a different type is thrown, the test also fails.

One important aspect: expectException() checks the type including the entire class hierarchy. If you write expectException(\RuntimeException::class) and the code actually throws a custom DatabaseConnectionException extends \RuntimeException, the test passes, because DatabaseConnectionException is a subtype of RuntimeException. If you want to check the exact type only, you must specify the concrete class name. This distinction is crucial in projects with deep exception hierarchies.


<?php
declare(strict_types=1);

namespace Mironsoft\Shop\Tests\Unit\Domain;

use Mironsoft\Shop\Domain\Product\Exception\InvalidPriceException;
use Mironsoft\Shop\Domain\Product\PriceCalculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new PriceCalculator();
    }

    #[Test]
    public function throwsInvalidPriceExceptionForNegativePrice(): void
    {
        // Expectation must be declared BEFORE the code that throws
        $this->expectException(InvalidPriceException::class);

        // This call must throw, PHPUnit verifies the expectation
        $this->calculator->calculateNetPrice(-9.99, 0.19);
    }

    #[Test]
    public function throwsInvalidArgumentExceptionForZeroTaxRate(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->calculator->calculateNetPrice(100.00, 0.0);
    }
}

3. expectExceptionMessage and MessageMatches

The type of an exception alone is often not enough to verify correct behavior. An InvalidArgumentException can have many different causes, and the error message carries crucial context information for developers and logging systems. expectExceptionMessage() checks for an exact match, while expectExceptionMessageMatches() expects a regular expression, useful when the message contains dynamic parts such as file names, IDs, or timestamps.

The choice between an exact message and a regex pattern has a practical consequence for test maintainability. Exact messages make tests brittle against wording changes. Regex patterns such as '/price must not be negative/i' or '/ID \d+ not found/' are more robust while still covering enough to ensure that the right error message is produced. In projects with multilingual error messages or configuration, you should limit the assertion to the invariant core of the message.


<?php
declare(strict_types=1);

use Mironsoft\Shop\Domain\Order\Exception\OrderNotFoundException;
use Mironsoft\Shop\Domain\Order\OrderRepository;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

final class OrderRepositoryTest extends TestCase
{
    #[Test]
    public function throwsWithExactMessageWhenOrderNotFound(): void
    {
        $repository = $this->createStub(OrderRepository::class);
        $repository->method('findById')
            ->willThrowException(new OrderNotFoundException('Order 42 not found'));

        $this->expectException(OrderNotFoundException::class);
        // Exact match, brittle if message text changes
        $this->expectExceptionMessage('Order 42 not found');

        $repository->findById(42);
    }

    #[Test]
    public function throwsWithDynamicIdInMessage(): void
    {
        $repository = $this->createStub(OrderRepository::class);
        $repository->method('findById')
            ->willThrowException(new OrderNotFoundException('Order 9876 not found'));

        $this->expectException(OrderNotFoundException::class);
        // Regex match, robust against message reformulation
        $this->expectExceptionMessageMatches('/Order \d+ not found/');

        $repository->findById(9876);
    }
}

4. expectExceptionCode: testing error codes precisely

Alongside the message, PHP exceptions also carry an integer error code that plays an important role in many domains. HTTP status codes, internal error codes for API clients, or database-specific error numbers are often carried via the exception code. expectExceptionCode() ensures that the code is set correctly, independent of the wording of the message. That makes tests more stable in environments where error messages are localized or change over time while the semantic code stays constant.

A sensible practice in complex systems is to define exception codes as constants in the respective exception classes. That makes tests readable: expectExceptionCode(PaymentException::CARD_DECLINED) expresses the intent directly, instead of writing in a cryptic number like 4003. These constants simultaneously serve as documentation of the possible error scenarios and can be reused in API references and logging systems.

5. Structuring custom exception hierarchies

In domain-driven PHP projects, a flat exception hierarchy that only uses standard PHP exceptions is a sign of insufficient modeling. Custom exception classes carry domain knowledge, enable targeted catching in application code, and make tests more expressive. The basic principle: every module or bounded context gets its own base exception, from which all module-specific exceptions inherit.

The pattern DomainException extends \DomainException, with module-specific subclasses such as ProductNotFoundException extends ProductException, allows both specific and generic catching. In tests, you can test either the exact type or the base exception, depending on what makes sense at the given test level. Integration tests often check only the base exception, unit tests the concrete subtype. This flexibility is a direct advantage over using standard PHP exceptions everywhere.


<?php
declare(strict_types=1);

namespace Mironsoft\Shop\Domain\Product\Exception;

// Base exception for the Product domain
class ProductException extends \DomainException {}

// Specific exceptions extend the domain base
class ProductNotFoundException extends ProductException
{
    public static function forId(int $id): self
    {
        return new self(
            message: sprintf('Product with ID %d was not found', $id),
            code: 404
        );
    }
}

class InsufficientStockException extends ProductException
{
    public static function forProduct(int $productId, int $requested, int $available): self
    {
        return new self(
            message: sprintf(
                'Product %d: requested %d units but only %d available',
                $productId, $requested, $available
            ),
            code: 409
        );
    }
}

// Test: verifies both specific type and base-type catching
final class ProductExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testNotFoundIsSubtypeOfProductException(): void
    {
        $exception = ProductNotFoundException::forId(99);

        $this->assertInstanceOf(ProductException::class, $exception);
        $this->assertInstanceOf(\DomainException::class, $exception);
        $this->assertSame(404, $exception->getCode());
        $this->assertStringContainsString('99', $exception->getMessage());
    }
}

6. Combining exceptions with DataProvider

Many validation rules produce similar but not identical exceptions for different invalid inputs. Instead of writing a separate test method for every input case, DataProvider attributes can be used efficiently to parameterize the same test body with different combinations of input, expected exception type, message, and code. That reduces redundancy and ensures that all edge cases are tested consistently.

When combining DataProvider with exception tests, the order matters: the expect*() calls must be inside the test method, not in the DataProvider itself. The DataProvider only supplies raw data. This makes it possible to check different exception types per data set by passing the class name as a parameter and forwarding it in the test body to expectException(). This technique saves a significant amount of code while still fully covering every variant.

7. Common pitfalls and wrong patterns

The most common mistake in exception testing with PHPUnit is placing code after the throwing call that never actually runs. Since the exception interrupts the test body, all assertions after the throwing call are useless and can create a false sense of security. PHPUnit automatically runs the expect*() assertions at the end of the test, but custom assertions after the throwing code are never reached.

A second dangerous anti-pattern is wrapping the throwing code in a try-catch block inside the test and manually running assertions on the caught exception. That technically works, but it completely bypasses the PHPUnit infrastructure. If no exception is thrown, the test runs through and is marked as passed, even though the error case never occurred. The correct pattern is always expectException() without a surrounding try-catch.


<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

final class ValidationExceptionTest extends TestCase
{
    // WRONG: try-catch hides missing exception, test passes even without throw
    public function wrongPattern(): void
    {
        try {
            $this->someService->validate('');
            // If no exception is thrown, test still passes, dangerous!
        } catch (\InvalidArgumentException $e) {
            $this->assertStringContainsString('required', $e->getMessage());
        }
    }

    // RIGHT: expectException before the throwing call
    #[Test]
    public function correctPattern(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessageMatches('/required/i');

        // PHPUnit catches the exception and verifies the expectation
        $this->someService->validate('');
        // Nothing after this line, code here never executes
    }

    /** @return array<string, array{input: string, exClass: class-string, msgRegex: string}> */
    public static function invalidInputProvider(): array
    {
        return [
            'empty string'    => ['input' => '',     'exClass' => \InvalidArgumentException::class, 'msgRegex' => '/required/i'],
            'negative number' => ['input' => '-5',   'exClass' => \RangeException::class,           'msgRegex' => '/negative/i'],
            'too long'        => ['input' => str_repeat('a', 256), 'exClass' => \LengthException::class, 'msgRegex' => '/255/'],
        ];
    }

    #[Test]
    #[DataProvider('invalidInputProvider')]
    public function throwsCorrectExceptionForInvalidInput(
        string $input,
        string $exClass,
        string $msgRegex
    ): void {
        $this->expectException($exClass);
        $this->expectExceptionMessageMatches($msgRegex);
        $this->someService->validate($input);
    }
}

8. Exception test methods compared

PHPUnit offers several mechanisms for exception testing, and choosing the right approach significantly affects the readability, maintainability, and precision of the tests.

Method Checks Recommendation Note
expectException() Exception type (including subclasses) Always use Mandatory for every exception test
expectExceptionMessage() Exact message text Use with caution Brittle against wording changes
expectExceptionMessageMatches() Regex pattern in the message text Preferred More robust than an exact match
expectExceptionCode() Integer error code Important for API codes Make it readable with constants
try-catch in the test Manual, whatever you write into it Avoid Does not fail without an exception

9. Integrating exception tests into CI and coverage

Exception tests contribute to code coverage, but only if the code path that throws the exception is actually executed. A common misunderstanding: if you set expectException() but have no assertions on the actual code underneath, you get no coverage for the throwing path. PHPUnit counts execution of the code that throws the exception as covered, but only if @covers or the #[CoversClass] attribute is set correctly.

In CI pipelines, exception coverage should be treated as a separate criterion. Branch coverage, not just line coverage, shows whether both the success path and the exception path through conditional logic are tested. PHPUnit with the --coverage-clover flag produces reports that Infection (mutation testing) uses as input, and mutation testing is the most reliable tool for checking whether exception tests are truly meaningful or merely simulate the exception being raised.

10. Summary

Exceptions are explicit system behavior and must be tested as such. expectException() checks the type, expectExceptionMessageMatches() checks the message robustly via regex, expectExceptionCode() checks the semantic error code. The expect*() calls must come before the throwing code, try-catch blocks in the test body are an anti-pattern. Structure custom exception hierarchies by domain and define error codes as constants to keep tests readable and maintainable.

DataProvider attributes enable efficient testing of different inputs with different exception expectations without duplicating code. In CI pipelines, branch coverage ensures that both success and error paths are covered. Mutation testing with Infection shows whether exception tests really secure system behavior or merely document execution without meaning.

Testing Exceptions in PHPUnit: The Essentials at a Glance

Correct order

expectException() must come before the throwing code. Everything after it never runs. Never use try-catch in the test body as a substitute.

Testing messages

expectExceptionMessageMatches() with a regex instead of an exact text, more robust against wording changes, especially with dynamic values in the message.

Exception hierarchies

A domain-specific base exception per module. Concrete subclasses with static named-constructor methods for readable test fixtures.

Coverage and CI

Enable branch coverage. Use Infection mutation testing to check whether exception tests have real meaning or merely document execution.

Mironsoft

PHP development, testing strategy, and code quality

Test suites that really protect you?

We analyze existing PHPUnit test suites, identify untested exception paths, and add targeted tests, with a focus on branch coverage, mutation testing, and domain-specific exception hierarchies.

Test audit

Analysis of existing test suites for untested exception paths and anti-patterns

Exception design

Designing and documenting domain-specific exception hierarchies

Mutation testing

Infection integration into the CI pipeline for meaningful test quality measurement

11. FAQ: Testing exceptions in PHPUnit

1Why must expectException() come before the throwing code?
PHPUnit registers the expectation before execution. The throwing call interrupts the test body. If expectException() comes after it, it is never reached.
2Multiple expectException() calls in one test?
Not possible, the first throwing call aborts the test body. Use separate test methods or a DataProvider.
3expectExceptionMessage vs. expectExceptionMessageMatches?
Message checks an exact substring. MessageMatches takes a regex, more robust for dynamic content such as IDs or timestamps.
4How do I test that no exception is thrown?
Do not set expectException() and add a real assertion. Without an assertion, PHPUnit marks the test as 'risky'.
5Is try-catch in the test body ever useful?
Only for additional properties like getPrevious(). Then add $this->fail() after the catch, so the test fails if no exception comes.
6Are exception tests counted in coverage?
Yes. The throwing code path is marked as executed. Branch coverage additionally shows whether the success path is also tested.
7Exception hierarchies in Magento modules?
Derive a ModuleException as the base from LocalizedException. Specific subclasses for EntityNotFoundException, ValidationException, etc. Targeted catching at the module level becomes possible.
8expectExceptionCode() with strings?
No, PHP exception codes are integers. For string codes, define a custom getter and check it with assertSame().
9DataProvider with different exception types?
Pass the class name as a class-string parameter and forward it in the test body to expectException($exClass). This way, a single test body covers every variant.
10What does mutation testing show for exception tests?
Infection inverts exception conditions. If a mutant survives, the test does not really cover the raising of it, only its execution is documented, not secured.