Writing golden master tests for Laravel applications
Published by Matthew Daly at 14th May 2019 11:15 am
Last year I wrote a post illustrating how to write golden master tests for PHP applications in general. This approach works, but has a number of issues:
- Because it uses a headless browser such as Goutte, it's inevitably slow (a typical test run for the legacy application I wrote those tests for is 3-4 minutes)
- It can't allow for differing content, so any changes to the content will break the tests
These factors limit its utility for many PHP applications. However, for a Laravel application you're in a much better position:
- You can use Browserkit rather than a headless browser, resulting in much faster response times
- You can set up a testing database, and populate it with the same data each time, ensuring that the only thing that can change is how that data is processed to create the required HTML
Here I'll show you how to adapt that approach to work with a Laravel application.
We rely on Browserkit testing for this approach, so you need to install that:
$ composer require --dev laravel/browser-kit-testing
Next, we need to create our base golden master test case:
1<?php23namespace Tests;45use Tests\BrowserTestCase;67class GoldenMasterTestCase extends BrowserTestCase8{9 use CreatesApplication;1011 public $baseUrl = 'http://localhost';1213 protected $snapshotDir = "tests/snapshots/";1415 protected $response;1617 protected $path;1819 public function goto($path)20 {21 $this->path = $path;22 $this->response = $this->call('GET', $path);23 $this->assertNotEquals(404, $this->response->status());24 return $this;25 }2627 public function saveHtml()28 {29 if (!$this->snapshotExists()) {30 $this->saveSnapshot();31 }32 return $this;33 }3435 public function assertSnapshotsMatch()36 {37 $path = $this->getPath();38 $newHtml = $this->processHtml($this->getHtml());39 $oldHtml = $this->getOldHtml();40 $diff = "";41 if (function_exists('xdiff_string_diff')) {42 $diff = xdiff_string_diff($oldHtml, $newHtml);43 }44 $message = "The path $path does not match the snapshot\n$diff";45 self::assertThat($newHtml == $oldHtml, self::isTrue(), $message);46 }4748 protected function getHtml()49 {50 return $this->response->getContent();51 }5253 protected function getPath()54 {55 return $this->path;56 }5758 protected function getEscapedPath()59 {60 return $this->snapshotDir.str_replace('/', '_', $this->getPath()).'.snap';61 }6263 protected function snapshotExists()64 {65 return file_exists($this->getEscapedPath());66 }6768 protected function processHtml($html)69 {70 return preg_replace('/(<input type="hidden"[^>]+\>|<meta name="csrf-token" content="([a-zA-Z0-9]+)">)/i', '', $html);71 }7273 protected function saveSnapshot()74 {75 $html = $this->processHtml($this->getHtml());76 file_put_contents($this->getEscapedPath(), $html);77 }7879 protected function getOldHtml()80 {81 return file_get_contents($this->getEscapedPath());82 }83}
The goto()
method sets the current path on the object, then fetches the page. It verifies the page was found, and then returns an instance of the object, to allow for method chaining.
Another method of note is the saveHtml()
method. This checks to see if the snapshot exists - if not, it saves it. The snapshot is essentially just the HTML returned from that route, but certain content may need to be stripped out, which is done in the processHtml()
method. In this case we've stripped out hidden fields and the CSRF token meta tag, as CSRF tokens are generated anew each time and will break the snapshots.
The last method we'll look at is the assertSnapshotsMatch()
method. This will get the current HTML, and that for any snapshot for that route, and then compare them. If they differ, it will fail the assertion. In addition, if xdiff_string_diff
is available, it will show a diff of the two files - be warned, these can sometimes be large, but they can be helpful in debugging.
Also, note our snapshots directory - tests/snapshots
. If you do make a breaking change and want to delete a snapshot, then you can find it in there - the format replaces forward slashes with underscores, and appends a file extension of .snap
, but feel free to customise this to your needs.
Next, we'll create a test for routes that don't require authentication, at tests/GoldenMaster/ExampleTest.php
:
1<?php23namespace Tests\GoldenMaster;45use Tests\GoldenMasterTestCase;6use Illuminate\Foundation\Testing\RefreshDatabase;7use App\User;89class ExampleTest extends GoldenMasterTestCase10{11 use RefreshDatabase;1213 /**14 * @dataProvider nonAuthDataProvider15 */16 public function testNonAuthPages($data)17 {18 $this->goto($data)19 ->saveHtml()20 ->assertSnapshotsMatch();21 }2223 public function nonAuthDataProvider()24 {25 return [26 ['/register'],27 ['/login'],28 ];29 }30}
Note the use of the data provider. We want to be able to step through a list of routes, and verify each in turn, so it makes sense to set up a data provider method as nonAuthDataProvider()
, which will return an array of routes. If you haven't used data providers before, they are an easy way to reduce boilerplate in your tests when you need to test the same thing over and over with different data, and you can learn more here.
Now, having seen the methods used, it should be easy to understand testNonAuthPages()
. It goes through the following steps:
- Visit the route passed through, eg
/register
- Save the HTML to a snapshot, if not already saved
- Assert that the current content matches the snapshot
Using this method, you can test a lot of routes for unexpected changes quite easily. If you've used snapshot tests with something like Jest, this is a similar approach.
Authenticated routes
This won't quite work with authenticated routes, so a few more changes are required. You'll get a response, but if you look at the HTML it will clearly show the user is being redirected for all of them, so there's not much point in testing them.
If your content does not differ between users, you can add the trait Illuminate\Foundation\Testing\WithoutMiddleware
to your test to disable the authentication and allow the test to get the content without being redirected.
If, however, your content does differ between users, you need to instead create a user object, and use the actingAs()
method already available in Laravel tests to set the user, as follows:
1<?php23namespace Tests\GoldenMaster;45use Tests\GoldenMasterTestCase;6use Illuminate\Foundation\Testing\RefreshDatabase;7use App\User;89class ExampleTest extends GoldenMasterTestCase10{11 use RefreshDatabase;1213 /**14 * @dataProvider authDataProvider15 */16 public function testAuthPages($data)17 {18 $user = factory(User::class)->create([19 'email' => 'eric@example.com',20 'name' => 'Eric Smith',21 'password' => 'password'22 ]);23 $this->actingAs($user)24 ->goto($data)25 ->saveHtml()26 ->assertSnapshotsMatch();27 }2829 public function authDataProvider()30 {31 return [32 ['/'],33 ];34 }35}
This will allow us to visit a specific page as a user, without being redirected.
Summary
This can be a useful technique to catch unexpected breakages in applications, particularly ones which have little or no conventional test coverage. While I originated this technique on a Zend 1 legacy code base, leveraging the tools available in Laravel makes this technique much faster and more useful. If your existing Laravel application is not as well tested as you'd like, and you have some substantial changes to make that risk breaking some of the functionality, having these sorts of golden master tests set up can be a quick and easy way of catching any problems as soon as possible.