Unit tests are very useful for checking how our app is working.
Otherwise, we run into all kinds of issues later on.
In this article, we’ll look at some best practices we should follow when writing JavaScript unit tests.
Properly Set Up the Actions that Apply to All the Tests Involved
If we’re running the same thing before every test, we should put it in a beforeEach
hook.
This way, we run the same piece of code before each test without repeating the code.
For example, we can write:
describe('Saving the user profile', () => {
beforeEach(() => {
login();
});
it('should save updated profile setting to database', () => {
//...
expect(request.url).toBe('/profiles/1');
expect(request.method).toBe('POST');
expect(request.data()).toEqual({ username: 'james' });
});
it('should notify the user', () => {
//...
});
});
it('should redirect user', () => {
//...
});
});
Likewise, if we have some code that we’ve to run after each test, we should have an afterEach
hook that runs after each test:
describe('Saving the user profile', () => {
beforeEach(() => {
login();
});
afterEach( () => {
logOut();
});
it('should save updated profile setting to database', () => {
//...
expect(request.url).toBe('/profiles/1');
expect(request.method).toBe('POST');
expect(request.data()).toEqual({ username: 'james' });
});
it('should notify the user', () => {
//...
});
it('should redirect user', () => {
//...
});
});
Consider Using Factory Functions in the Tests
Factory functions can help reduce setup code.
They make each test more readable since creation is done in a single function call.
And they provide flexibility when creating new instances.
For instance, we can write:
describe('User profile module', () => {
let user;
beforeEach(() => {
user = createUser('james');
});
it('should publish a topic "like" when like is called', () => {
spyOn(user, 'notify');
user.like();
expect(user.notify).toHaveBeenCalledWith('like', { count: 1 });
});
it('should retrieve the correct number of likes', () => {
user.like();
user.like();
expect(user.getLikes()).toBe(2);
});
});
We have the createUser
function to create a user with one function call.
This way, we don’t have to write the same setup code for every test.
We can also use them with DOM tests:
function createSearchForm() {
fixtures.inject(`<div id="container">
<form class="js-form" action="/search">
<input type="search">
<input type="submit" value="Search">
</form>
</div>`);
const container = document.getElementById('container');
const form = container.getElementsByClassName('js-form')[0];
const searchInput = form.querySelector('input[type=search]');
const submitInput = form.querySelector('input[type=submit]');
return {
container,
form,
searchInput,
submitInput
};
}
describe('search component', () => {
describe('when the search button is clicked', () => {
it('should do the search', () => {
const { container, form, searchInput, submitInput } = createSearchForm();
//...
expect(search.validate).toHaveBeenCalledWith('foo');
});
// ...
});
});
We have the search form creation code in the createSearchForm
function.
In the function, we return various parts of the form’s DOM objects to let us check the code.
Using Testing Framework’s API
We should take advantage of a test framework’s API.
This way, we make use of its functionality to make testing easier.
For example, we can write:
fit('should call baz with the proper arguments', () => {
const foo = jasmine.createSpyObj('foo', ['bar', 'baz']);
foo.baz('baz');
expect(foo.baz).toHaveBeenCalledWith('baz');
});
it('should do something else', () => {
//...
});
using Jasmine.
We spied on stubbed functions to see if they’re called with the createSpyObj
method.
And we use fit
so that only the first test is run.
Conclusion
We should make sure of the testing framework’s API to make testing easier.
Also, we should make sure we put repeated code in hooks to avoid repetition.