How to setup unit testing in a Laravel and Vue mono-repo application, with PHPUnit and Jest

Adam Mackintosh
12 min readJul 11, 2020

--

Fig 1.0 — Laravel + Vue

Introduction

To help you understand the level at which I might approach this, I will say I am aware of Kent Beck TDD, avoidance of the ice cream cone testing-style, and the idea of testing against feature list requirements and mandatory behaviour while avoiding brittle implementation details. I try to take a math, physics, and functional-programming approach to composing functions.

To help you figure out if you need to instantly bounce off this article, I will say the article seeks to provide maximum utility for those that have one repository that includes both a Laravel API and a Vue SPA app, but I might use some pure logic that is useful in other architectures. If your API is JSON-serving and your Vue app has vue-router and vuex, this article should rapidly trend towards useful.

Laravel comes preloaded with PHPUnit, so we will use that, and my preferred technique is to test against the real database, so we will use database transactions without committing mutations, so no test data will persist. This is also simpler overall, so I hope you enjoy the technique as much as I do.

The key factor is that the database is essentially read-only in test, but you can research how to use different database config while APP_ENV=testing if you want that for your use-case. CI/CD pipelines can stem fascinating, unique requirements.

Note: it’s important to test what we use in production. We don’t want to use anything “differently” during testing than we do in production. We should aim to test against what we use. From this reasoning, we test against the local database.

Vue comes preloaded with nothing, so it’s reasonable that life can start out terrible. Similar to video games, we must start at level 1 with rag-like textiles. The question is, what tools should be picked up first? I assert we should use Jest because it will keep us maximally-congruent with test strategies used in React JS. We should aim for 1:1 techniques between Vue and React when reasonable.

I will try to keep this article efficient, and exposit as I feel needed. It is important to keep track of why we are doing something rather than just how to do it.

Laravel

To setup Laravel, we need to open up phpunit.xml. Open that, and paste in this nectar:

Fig 2.0 — phpunit.xml

If you scan this above example config, you will see a couple things. There might be couple settings that are different than the default Laravel phpunit.xml file. I collected these random settings over time.

For example, it is common to use 4 salt rounds while testing because we aren’t testing bcrypt, and 4 salt rounds is faster than 10 rounds.

Pay attention: It isn’t a terrible idea to study each setting you aren’t using already. It’s a bad idea to paste in things without understanding their nature. You can ensure your application behaviour remains predictable. An unknown setting may increase system entropy — measure twice, cut once, and then cut again and hot-swap in prod due to unforeseen circumstances.

For running Laravel unit tests, I like to keep a dedicated terminal open, and you can run the tests via the command: php artisan test.

Test grouping

Besides the top config settings, the first area to take a look at is the <testsuites> section. Here we are simply grouping tests by directory, and PHPUnit will run them in the declared order: AppLayout > Auth > Unit > Feature. You can modify these as desired.

PHPUnit will run the files in those directories in alphabetical order. It is worth mentioning too that it is a good idea to avoid a time when a unit test depends on another unit test. That would be a form of bad coupling, and can spawn issues with shared mutable state.

Mockery

If you use Mockery, you can add the listener under the <listeners> section. You can completely omit the listeners section if you aren’t using it, or you can leave the <listeners></listeners> stub as a kind of way-marker for your future self — maybe it can trigger a memory or two. Maybe it’s cruft, or maybe it’s Maybelline.

As a bonus example, if you are using something like Socialite, and you want to test if Socialite calls out to an OAuth provider correctly, here’s an example of using Mockery to test an area of your codebase that calls an external API:

Fig 2.2 — Mockery

If you are examining the above code and find yourself wanting more, you can check out my portfolio repo that I leave public for code-scavenging reasons: https://github.com/agm1984/vue-portfolio (note: the master branch will be updated soon, you might not see anything for a short while).

Database transactions

With Laravel’s DatabaseTransactions trait, PHPUnit will apply the settings in the .env file, and then your phpunit.xml file (note: right-to-left-precedence). Then, it will use database transactions on the currently-loaded database. Every time a unit test mutates data in the database, data will be mutated “in the transaction” and appear as normal in the database, and then after the unit test, the transaction will be rolled back. This means you can chain database mutation calls and then ‘not commit’ the changes.

It is important to understand transaction commit and transaction rollback to fully appreciate this.

You may need to change your testing strategy slightly, but each test by itself should not have dependencies or rely on any-more-than-required external state. This philosophy matches up well with the idea of making each test occur in a transaction wrapper and then rolling it back after each test.

If any state does persist through tests, I recommend blaming Laravel first, and then check if Laravel is maintaining any singletons or anything like that, possibly due to a session.

Extra: Here is a StackOverflow answer that is mandatory-reading for every person making unit tests in Laravel: https://stackoverflow.com/a/57941133/6141025

Setting up DatabaseTransactions is kind of a two-step process, but the good news is both are pretty much the same. You just need use DatabaseTransactions in two places: TestCase.php and every other unit test that makes database mutations. I like to use GraphQL terminology even in relational databases. A mutation is what it sounds like. The store changes. A mutation is a set operation, and a query is a get operation. Therefore, we likely don’t care about database transactions if we are just querying.

Here’s a quick example:

TestCase.php (and for science reasons, I would consider this absolute minimal):

Fig 2.3 — TestCase.php

SomeTest.php:

Fig 2.4 — SomeTest.php

After examining the above code, you can see why I said testing in transactions is logically simpler and less code, a 2-for-1 win. It is simpler logically because there is less config-related code. We are using whatever database is loaded in the .env file, and whatever data it has in it. Also, after the use DatabaseTransactions; declaration, there is no extra code in each unit test. The rollbacks are done for you. If any of your tests get screwed up, consider it a code smell nearby a potential bad news pattern.

Laravel auth

It took me countless hours of research and of ramming my head into the keyboard to understand how to test Laravel auth in a way that made me comfortable, and in a way that allowed me to lean upon Laravel’s features.

I experimented with Passport, Sanctum, and JWTAuth libraries, and contemplated Proof Key for Code Exchange (PKCE) auth flow, but since we are talking about a mono-repo, the server-side and client-side apps are both on the same domain, so Laravel’s auth system is quite suitable. At the moment, I recommend Sanctum if you need Cross-origin resource sharing (CORS).

I find all other options besides Laravel’s secure cookie auth system have a propensity to get hairy once you start to add JSON Web Tokens (JWT) and refresh token logic. It becomes a major concern to avoid CSRF and XSS exploits. If you venture down the JWT path, make sure you understand the PKCE protocol.

The first thing we need is to deal with Laravel’s $this->actingAs($user) problem. We would live in a subpar world if we couldn’t just call that anywhere, anytime to impersonate an arbitrary user.

I like to add a user of each type to TestCase.php:

Fig 2.5 — TestCase.php with test users added

After harnessing the above code, you add stuff like this into your unit tests:

$this->actingAs($this->adminUser());// and$user = $this->user();

Those methods work nicely because, after each unit test, all mutations are rolled back, so rather than create a user every unit test, we can designate specific users to have predictable attributes, and then use the logical process:

  • if the designated user exists, return it,
  • otherwise, create it and return it.

After all that, you will notice that some session state somehow gets preserved across tests, which makes it hard to test the Laravel API for things that would assume state to be fully-reset after every test. To fix this, we can add this resetAuth method to TestCase.php:

Fig 2.6 — TestCase.php’s resetAuth method

Don’t forget: make sure you read the StackOverflow post (https://stackoverflow.com/a/57941133/6141025) to understand what the above code does, and make sure you add a sufficient amount of \Log::debug()'s into the code until you feel comfortable.

Here is a quick sample for how to use $this->resetAuth();:

Fig 2.7 — Demo of resetAuth function to reset auth state

Hyper-astute observers will notice that, technically it would still throw an error 429 after 10 attempts when the first attempt is from the previous unit test that doesn’t resetAuth. The key observation to make is that, if you switch the order of those unit tests around, the it_should_throw_error_422_with_empty_form test will fail every time by getting an error 429 instead of an error 422 because the cached auth state would still be throttled.

Moving on, if we have a feature that involves throttling the user, we need to actually test that behaviour. We need to trigger the error and make sure the server responds properly, in this case with an error 429. Our goal isn’t to test to make sure code exists at specific places related to an error 429. Our goal is to ensure the behaviour is observed at the specific time. Imagine if a breaking change in some library required a new syntax. We will catch wind of that immediately because, after 10 incorrect logins, we will see an error 429 if the throttle logic is working.

I have seen examples of people testing throttling but only checking some implementation details about it, such as if the Route middleware contains ‘throttle’. This is a good opportunity to re-visit the idea of testing against the behaviour we need to see. We must see the behaviour to prove the behaviour is working correctly. We can ignore everything in-between.

In the above example, without the resetAuth method, you would see the first unit test run which would increment the number of auth attempts by 1. Then the next test would run and the throttling would actually activate after 9 attempts rather than 10. Perhaps borderline-innocuous here, but we are relying on the imperative foreach loop to make exactly 10 attempts and then check if there is an error 429.

The point is, without resetAuth, there is auth state pollution shared across tests, and we don’t want that because it allows preceding tests to implicitly modify the behaviour of the current unit test.

Next, we can move on to the client-side.

Vue

Unit testing Laravel Vue with Jest presents a mild nightmare-scenario because Laravel uses Laravel Mix, and Mix controls Webpack. Webpack is browser JavaScript, and Jest runs in a node process.

We will attempt it today using Mix v5.0. If you try it with another version, be sure to investigate node_modules/laravel-mix/src/BabelConfig.js and pay attention to what version of Babel is being used, 6 or 7. This may radically affect the package.json packages. V7 uses @babel prefix. It is plausible that it could require some dancing in the .babelrc file.

At the moment based on my testing, it seems to be definitely-required to use babel-core@bridge because that allows Babel to use both V6 and V7 syntax. This is significant towards the interplay between Webpack and node.js JavaScript and Babel plugins.

One of the main benefits of Jest is that unit tests can run in parallel. A few years ago, it was said that Jest had improved performance over Mocha by 300% due to this (note: 12 to 4 minutes is 300%~). Literature may have changed since then, but you can read the article by a developer at Airbnb:

Another reason to use Jest is that your muscle memory might transform to and from React JS a little easier. I’m a fan of aiming for congruent patterns between React and Vue.

To install Jest, we need to install …some packages:

  • @vue/test-utils
  • babel-core@bridge (note the @bridge)
  • babel-jest
  • jest
  • vue-jest

I think it will be most helpful if we move through those in an order that allows us to see each’s contribution, but if you want to yolo-style install everything at once, try:

npm install --save-dev @vue/test-utils babel-core@bridge babel-jest jest vue-jest

I don’t recommend that though because this article you are reading will suffer entropy over time as downstream package versions change and affect syntax. Instead, let’s try to naively install everything while prepared for detonations.

First, install the 3 main packages:

npm install --save-dev @vue/test-utils jest vue-jest

Checkout the API of @vue/test-utils:

Checkout the docs for jest:

Checkout the docs for vue-jest:

Ensure this is in your package.json file:

{
"scripts": {
"test": "jest"
},
}

Add an App.spec.js file in your ./resources/js/components/ directory:

import { shallowMount } from '@vue/test-utils';
import App from './App.vue';
describe('App', () => {
it('works', () => {
const wrapper = shallowMount(App);
});
});

Now, try to run Jest, and again I like to run this in a dedicated terminal:

npm run test

The first thing we might notice, is that Jest is somehow smart enough to find App.spec.js and attempt to run it. We can infer that some respectable default settings are likely being used. We will then notice that Jest is exploding on this line:

import { shallowMount } from '@vue/test-utils';
^^^^^^
SyntaxError: Cannot use import statement outside a module

Ah yes, the infamous “I have no idea what ES6 is.” — node.js. But of course, the vue-jest docs contain some helpful tips:

If you use jest > 24.0.0 and babel-jest make sure to install babel-core@bridge

Now run:

npm install --save-dev babel-core@bridge

jest.config.js

And now, a critical issue remains, but it is described in the vue-jest docs. We haven’t instructed Jest to parse .js files using babel-jest, and .vue files using vue-jest. We need to create a jest.config.js file in the root of the repository (make sure: no . prefix):

Fig 3.0 — jest.config.js file

Note: after adding jest.config.js, try to run npm run test again. If you get an error about SyntaxError: Unexpected token ‘<’, It’s because the config file isn’t loaded properly.

.js config files are nice too because you can put inline annotations in beside wild config settings because no one will remember what purpose those settings serve.

Webpack aliases

I added a couple bonus items in the above example. You won’t see those in the vue-jest docs, but the moduleNameMapper key can be used for mapping Webpack aliases to specific directories, otherwise you will see errors when you import components using aliases such as:

import SomeComponent from '~/components/some-component.vue';

Now, the final thing we need to do is clear that import syntax error. To do that, we will instruct babel to target the newest version of node.

.babelrc

Create a .babelrc file in your repository’s root directory, and paste this in:

Fig 3.1 — .babelrc file

Laravel-mix comes by default with @babel/preset-env, so we shouldn’t need to explicitly install that. It’s plausible you could see weirdness if you have Mix version less than v5~.

There is a danger slope. Vue uses Webpack and browser JavaScript, and Jest doesn’t care about that and runs on node.js. If you ever get any crazy errors while unit testing, don’t forget to read discussions about Jest in React. They can be relevant.

Conclusion

Assuming we didn’t have any horrific anomalies related to package versions and downstream implications of syntax changes, you should have achieved success.

Remember to test against behaviours that you require to always work. Remember to think functionally. Start with known input. Ensure state is correct at key moments in time. Finish with known output. Allow implementation details to be freely adjusted.

Don’t forget that if values aren’t discrete (ie: particles), they are continuous (ie: waves), and so analogue values should have locked upper and lower bounds. Make sure no values can go out of your allowed bounds.

For example, if users can specify an email of length between 12 to 255 characters, the lower bound is 12 and the upper bound is 255. We should test to make sure values that go out of bounds raise an error as soon as possible because an error always stems from somewhere, and we need to know that place ASAP so we don’t build more logic on top of something undesirable.

--

--

Adam Mackintosh
Adam Mackintosh

Written by Adam Mackintosh

I prefer to work near ES6+, node.js, microservices, Neo4j, React, React Native; I compose functions and avoid classes unless private state is desired.

Responses (1)