framework icon indicating copy to clipboard operation
framework copied to clipboard

[12.x] Add `assertThrown()`

Open cosmastech opened this issue 11 months ago • 7 comments

This adds a testing helper for asserting a closure throws an exception/error, ensures the type, and then returns the exception. This is similar to how JUnit's assertThrows works.

The big difference from Laravel's assertThrows() is that you can make expectations against the exception.

Code examples

This is taken from tests/Auth/AuthAccessResponseTest.php.

The way its written currently

public function testAuthorizeMethodThrowsAuthorizationExceptionWhenResponseDenied_original()
{
    $response = Response::deny('Some message.', 'some_code');

    try {
        $response->authorize();
    } catch (AuthorizationException $e) {
        $this->assertSame('Some message.', $e->getMessage());
        $this->assertSame('some_code', $e->getCode());
        $this->assertEquals($response, $e->response());
    }
}

Rewritten to use the assertThrows

public function testAuthorizeMethodThrowsAuthorizationExceptionWhenResponseDenied_assertThrows()
{
    $response = Response::deny('Some message.', 'some_code');

    $this->assertThrows(
        fn() => $response->authorize(),
        function(AuthorizationException $e) use ($response) {
            $this->assertSame('some_code', $e->getCode());
            $this->assertEquals($response, $e->response());

            return true;
        },
        "Some message"
    );
}

Using the newly added method

public function testAuthorizeMethodThrowsAuthorizationExceptionWhenResponseDenied_assertThrown()
{
    $response = Response::deny('Some message.', 'some_code');

    $e = $this->assertThrown(fn () => $response->authorize(), AuthorizationException::class);

    $this->assertSame('Some message.', $e->getMessage());
    $this->assertSame('some_code', $e->getCode());
    $this->assertEquals($response, $e->response());
}

My belief is: The first way (try-catch and perform assertions in the catch block) is most common and works fine.

The second way (assertThrows()) is cumbersome to write, requires you to remember to add return true to the end, and the nested closure makes it difficult to parse. If you don't add return true to the end, you can just convert the second closure into a series of boolean statements, but then you can't really leverage the PHPUnit assertion methods which is a big loss.

The third way (assertThrown()) breaks down a layer of indentation, doesn't require adding any use ($otherVariablesFromOuterScope), nor does it have the gotcha of remembering to return true.


I do not love the name of this method, but couldn't think of anything better. I have added this as a trait in a work project, and figured I would see if it was of interest to the Laravel community at large.

cosmastech avatar Dec 31 '24 00:12 cosmastech

Seems very good method to have :)

shamimulalam avatar Dec 31 '24 11:12 shamimulalam

If it's of any help, PHPUnit has assertions for exceptions:

public function test_authorize(): void
{
    $this->expectException(AuthorizationException::class);
    $this->expectExceptionMessage('Some message');

    Response::deny('Some message', 'some_code')->authorize();
}

ziadoz avatar Jan 01 '25 14:01 ziadoz

If it's of any help, PHPUnit has assertions for exceptions:

public function test_authorize(): void
{
    $this->expectException(AuthorizationException::class);
    $this->expectExceptionMessage('Some message');

    Response::deny('Some message', 'some_code')->authorize();
}

That's a good point! I think what this approach still lacks is being able to perform additional assertions against the exception. For instance:

class PaymentFailed extends RuntimeException
{
    public PaymentMethodEnum $method;
    public User $receivingUser;

    public function setPaymentMethod(PaymentMethodEnum $method): void
    {
        $this->method = $method;
    }

    public function setReceivingUser(User $user): void
    {
        $this->receivingUser = $user;
    }
}

I may not render both of those pieces of information in the exception message, but I may want to perform assertions against them.

cosmastech avatar Jan 02 '25 00:01 cosmastech

Doesn't this already solved with https://github.com/laravel/framework/pull/50704

crynobone avatar Jan 02 '25 10:01 crynobone

Doesn't this already solved with #50704

I did not know this existed. Thanks for mentioning it and nice work @nunomaduro!

  1. This would be unavailable in tests which don't boot the application (ie, extending from PHPUnit's TestCase)
  2. It won't work for a case like this:
public function test_uniqueEmail() {
    Exceptions::fake();

    // Given
    Org::factory()->create(['id' => 12]);
    $userFactory = fn () => User::factory()->create(['email' => '[email protected]', 'org_id' => 12]);
    // And the user exists
    $userFactory();

    // When we attempt to create a user with the same email address
    $userFactory();

    // Then
    Exceptions::assertReported(function(UniqueConstraintViolationException $e) {
    self::assertStringContainsString(
            "Duplicate entry '[email protected]' for key 'users.users_email_org_id_unique'",
            $exception->getMessage()
        );
        return true;
    });
}

☝️ this could be solved with PHPUnit's expectException/expectExceptionMessage of course.

I'm not sure if I'm doing something wrong, but it doesn't seem to work here either?

public function test_ModelNotFound()
{
    // Given a route
    Route::get('/some-example', function () {
        return User::findOrFail(1);
    });

    // And we are faking exception handling
    Exceptions::fake([ModelNotFoundException::class]);
    // $this->withoutExceptionHandling();   <-- also tried this but it didn't seem to change anything

    // When we request the endpoint
    $response = $this->get('/some-example');

    // Then the response is not found
    $response->assertNotFound();

    // And the exception looks like this
    Exceptions::assertReported(function (ModelNotFoundException $e) {
        self::assertStringContainsString('No query results for model [App\Models\User] 1', $e->getMessage());

        return true;
    });
}
  1. It requires knowing which exceptions are set on Handler::$dontReportInternal (I think?) as well as which exceptions your application has indicated to not report.
  2. It requires returning true at the end of the expectation closure (which isn't indicated in the docblock)
  3. It has the problems of being a closure (additional indentation, more cumbersome to read/write data to the outside scope)

Obviously anyone is free to add their own AssertsExceptionsTrait, this isn't a bug, but a quality of life improvement.

cosmastech avatar Jan 02 '25 12:01 cosmastech

As I was working with this method today, I realized that this causes a failing test due to no assertions:

#[Test]
public function throws_an_exception(): void
{
    $myFunction = static fn () => throw new \RuntimeException();
    
    self::assertThrown($myFunction, \RuntimeException::class);
}

Just making mention of it.

cosmastech avatar Jan 15 '25 11:01 cosmastech

@crynobone thanks for updating the target branch. Do you know if there's anything I can do to get this re-considered?

cosmastech avatar Mar 20 '25 20:03 cosmastech