Writing golden master tests for Laravel applications

Published by 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:

3namespace Tests;
5use Tests\BrowserTestCase;
7class GoldenMasterTestCase extends BrowserTestCase
9 use CreatesApplication;
11 public $baseUrl = 'http://localhost';
13 protected $snapshotDir = "tests/snapshots/";
15 protected $response;
17 protected $path;
19 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 }
27 public function saveHtml()
28 {
29 if (!$this->snapshotExists()) {
30 $this->saveSnapshot();
31 }
32 return $this;
33 }
35 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 }
48 protected function getHtml()
49 {
50 return $this->response->getContent();
51 }
53 protected function getPath()
54 {
55 return $this->path;
56 }
58 protected function getEscapedPath()
59 {
60 return $this->snapshotDir.str_replace('/', '_', $this->getPath()).'.snap';
61 }
63 protected function snapshotExists()
64 {
65 return file_exists($this->getEscapedPath());
66 }
68 protected function processHtml($html)
69 {
70 return preg_replace('/(<input type="hidden"[^>]+\>|<meta name="csrf-token" content="([a-zA-Z0-9]+)">)/i', '', $html);
71 }
73 protected function saveSnapshot()
74 {
75 $html = $this->processHtml($this->getHtml());
76 file_put_contents($this->getEscapedPath(), $html);
77 }
79 protected function getOldHtml()
80 {
81 return file_get_contents($this->getEscapedPath());
82 }

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:

3namespace Tests\GoldenMaster;
5use Tests\GoldenMasterTestCase;
6use Illuminate\Foundation\Testing\RefreshDatabase;
7use App\User;
9class ExampleTest extends GoldenMasterTestCase
11 use RefreshDatabase;
13 /**
14 * @dataProvider nonAuthDataProvider
15 */
16 public function testNonAuthPages($data)
17 {
18 $this->goto($data)
19 ->saveHtml()
20 ->assertSnapshotsMatch();
21 }
23 public function nonAuthDataProvider()
24 {
25 return [
26 ['/register'],
27 ['/login'],
28 ];
29 }

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:

3namespace Tests\GoldenMaster;
5use Tests\GoldenMasterTestCase;
6use Illuminate\Foundation\Testing\RefreshDatabase;
7use App\User;
9class ExampleTest extends GoldenMasterTestCase
11 use RefreshDatabase;
13 /**
14 * @dataProvider authDataProvider
15 */
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 }
29 public function authDataProvider()
30 {
31 return [
32 ['/'],
33 ];
34 }

This will allow us to visit a specific page as a user, without being redirected.


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.