Question

Testing Exceptions with PHPUnit in PHP

phpexceptionphpunitassert

Question

How can I write a PHPUnit test that verifies code throws an exception under a specific condition? Is there a PHPUnit assertion or expectation for testing whether an exception was thrown by the code under test?

Short Answer

You will learn how PHPUnit tests expected exceptions with expectException(), how to check an exception message or code, and how to ensure the exception comes from the intended line of code.

Concept

Exceptions represent abnormal or invalid situations that normal code cannot successfully complete, such as invalid input, a missing record, or an unavailable service.

A test for an exception has two jobs:

  1. Arrange a situation that should fail.
  2. Declare which exception is expected, then run the code.

In PHPUnit, the usual approach is to declare an expectation with expectException(). If the tested code throws an exception matching that class, the test passes. If it throws nothing, or throws a different exception type, the test fails.

Testing exceptions matters because failure behavior is part of an application's contract. For example, callers may rely on an invalid email address causing an InvalidArgumentException rather than silently creating a broken user record.

Mental Model

Think of an exception expectation as telling the test runner: “When I press this button, an alarm must sound.”

  • expectException() describes the alarm you expect to hear.
  • The method call is pressing the button.
  • PHPUnit passes the test only if the matching alarm occurs.

Declare the expected alarm immediately before pressing the button. If you declare it too early, an unrelated earlier line could trigger the expected exception and make the test pass for the wrong reason.

Take Quiz

Syntax and Examples

The basic PHPUnit syntax is:

$this->expectException(ExceptionClass::class);

$object->methodThatShouldThrow();

For example, this function rejects an empty name:

function createGreeting(string $name): string
{
    if ($name === '') {
        throw new InvalidArgumentException('Name cannot be empty.');
    }

    return "Hello, {$name}!";
}

A PHPUnit test can verify that behavior:

use PHPUnit\Framework\TestCase;

final class GreetingTest extends TestCase
{
    public function testEmptyNameThrowsAnException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        createGreeting('');
    }
}

You can make the test more precise when the message or error code is part of the public behavior:

public function testEmptyNameHasHelpfulMessage(): void
{
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('Name cannot be empty.');

    createGreeting('');
}

Useful PHPUnit expectation methods include:

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Name cannot be empty.');
$this->expectExceptionMessageMatches('/cannot be empty/i');
$this->expectExceptionCode(422);

expectException() accepts a class name. An exception that extends the expected class also matches. For example, expecting RuntimeException::class also accepts a custom exception that extends RuntimeException.

Step by Step Execution

Consider this test:

use PHPUnit\Framework\TestCase;

final class AgeValidatorTest extends TestCase
{
    public function testNegativeAgeIsRejected(): void
    {
        $validator = new AgeValidator();

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Age cannot be negative.');

        $validator->validate(-1);
    }
}

Assume validate() is implemented as follows:

final class AgeValidator
{
    public function validate(int $age): void
    {
        if ($age < 0) {
            throw new InvalidArgumentException('Age cannot be negative.');
        }
    }
}

Execution proceeds like this:

  1. PHPUnit creates an AgeValidator instance.
  2. expectException() tells PHPUnit that an InvalidArgumentException must occur later in this test.
  3. expectExceptionMessage() adds a requirement for the exception message.
  4. validate(-1) runs.
  5. Since -1 is below zero, validate() throws InvalidArgumentException.
  6. PHPUnit compares the thrown exception with the expectations.
  7. The type and message match, so the test passes.

If validate(-1) did not throw an exception, PHPUnit would fail the test because an expected exception was missing.

Real World Use Cases

Exception tests are useful whenever code rejects invalid input or cannot complete an operation safely.

  • API validation: Reject a request with a missing required field.
  • Authentication: Throw an exception when a token is expired or malformed.
  • Domain rules: Prevent an order from being paid twice.
  • File processing: Report an error when an uploaded file has an unsupported format.
  • Database lookups: Throw a UserNotFoundException when a required user does not exist.
  • Configuration: Fail early when a required environment variable is missing.

For example, a service might reject a duplicate email address:

$this->expectException(DomainException::class);

$registrationService->register('existing@example.com', 'secret-password');
Take Quiz

Real Codebase Usage

In real projects, exception tests usually focus on the public behavior of a class rather than its internal implementation.

Validate at boundaries

Validate data as it enters a controller, service, command, or domain object:

final class PasswordPolicy
{
    public function assertValid(string $password): void
    {
        if (strlen($password) < 12) {
            throw new InvalidArgumentException('Password must contain at least 12 characters.');
        }
    }
}

Test the boundary rule directly:

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('at least 12 characters');

$policy->assertValid('short');

Use custom exceptions for meaningful business failures

A custom exception can let callers distinguish business rules from technical failures:

final class InsufficientFundsException extends DomainException
{
}
$this->expectException(InsufficientFundsException::class);

$account->withdraw(500);

Test error translation around dependencies

Applications often catch a low-level exception and throw an application-specific one:

try {
    $client->send($request);
} catch (NetworkException $exception) {
    throw new PaymentGatewayUnavailableException('Payment service is unavailable.', 0, $exception);
}

A test can verify that callers receive PaymentGatewayUnavailableException, not the dependency's internal NetworkException.

Keep expectations close to the throwing call

Place setup before the expectation. Then declare the expected exception immediately before the exact operation being tested. This prevents unrelated setup code from satisfying the expectation.

Common Mistakes

Setting the expectation too early

This test can pass for the wrong reason if createService() throws the expected exception:

// Risky: setup might satisfy the expectation.
$this->expectException(InvalidArgumentException::class);
$service = createService();
$service->register('');

Prefer this:

$service = createService();

$this->expectException(InvalidArgumentException::class);
$service->register('');

Catching the exception without making an assertion

This test passes even if no exception is thrown:

try {
    $validator->validate(-1);
} catch (InvalidArgumentException $exception) {
    // Nothing verifies that this branch ran.
}

Use expectException() instead. If you need to inspect several exception properties manually, call fail() after the tested operation:

try {
    $validator->validate(-1);
    $this->fail('Expected InvalidArgumentException was not thrown.');
} catch (InvalidArgumentException $exception) {
    $this->assertSame('Age cannot be negative.', $exception->getMessage());
}

Expecting an overly broad type

$this->expectException(Exception::class);

This may allow unrelated bugs to pass. Prefer the most specific exception type that is part of the method's contract:

$this->expectException(InvalidArgumentException::class);

Code after the throwing line does not run

$this->expectException(InvalidArgumentException::class);
$validator->validate(-1);
$this->assertTrue(true); // This line is never reached.

Put assertions about normal behavior in a separate test. Exception-focused tests should usually end with the call expected to throw.

Testing exact messages unnecessarily

Messages may change during copy edits or localization. Assert an exact message only when it is a stable and meaningful contract. Otherwise, test the exception class or a stable message fragment.

Comparisons

ApproachBest useMain limitation
expectException()The code should throw a specific exception typeDoes not itself inspect custom exception fields
expectExceptionMessage()The message is part of the expected behaviorExact messages can make tests fragile
expectExceptionMessageMatches()Only a stable message pattern mattersRegular expressions can be harder to read
expectExceptionCode()An exception code is a defined contractMany projects do not use exception codes consistently
Manual try/catchYou need to inspect custom properties, previous exceptions, or multiple detailsRequires fail() to avoid a false-positive test

expectException() is normally clearer than manually catching an exception. Use manual try/catch only when PHPUnit's expectation methods cannot express what you need.

Also distinguish exceptions from normal return values:

  • Use an exception for invalid or impossible operations that must stop.
  • Use a normal return value, such as null, false, or a result object, when absence or failure is an expected outcome that callers should handle routinely.
Take Quiz

Cheat Sheet

// Expect an exception type
$this->expectException(InvalidArgumentException::class);
$service->run($input);

// Also require an exact message
$this->expectExceptionMessage('Input is invalid.');

// Require a message that matches a regular expression
$this->expectExceptionMessageMatches('/input.+invalid/i');

// Require an exception code
$this->expectExceptionCode(422);
  • Declare expectations before the call that should throw.
  • Put expectations as close as possible to that call.
  • Prefer specific exception classes over Exception::class or Throwable::class.
  • An expected parent exception class also matches child exceptions.
  • Statements after an exception is thrown are not executed.
  • Use manual try/catch plus $this->fail() only when you need custom inspection.

FAQ

How do I assert that an exception is thrown in PHPUnit?

Call $this->expectException(ExceptionClass::class) immediately before invoking the code that should throw.

Does PHPUnit use assertThrows()?

The standard PHPUnit pattern is expectException(), not assertThrows(). Declare the expectation in the test, then call the method under test.

How do I test an exception message in PHPUnit?

Use $this->expectExceptionMessage('Expected message') along with expectException(). Use expectExceptionMessageMatches() when a regular expression is more appropriate.

Can I expect a parent exception class?

Yes. If you expect RuntimeException::class, an exception class that extends RuntimeException satisfies the expectation. Prefer the most specific type that your code promises to throw.

What happens if no exception is thrown?

The test fails because PHPUnit was told that an exception was expected.

Why did my exception test pass even though the wrong method threw?

The expectation was likely set before setup code or another operation that could throw the same exception. Move expectException() immediately before the intended method call.

When should I manually use try and catch in a test?

Use it when you must inspect data not covered by PHPUnit's expectation methods, such as custom properties or the previous exception. Call $this->fail() if the expected exception is not thrown.

Take Quiz

Mini Project

Description

Build a small TemperatureConverter class that converts Celsius to Fahrenheit while rejecting temperatures below absolute zero. This demonstrates using an exception to enforce a business rule and testing both successful and failing behavior.

Goal

Create PHPUnit tests that verify valid conversions and confirm invalid temperatures throw a precise exception.

Requirements

Create a TemperatureConverter class with a celsiusToFahrenheit() method. Reject Celsius values below -273.15. Throw an InvalidArgumentException for invalid values. Write one test for a valid conversion. Write one test that expects the exception type and a helpful message.

Take Quiz

Keep learning

Related questions

View more