Testing Exceptions in PHPUnit

Last week i gave 10 phpunit tips. This week we’ll take a look at testing exceptions, which wasn’t covered in that post.

Lets start with some example code that we will be testing. We have the Email and EmailValidator classes. Email is a value object that makes sure it is a valid email. We use the EmailValidator to make sure that the emails are only from our company.

//Email.php
final class Email
{
    private function __construct(
        private string $email
    ){}

    public static function create(string $email): self
    {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(sprintf(
                '"%s" is not a valid email',
                $email
            ));
        }

        return new self($email);
    }

    public function asString(): string
    {
        return $this->email;
    }
}

//EmailValidator.php
final class EmailValidator
{
    public function validateCompanyEmail(Email $email): void
    {
        if (! str_ends_with($email->asString(), '@company.com')) {
            throw new InvalidArgumentException(
                'Only "@company.com" emails are allowed'
            );
        }
    }
}

Now, we create a test that makes sure we don’t allow emails other than @company.com. We use the expectException method to tell PHPUnit that we expect this exception. If it is not thrown, or if another exception is thrown, then this test will fail.

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

        $email = Email::create('foo@bar.com');

        $validator = new EmailValidator();
        $validator->validateCompanyEmail($email);
    }
}

However, we made an error in this test. By starting with the expectException, the test will pass as long the InvalidArgumentException is thrown anywhere within this test. So if we make a typo, and pass foo@barcom to the Email::create, an exception will be thrown. And the test will pass. But then we never test the EmailValidator.

To make sure we are properly testing the exception we need to call expectException just before the method that throws. Now if we run the test, it still passes. But if we make a mistake, and Email::create throws an exception, our test will fail.

final class EmailValidatorTest extends TestCase
{
    public function testItValidatesEmail(): void
    {
        $email = Email::create('foo@bar.com');

        $validator = new EmailValidator();

        $this->expectException(InvalidArgumentException::class);
        $validator->validateCompanyEmail($email);
    }
}

Testing multiple exception paths

Your method may throw exceptions for different reasons. For example, lets say our email validator also makes sure that our colleague bob can’t log in. So the method would look like this.

public function validateCompanyEmail(Email $email): void
{
    if (! str_ends_with($email->asString(), '@company.com')) {
        throw new InvalidArgumentException(
            'Only "@company.com" emails are allowed'
        );
    }

    if ($email->asString() === 'bob@company.com') {
        throw new InvalidArgumentException(
            'bob is no longer allowed to log in'
        );
    }
}

Now we need to test the second path. So lets write that.

public function testBobCantLogIn(): void
{
    $email = Email::create('bob@bar.com');

    $validator = new EmailValidator();

    $this->expectException(InvalidArgumentException::class);
    $validator->validateCompanyEmail($email);
}

But we forgot to turn bar.com into company.com. The test is still green, but we don’t actually test that bob can’t log in.

There are a few options here. We could introduce different exception classes for each error state. Or we can use expectExceptionMessage to make sure we get the correct error. So if we add $this->expectExceptionMessage('bob is no longer allowed to log in'); just after the expectException call, then our test turns red. If we then change @bar.com to @company.com, our test turns green again.

The best practice would be to do both. Introducing new exception classes per error means we can more easily catch the right error. However, you may be dealing with legacy code, where a new exception class could break things. Validating you get the right error message is really important if that message faces users. But even if it is only for other devs, you want to make sure you get the correct exception.

Testing Exceptions with data

Sometimes we use exceptions to pass data back. Perhaps a validator that can have multiple failures, and that has an array of all the errors. Lets take a look at the FormValidator class, and how we would test it. This class validates some information, and may throw an exception, containing the data of all things that went wrong.

final class FormValidator
{
    public function validate(
        DateTimeImmutable $start,
        DateTimeImmutable $end,
        int $newId,
        string $description
    ): void {
        $errors = [];
        if ($start > $end) {
            $errors[] = 'End must be after start';
        }

        if ($newId <= 0) {
            $errors[] = 'The new id must be greater than 0';
        }

        if ($description === '') {
            $errors[] = 'Description can not be empty';
        }

        if ($errors !== []) {
            throw FormValidationException::fromValidationErrors($errors);
        }
    }
}

If we need to test this, using expectException doesn’t make much sense. expectExceptionMessage wont help us out either, as it doesn’t have a message. Instead we can use the good only try catch here.

final class FormValidatorTest extends TestCase
{
    public function testValidatesMultipleErrors(): void
    {
        $validator = new FormValidator();

        try {
            $validator->validate(
                new DateTimeImmutable('2020-01-01'),
                new DateTimeImmutable('1999-01-01'),
                -3,
                ''
            );
            $this->fail('FormValidationException was not thrown');
        } catch (FormValidationException $e) {
            $this->assertSame(
                [
                    'End must be after start',
                    'The new id must be greater than 0',
                    'Description can not be empty',
                ],
                $e->getErrors()
            );
        }
    }
}

Important here is the fail after the validation. If the method throws no exception, we need to fail the test. If we don’t add that call then our test would pass if no exception was thrown.

But, if you don’t need to check any details, other than the exception class, message or code, use expectException.

In conclusion

You can use expectException to test your exceptions. But only set the exception just before it is thrown. Use expectExceptionMessage if the message is important, or if it is the only way to see where something went wrong. Use try catch if you need to validate specific properties of the exception.

For this post, PHPUnit 9.5 and PHP 8.0 were used. If you want to run the tests for yourself, you can find them on github. If you were looking for information on how to test warnings and notices, i have a post about that as well right here.

Avatar
Gert de Pagter
Software Engineer

My interests include software development, math and magic.