Pretty Laravel Dusk tests without the browse() method

Published on

A typical Dusk test looks like this:

public function test_login_as_user()
{
    $user = factory(User::class)->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->visit('/login')
            ->type('email', $user->email)
            ->type('password', 'password')
            ->press('Login')
            ->assertPathIs('/home');
    });
}

This post explains how to get rid of the browse() method to make Dusk tests look like this instead:

public function login_as_user(Browser $browser)
{
    $user = factory(User::class)->create();

    $browser->visit('/login')
        ->type('email', $user->email)
        ->type('password', 'password')
        ->press('Login')
        ->assertPathIs('/home');
}

Without having to call the browser method, you'll save a level of indentation and you don't have to use any variables in the closure.

Abusing a data provider

We can inject the browser into each test using a PHPUnit data provider. All we need is a bit of reflection. This sounds like a bad idea, but it actually works pretty great.

Adding these two methods to our Dusk​Test​Case is all there is to it:

<?php

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use ReflectionClass;
use ReflectionMethod;

abstract class DuskTestCase
{
    public function providesTests()
    {
        $tests = [];

        $reflection = new ReflectionClass($this);

        foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
            if ($method->getNumberOfParameters() === 0) {
                continue;
            }

            $firstParameter = $method->getParameters()[0];

            if (! $firstParameter->hasType()) {
                continue;
            }

            $firstParameterType = $firstParameter->getType();

            if (! $firstParameterType instanceof ReflectionNamedType) {
                continue;
            }

            if ($firstParameterType->getName() === Browser::class) {
                $tests[$method->name] = [$method->name, $method->getNumberOfParameters()];
            }
        }

        return $tests;
    }

    /**
     * @dataProvider providesTests
     *
     */
     #[Test]
    function t($method, $browserCount)
    {
        return match ($browserCount) {
            1 => $this->browse(fn (Browser $browser) => $this->{$method}($browser)),
            2 => $this->browse(fn (Browser $browser1, Browser $browser2) => $this->{$method}($browser1, $browser2)),
            3 => $this->browse(fn (Browser $browser1, Browser $browser2, Browser $browser3) => $this->{$method}($browser1, $browser2, $browser3)),
            4 => $this->browse(fn (Browser $browser1, Browser $browser2, Browser $browser3, Browser $browser4) => $this->{$method}($browser1, $browser2, $browser3, $browser4)),
        };
    }
}

The reflection in the provides​Tests() method searches all classes that extend Dusk​Test​Case for methods that require a variable with a Browser type hint. These methods are collected, and passed as data to the t() test method. The t() method calls browse(), and then passes the browser variable down to the method that the data provider discovered earlier.

As a result, we don't have to call $this->browse() in our Dusk tests anymore:

class LoginTest extends DuskTestCase
{
    public function login_as_user(Browser $browser)
    {
        $user = factory(User::class)->create();

        $browser->visit('/login')
            ->type('email', $user->email)
            ->type('password', 'password')
            ->press('Login')
            ->assertPathIs('/home');
    }
}

Pretty sweet.

Caveat

I've been using this approach for a few years now, and this is the only caveat I've encountered so far:

Your tests will run twice if they get discovered by both PHPUnit and by our data provider. If this happens, the test that PHPUnit discovered will fail because it doesn't provide the browser. As a solution, PHPUnit shouldn't discover your tests. Make sure you don't give your test methods a test prefix, and don't give them a /** @test */ annotation.