Single-use Tests

The tests I write are usually written in Pest, PHPUnit, or Jest. I also usually commit them to a code repository - but not all automated tests should be committed. I wrote what I consider to be an automated test that was a "single-use test". It served its purpose and it will forever be in my heart, but not in my repository.

A tiny script I created allowed me to verify, with certainty, that I had not modified any route definitions after a pretty decent-sized refactor that touched a lot of different parts of the application. I thought it was pretty neat and gave me a lot of confidence to ship the changes, so I thought I would share.

In a project I’m working on there are duplicated middleware added to a lot of routes. At some point, in this projects long life, the way middleware was added to routes was moved from controller constructor middleware to the route file middleware (where they belong 😎).

If you aren’t familiar with these two options for adding middleware to a route, Laravel allows you to add middleware in a routes file...

1// routes/web.php
2 
3use App\Http\Middleware\Authenticate;
4use App\Http\Middleware\MonitorUsage;
5use Illuminate\Support\Facades\Route;
6 
7// Apply middleware to a group of routes...
8Route::middleware(Authenticate::class)->group(function () {
9 
10 // Apply middleware to a single route...
11 Route::get(/* ... */)->middleware(MonitorUsage::class);
12});

Laravel also allows you to add middleware to routes via the controller’s constructor. These middleware will apply to all methods on the controller unless otherwise configured.

1namespace App\Http\Controllers;
2 
3use App\Http\Middleware\Authenticate;
4use App\Http\Middleware\MonitorUsage;
5 
6class UserController extends Controller
7{
8 public function __construct()
9 {
10 $this->middleware(Authenticate::class);
11 $this->middleware(MonitorUsage::class, ['only' => 'show']);
12 }
13 
14 // ...
15}

As I mentioned, this project had moved from using controller middleware to route file middleware, however the middleware in the controllers had not yet been tidied up and removed.

This wasn’t really a problem.

It wasn’t like these middleware were being run twice. Laravel deduplicates middleware applied to a route, i.e., if we add the same middleware to a route more than once it is only executed once.

We can see this with a quick example. I'll create an empty middleware that dumps a message when it is run.

1namespace App\Http\Middleware;
2 
3class MyMiddleware
4{
5 public function handle($request, $next)
6 {
7 dump('Running my middleware.');
8 
9 return $next($request);
10 }
11}

I can then create a route and apply the middleware to it twice; once via a wrapping group and once on the actual route itself.

1// routes/web.php
2 
3use App\Middleware\MyMiddleware;
4use Illuminate\Support\Facades\Route;
5 
6Route::middleware(MyMiddleware::class)->group(function () {
7 Route::get('/', fn () => 'Welcome!')->middleware(MyMiddleware::class);
8});

When we access this page we see the following output rendered in the browser. You will notice that the dump is only seen once, indicating that Laravel has removed the duplicate middleware from the stack.

So cleaning this up was about aesthetics and consistency - or so I thought!

I decided to finally dig in and refactor it away. I came up with a plan and started doing a, rather manual, systematic route-by-route check.

  1. Look at the route.
  2. Work out the applied middleware in the routes file.
  3. Move to the controller constructor.
  4. Verify the middleware applied matched or was at least a subset of the route file middleware.
  5. Remove the middleware from the constructor.
  6. Make any required adjustments in the route file.

I did this for about 2 minutes before it dawned on me that whoever reviews this is gonna have a bad time. We would either YOLO it into production and hope the testsuite caught any issues or some poor soul would have to do all this manual checking again while reviewing. Not a fun time.

It wasn’t worth the time / improvement trade off.

I stopped coding; I moved away from the keyboard; I thought…

Do I scrap the changes? It’s not hurting anyone…but it did hurt me. Won’t somebody think of the children!

Then I realised I could potentially automate the review process and MAKE THE ROBOTS DO IT 🤖

Laravel ships with an artisan command that displays all routes defined within the application. There are a few flags on the command to change the commands output.

The bare command, php artisan route:list, will show you the routes.

But this doesn’t show the route middleware. Using the verbose flag, php artisan route:list -v, will show middleware for each route.

Great! We are getting somewhere useful! I then remembered that a --json flag was available on the command to output a JSON representation of the routes and their middleware instead of the visual representation we see in the terminal.

With the combination of all of this I could get all the routes and their respective middleware output before I made the change and then compare the file to the routes after I made the change.

So no more route-by-route review; I went straight for the jugular and stripped out all the middleware from all the controllers in a matter of minutes.

I committed my changes to my working branch.

I crafted the following helper script.

1git checkout main
2php artisan route:list --json -v | jq > before.json
3 
4git checkout working
5php artisan route:list --json -v | jq > after.json

The content of the before.json and after.json files now looks something like the following, which has been truncated for brevity...

1[
2 {
3 "domain": null,
4 "method": "GET|HEAD",
5 "uri": "/",
6 "name": null,
7 "action": "Closure",
8 "middleware": [
9 "web"
10 ]
11 },
12 {
13 "domain": null,
14 "method": "GET|HEAD",
15 "uri": "api/user",
16 "name": null,
17 "action": "Closure",
18 "middleware": [
19 "api",
20 "App\\Http\\Middleware\\Authenticate:sanctum"
21 ]
22 },
23 {
24 "domain": null,
25 "method": "GET|HEAD",
26 "uri": "confirm-password",
27 "name": "password.confirm",
28 "action": "App\\Http\\Controllers\\Auth\\ConfirmablePasswordController@show",
29 "middleware": [
30 "web",
31 "App\\Http\\Middleware\\Authenticate"
32 ]
33 },
34 // ...
35]

The only thing needed now was to compare the two files and see what the damage is. Of course I don't want this to be a manual process either! This step is why I wanted the JSON formatted nicely. If it was all a single line it would be impossible to see the difference. With formatted JSON I can use some tooling to see the exact differences before and after the refactor.

My tool of choice here is the diffing tool built into Kitty, the terminal I use, but you could use any diffing tool you have access to.

1diff <(jq --sort-keys . before.json) <(jq --sort-keys . after.json)

The result was something that look liked the following...

This gave me a way to visualise, with precision, the differences between each route's middleware before and after the change and detect inconsistecies.

I found this super valuable.

I ended up finding a few differences that were inconsistencies between the contructor and route file middleware - so it turns out it wasn't just aesthetics and consistency but actually help identify some places where our constructor middleware was lying to us.

So a PR with 548 deletions and 359 additions across 111 files was easily merge without manual testing.

Why do you care? Because now you can migrate your middleware to the routes file and also completely refactor your routes file to remove the use of Route::prefix and have a flat routes file, the way it should be, without worrying about breaking everything 😎

But hopefully it also gives you some ideas of how you could create single-use tests for your own PRs.