You know what’s annoying? API mismatches.
One day the backend devs change one of the APIs without warning the frontend devs. “We decided dateCreated
was a better name than created_at
,” they say. “Didn’t we tell you in standup yesterday?”
And then everything is broken.
There are unit tests covering the UI code. There are unit tests covering the backend code. All of those are passing. And yet, the app is broken.
In this post we’ll cover how you can write API tests with Jest, with very little code, and avoid this mess.
Not End-to-End Testing
What’s missing is a set of tests that check that the frontend and backend are integrated correctly.
These are called end-to-end or acceptance tests, and they’re typically done at the browser level. A tool like Selenium or Nightwatch or Capybara drives a headless browser to log in, click around, fill out forms, and generally ensure that everything is working correctly.
There are a few problems with end-to-end (E2E) tests though – they’re slow, error-prone, and brittle. Selenium-style browser automation is tricky to write well. Sneaky timing bugs can creep in, causing tests to fail intermittently.
If a test says:
Load the user profile page and assert that the
<h2>
tag has the textUser Profile
If you then go and change it to an <h3>
, the test will fail.
So there is a fine balancing act in writing tests like this – you need assertions that are good enough to verify functionality, but not so specific that they break when you introduce an extra <div>
or something.
A Happy Medium: Snapshot API Tests
The Jest tool from Facebook suports a style of testing called snapshot testing, where basically:
- You manually verify that the code works.
- You write a snapshot test and run it. It saves a text representation of the thing. You check the snapshot into source control.
- After that, every time the test runs it verifies the result against the old snapshot on disk. If they don’t match, the test fails.
This is typically applied to React components (and you can read about snapshot testing React components here), but snapshots can be taken of anything. Any JS object can be snapshotted.
Which means, you can:
- Make an API call.
- Snapshot the result.
- Sleep well knowing that if the API snapshots are passing, your UI and backend are in agreement.
Considerations
If you’ve written unit tests before, you’ve likely mocked out your API so that it doesn’t make any calls. In these tests, we’re turning that on its head. We want to make real API calls against a real server.
This means you will need a backend server running in order to run these tests. It’s a bit more complexity, but in trade, you get a bit more confidence.
You also need to be aware of the test database, and be sure to reset it to a known state before you do something like “Create 3 transactions, and verify that GET /transactions
returns 3 transactions.” Run that twice without cleaning the database, and the test will fail.
I won’t go into depth here about how to set all this up, because it will depend heavily on your own backend setup, your CI setup, etc.
If you decide to try this out, start simple: write tests against things like “login” or “create” that will be resilient to a dirty database. If you find you like the approach, then you can worry about solving the problems of database/CI/etc.
Examples
Testing Login
Here are a few tests of a theoretical “login” service:
import * as API from 'api';
test('failed login (bad password)', async () => {
let data;
try {
data = await API.login('me@example.com', 'wrong_password');
fail();
} catch(e) {
expect(e.response.data.error).toMatchSnapshot();
}
});
test('failed login (bad username)', async () => {
let data;
try {
data = await API.login('not-a-real-account@example.com', 'password');
fail();
} catch(e) {
expect(e.response.data.error).toMatchSnapshot();
}
});
test('good login', async () => {
try {
const response = await API.login('test-account@example.com', 'supersecret!');
expect(response).toMatchSnapshot();
} catch(e) {
fail();
}
});
These tests take advantage of async/await to make the code read more like synchronous code.
There’s not too much magic happening here: each test makes an API call, and asserts that the result (or error) matches the snapshot.
Remember, you have to verify that the API calls are working before you run the snapshot tests for the first time. They’re typically saved in a __snapshots__
folder alongside the test JS file, so you can inspect them for correctness as well (and you should).
Testing Things That Change
Sometimes the API responses might contain an auto-incremented ID, or a timestamp. These things will cause a snapshot test to fail every time.
To fix that, here is an example of a sanitize
function that takes an object, and an array of keys
to sanitize. Since it uses lodash’s set
function, the keys can reference “deep” properties like user.orders[0].created_at
if necessary.
import * as _ from 'lodash';
import * as API from 'api';
function sanitize(data, keys) {
return keys.reduce((result, key) => {
const val = _.get(result, key);
if(!val || _.isArray(val) || _.isObject(val)) {
return result;
} else {
return _.set(_.cloneDeep(result), key, '[SANITIZED]');
}
}, data);
}
test('createOrder', async () => {
let order = await API.createOrder('Camera', 47, 19.84);
order = sanitize(order, ['id', 'created_at']);
expect(order).toMatchSnapshot();
});
Try It Out
I’ve only just started to implement this testing approach in my own projects, but it seems promising so far. Give it a try, and leave a comment if you do :)