I've finally extended PHPUnit in Laravel.

I've finally extended PHPUnit in Laravel.

Denver F's photo
Denver F
·Apr 28, 2022·

3 min read

Table of contents

  • The Premise
  • The Situation
  • The Solution v1.0
  • The Solution v1.1

The Premise

I've been writing PHP for many years at this point and I've always ignored testing preferring to just get the code to execute and fix any issues. The problem with that is I am not a user and can't think like a user. This tweet shows why programmers and QA are not sufficient for testing.

The Situation

However, I have recently started a new project for a friend and I am trying my darndest to follow TDD. Actually, what I'm doing is writing a basic implementation before I write the test then rewriting what I wrote to be more better.

All this to say that in one of my tests I am using assertViewIs(string: '') which is provided by Laravels abstraction of PHPUnit. However, I like to be thorough so I wanted to chain assertViewIsNot(string: '') right after. This is a method not supplied by Laravel.

Let's take the Test Case

public function test_we_can_see_the_index_page_when_unauthenticated(): void
{
    $response = $this->get('/');

    $response->assertStatus(200)
        ->assertViewIs('index')
        ->assertViewIsNot('home');
}

Pretty straight-forward in my opinion. Request the index page, check we get an HTTP_OK 200 response, check we have the view called index.blade.php and check that it's not home.blade.php.

Currently, assertViewIsNot(string: '') doesn't exist so we need to create that. I tried extracting my own TestCase (AppTestCase) from the TestCase Laravel supplies but that didn't work. Following the assertViewIs(string: '') to its home, I find it's located in vendor/laravel/src/Illuminate/Testing/TestResponse.php. I do a little light googling (googleing?) and see a post on StackOverflow saying that TestResponse has the Macroable trait which means I can hook into the class and write my own methods without having to modify any vendor files, fork code or submit patches.

The Solution v1.0

I initially added this to AppServiceProvider but then thought to myself that I don't want that code being executed on every request, it's only ever going to be needed when running tests so I opened up tests/CreatesApplication.php which is a trait called by TestCase.

By adding one line of code to the createApplication() method

public function createApplication(): Application
{
    $app = require __DIR__.'/../bootstrap/app.php';

    $app->make(Kernel::class)->bootstrap();

    // this line right here.
    $this->assertViewIsNot();

    return $app;
}

I can then add this method

public function assertViewIsNot(): void
{
    TestResponse::macro('assertViewIsNot', function(string $value) {
        $this->ensureResponseHasView();

        PHPUnit::assertNotEquals($value, $this->original->name());

        return $this;
    });
}

and thus my tests now pass!

Yeah, I see an issue here too. For however many custom assertions I add, that createApplication() method will get very long. My next change was to then rename the function to macros and then we can add as many custom assertions as we want

public function macros(): void
{
    /**
     * Assert that the response view does not equal the given value.
     */
    TestResponse::macro('assertViewIsNot', function (string $value): static {
        $this->ensureResponseHasView();

        PHPUnit::assertNotEquals($value, $this->original->name());

        return $this;
    });
}

The Solution v1.1

However, we lose code-completion and doc-block support using this macro so we need to extend Mr TestResponse class.

use Illuminate\Testing\TestResponse as BaseTestResponse;
use PHPUnit\Framework\Assert as PHPUnit;

class TestResponse extends BaseTestResponse
{
    public function assertViewIsNot(string $value)
    {
        $this->ensureResponseHasView();

        PHPUnit::assertNotEquals($value, $this->original->name());

        return $this;
    }
}

Great, that part is done. Now we need to override Laravels calls to what is now BaseTestResponse. Oh. That's buried in a vendor file, there must be a way. Yup! Laravel has our back once again.

protected function createTestResponse($response): \Test\TestResponse
{
    return \Test\TestResponse::fromBaseResponse($response);
}

What happens here is ::fromBaseResponse creates a new Illuminate\Testing\TestResponse instance from our TestResponse class meaning that our assertion is now available with code-completion and doc-blocks!. No, it doesn't. PHPStorm throws this Inspection warning at me Potentially polymorphic call. TestResponse does not have members in its hierarchy. I'm not sure where to proceed from here but I will solve this issue somehow!

Did you find this article valuable?

Support Denver F by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this