DEV Community

Philipp Scheit
Philipp Scheit

Posted on

Trick for testing exceptions in PHPUnit

When you're using PHPUnit to test for an exception you can use $this->expectException(EvatrInvalidVatNumberException::class); for setting up your test to listen to the exception. If the expected exception is not thrown your test will fail and PHPUnit will report the issue.

When you only need to assert the message (or a substring) you would use $this->expectExceptionMessage().

In most test cases this works very well. But sometimes you might want to assert some more aspects of the exception. For example if it contains some debug infos.

Of course you could try to use $this->expectExceptionObject() but this is only a shortcut for expecting the class, message and code. It won't compare properties on the exception for example (!).

You could assert that the exception is thrown when you write the whole test by hand like this:

public function testAnExceptionIsThrown(): void { try { $this->start(); } catch (Domain\Exception $e) { $this->assertEquals('some debug info', $e->debugInfo); return; } $this->fail('Expected Exception is not thrown'); } 
Enter fullscreen mode Exit fullscreen mode

This works. But its not very nice. The fail now does not provide any information anymore which exception is thrown instead. Therefore does not convey why the tests fails. And the return in the catch is hard to read and easy to miss.

Looking closer at it, there is another issue. The exception count is wrong: assertContains when the test passes, but actually you have two assertions

  1. The correct exception is thrown
  2. The exception contains the debug info

A Better Approach

public function testAnExceptionIsThrownWithDebugInfo(): void { $this->expectException(Domain\Exception::class); try { $this->start(); } catch (Domain\Exception $e) { $this->assertEquals('some debug info', $e->debugInfo); throw $e; } } 
Enter fullscreen mode Exit fullscreen mode

Testing the exception like this has some advantages:

  • Assertion count is now correct
  • Your test will still be reporting correctly if the exception is changed
  • It's more readable
  • When you miss to add the throw the test will still fail
  • The failing test (wrong exception thrown) will report nicely which exception is caught instead and skip the assertEquals

Insights from the comments

Be extra careful, when using something like

class Domain\Exception extends \RuntimeException { public function __construct( string $message = '', int $code = 0, \Throwable $previous = null, public string $debugInfo = ''; ) {} } // and in the test: $this->expectExceptionObject( new Domain\Exception( message: 'this went wrong', debugInfo: 'someDebugInfo', ), ); 
Enter fullscreen mode Exit fullscreen mode

Because the phpunit implementation (til 15) is:

final protected function expectExceptionObject(\Exception $exception): void { $this->expectException($exception::class); $this->expectExceptionMessage($exception->getMessage()); $this->expectExceptionCode($exception->getCode()); } 
Enter fullscreen mode Exit fullscreen mode

So the test would not compare the $debugInfo, even if it looks like it SHOULD.

Originally published on ps-webforge.com on March 3rd, 2013, updated Dec 2025

Top comments (8)

Collapse
 
xwero profile image
david duymelinck

If you re-upload an article, I suggest you add updated information. expectExceptionMessage is available since 2015. Which reduces the test code to

public function testAnExceptionIsThrownWithDebugInfo(): void { $this->expectException(Domain\Exception::class); $this->expectExceptionMessage('some debug info'); $this->start(); } 
Enter fullscreen mode Exit fullscreen mode
Collapse
 
pscheit profile image
Philipp Scheit • Edited

Hey @xwero did you maybe read an earlier version of the article? Because I went ahead and updated it exactly because of that. So thanks for the great hint! Maybe you didn't see my edit yet?

I added the 'some debug info' as a property of the exception, to show that this is only needed if you have special exceptions.

First i thought "oh there is expectExceptionObject", so that it can be:

$e = new Domain\Exception('the message'); $e->debugInfo = 'some debug info'; $this->expectExceptionObject($e) 
Enter fullscreen mode Exit fullscreen mode

But this wont work as well!
Because if you look at the phpunit implementation

 final protected function expectExceptionObject(\Exception $exception): void { $this->expectException($exception::class); $this->expectExceptionMessage($exception->getMessage()); $this->expectExceptionCode($exception->getCode()); } 
Enter fullscreen mode Exit fullscreen mode

Hope you read the article again, does this explain it better for you now?

Collapse
 
xwero profile image
david duymelinck • Edited

I can still see the assertEquals and the try catch, so I'm not sure which post you changed?

If you want to use expectExceptionObject the test code will be.

public function testAnExceptionIsThrownWithDebugInfo(): void { $this->expectException(new Domain\Exception()); $this->start(); } 
Enter fullscreen mode Exit fullscreen mode

I prefer to use Pest, then the code is expect($this-start())->toThrow( Domain\Exception::class, 'some debug info');. Having to read from the bottom up is not the way I'm taught to read text.

Thread Thread
 
pscheit profile image
Philipp Scheit

My point is: the try catch is still needed.

Given the class

class Domain\Exception extends \RuntimeException { public string $debugInfo; } 
Enter fullscreen mode Exit fullscreen mode

You cannot test this without using try catch. The code

 $this->expectException(new Domain\Exception()); 
Enter fullscreen mode Exit fullscreen mode

would error:
github.com/sebastianbergmann/phpun...
this only accepts string not an object. And expectExceptionObject() (i guess this is what you had in mind) wouldn't compare the public property $debugInfo.

Thread Thread
 
pscheit profile image
Philipp Scheit • Edited

Here is a demo:

<?php namespace AppBundle; use PHPUnit\Framework\TestCase; class DomainException extends \Exception { public string $debugInfo; } class ExceptionTest extends TestCase { public function testSomethingThrows_notPossibleWithPhpunit(): void { $e = new DomainException('not relevant message'); $e->debugInfo = 'some debug info'; $this->expectExceptionObject($e); $this->start();` // this test passes! } public function testSomethingThrows_needsTryCatch(): void { $e = new DomainException('not relevant message'); $e->debugInfo = 'some debug info'; try { $this->start(); } catch (DomainException $thrown) { $this->assertEquals($e, $thrown); throw $e; } // this fails } private function start(): void { $e = new DomainException('not relevant message'); $e->debugInfo = 'WRONG debug info'; throw $e; } } 
Enter fullscreen mode Exit fullscreen mode

here is the output from phpunit 12:

.F 2 / 2 (100%) Time: 00:00.004, Memory: 16.00 MB There was 1 failure: 1) AppBundle\ExceptionTest::testSomethingThrows_needsTryCatch Failed asserting that two objects are equal. --- Expected +++ Actual @@ @@ 'message' => 'not relevant message' 'code' => 0 'previous' => null - 'debugInfo' => 'some debug info' + 'debugInfo' => 'WRONG debug info' ) 
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
xwero profile image
david duymelinck

Ok I get it now, but why would an exception class should have an extra property? Is there a real world situation you needed this?

PS: yes the expectException was meant to be expectExceptionObject in the example.

Thread Thread
 
pscheit profile image
Philipp Scheit • Edited

Yeah would say so, e.g. the debug info, which is maybe really really long, you want to transport that in the exception to later render it somehow, but dont want to add it to the exception message - cause it's way too long.

Another one I can think of is e.g. BranchAlreadyExistsException and you want to pass in the branch name, so you don't have to parse it from the exception. Sometimes we pass the translation key and parameters into exceptions, so that we can render a message to the user later (where the exception message is used only internally).

I think the expectExceptionObject() is really a misleading API! I just learned this yesterday, when I updated the article and had to immediately search my codebase if I used it somewhere :D

Thread Thread
 
xwero profile image
david duymelinck • Edited

dont want to add it to the exception message - cause it's way too long

If I really wanted debug information I would create a separate log entry with it. The exception should not be extended with a property that is only useful occasionally.

Sometimes we pass the translation key and parameters into exceptions, so that we can render a message to the user later (where the exception message is used only internally).

That is a code smell in my book. The message should end up in the logs, and if it only has translation metadata, the log is useless.

But if a property is needed I think the try catch shouldn't be in the test methods. That should be the job of a helper method.
The example is also a test that could fail on multiple points:

  • The function/method that gets called is changed so that no exception is thrown.
  • The exception that is thrown doesn't have the property you want to test.

I would create an expectExceptionPropertyValue method

function expectExceptionPropertyValue(Closure $actual, string $property, mixed $expected): void { $caught = null; try{ $actual(); }catch (\Exception $exception){ $caught = $exception; } if($caught === null){ throw new ExpectationFailedException('No exception was thrown.'); } if( ! isset($caught->$property)){ throw new ExpectationFailedException("The exception has no $property property."); } $this->assertSame($expected, $caught->$property); } 
Enter fullscreen mode Exit fullscreen mode

In the test you can now do $this->expectExceptionPropertyValue( fn() => $this->start(), 'debugInfo', 'some debug info').
This helper will fail when changes are made by people with less knowledge of the code.

I think the expectExceptionObject() is really a misleading API!

It does what it says, the thrown exception is checked against all the exception asserts of the added exception.
I do think that the exception asserts should allow custom extensions.