Introduction to Testing with JavaScript
JavaScript Chats with ACM Hack Session 6
November 19, 2019
In this session of JavaScript Chats, we will introduce the concepts used in testing through JavaScript. The example that we used is not complex or fancy, but the point is to introduce the concept.
Test Driven Development
Chicken or egg first?
When you are writing a program, how do you know that your program works? When I was doing my CS31 C++ homework, I try to layout the logic of my program and then code it out. After the code is finished, I give it some test case and see if my program handles the test cases correctly.
To test a function, you just add some code in the main
function that calls the function with the test case inputs,
and seeing if the output is correct. You either do it
by printing it out and manually checking the output value,
or by an assertion (with an if statement).
The process I just described have the following (subjective) characteristics:
- You understand fully the code that you wrote (hopefully)
- There is quite a bit of labour on writing code to check your answer (if statements, print statements)
- You are testing your own code that is not used by anyone else
- Code comes before test
There is an extremely opinionated Wikipedia page that criticizes this approach called cowboy coding. You can see how opinionated it is based on the warning on the wiki page:
The popular kid that is compliant and is everybody's favorite is called Test Driven Development (TDD). If I were to name the one biggest difference between test driven development and cowboy coding, it would be that test comes first before the code.
Before writing a single line of code, a test driven developer will write the test cases first. But the function/module that we are testing contains no code and therefore does nothing. What is the point of writing test cases that is going to fail?
That is the central idea of test driven development. You "break" your program with a failing test case, then you go "fix" your program such that your program passes the test case. People often describes TDD in 3 basic steps:
- Red: Write a test and make sure it fails.
- Green: Write the simplest, easiest possible code to make the test pass.
- Refactor: Optimise and/or simplify the application code, making sure that all the tests still pass.
(Taken from James Sinclair, A Gentle Introduction to JavaScript Test Driven Development Part 1)
Can you forsee any problem with this approach?
Jest: a JavaScript Testing Framework
Let's experience test driven development with a testing
framework called Jest. Assume that we are writing a
Node.js server application. The first thing we need is to
set up our application using npm
.
npm init -y
We want to install the jest
framework.
npm install --save-dev jest
Note: The
--save-dev
options of thenpm
command saves the installed package as a development dependencies, meaning this package is not needed for the application to work, but it is only used during development. Can you see why there is a need to separate development and build dependencies?
We are going to write a function fizzBuzz
in a file called
utils.js
. The function takes in a number x
as the
only argument, and returns the following strings:
'fizz'
ifx
is divisible by 3'buzz'
ifx
is divisible by 5'fizz buzz'
ifx
is divisible by both 3 and 5x
as a string ifx
is divisible neither by 3 nor 5
However, before we even start writing a single line of code,
remember that tests come first. We create a file containing
our test named utils.test.js
. Then, we start writing our
first test case!
const { fizzBuzz } = require('./utils');
test('fizz is returned on 3', () => {
expect(fizzBuzz(3)).toBe('fizz');
});
The test
, expect
functions are implicitly defined by
the testing framework. Each test
function takes in a
string as the name of the test and a function that executes
the test. Each expect
function does an assertion on
a value.
The Jest framework is designed such that if you read out the test code, you understand what it is trying to test for!
Why does a good test has to be readable?
This is definitely going to break since we have not even defined our function or even created our file yet! But this is fine, since we want our test to fail.
Now, we add a script to our package.json
to run the
test.
{
"scripts": {
"test": "jest"
}
}
Then, we can execute the test with npm run test
.
You should see the following output.
FAIL ./utils.test.js
● Test suite failed to run
Cannot find module './utils' from 'utils.test.js'
However, Jest was able to find:
'./utils.test.js'
This is our first failing test case! We can clearly
see the problem being the module ./utils
is
not found since we have not defined it. Following
our Red, Green, Refactor process, we make our test
pass by creating our utils.js
.
After we create an empty file, we run our test
again. We should get failure again. Here is the error
message.
FAIL ./utils.test.js
✕ fizz is returned on 3 (4ms)
● fizz is returned on 3
TypeError: fizzBuzz is not a function
2 |
3 | test('fizz is returned on 3', () => {
> 4 | expect(fizzBuzz(3)).toBe('fizz');
| ^
5 | });
6 |
7 |
at Object.<anonymous> (utils.test.js:4:10)
We can see the beautifully formatted error message.
We know the problem is that we have not defined
the fizzBuzz
function yet! With Jest, you do not
need to do much manual work to test your code!
The testing experience is every developer friendly.
We had to executing the npm run test
command
after each update to our file. Can we make it easier?
Can Jest automatically run our test cases every time
we update our file? Yes!
We change our test script in package.json
.
{
"scripts": {
"test": "jest --watchAll"
}
}
We execute npm run test
again. You should see
the same failing output. But our test program did
not quit but instead hangs on the terminal.
Now we go defined our function.
// in utils.js
const fizzBuzz = x => {};
module.exports = {
fizzBuzz
};
Now, our test screen has updated, with a different error message.
FAIL ./utils.test.js
✕ fizz is returned on 3 (3ms)
● fizz is returned on 3
expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: undefined
2 |
3 | test('fizz is returned on 3', () => {
> 4 | expect(fizzBuzz(3)).toBe('fizz');
| ^
5 | });
6 |
7 |
We can clearly see the returned value does not match the expected value. As a developer, we only had to write a few lines of test code to get all these nice features. We don't need to care about what to print out if our test fails. We don't need to handle any if statements and such.
Our test case still fails. Let's keep fixing it. Remember in test driven development, you want to write the minimal code to get the test case to pass.
To pass the only test case that we have, we can simply
return 'fizz'
regardless of the function input.
const fizzBuzz = x => {
return 'fizz';
};
Now our test passes. We are now in the "green" stage.
PASS ./utils.test.js
✓ fizz is returned on 3 (1ms)
Let's keep adding failing test case then.
test('fizz buzz is returned on 15', () => {
expect(fizzBuzz(15)).toBe('fizz buzz');
});
Now our test is "red". We write code to make it pass.
const fizzBuzz = x => {
if (x % 5 === 0)
return 'fizz buzz';
return 'fizz';
};
Now, our test is green. We add more failing test cases.
test('buzz is returned on 5', () => {
expect(fizzBuzz(5)).toBe('buzz');
});
We write more code to fix the failing test.
const fizzBuzz = x => {
if (x % 5 === 0) {
if (x % 3 == 0)
return 'fizz buzz';
else
return 'buzz';
}
return 'fizz';
};
We are now "green". We add another test case to become "red".
test('a number string 17 is returned on 17', () => {
expect(fizzBuzz(17)).toBe('17');
});
Now, we turn "green" by adding more code.
const fizzBuzz = x => {
if (x % 5 === 0) {
if (x % 3 == 0)
return 'fizz buzz';
else
return 'buzz';
}
if (x % 3 == 0)
return 'fizz';
return x.toString();
};
At this point, you might notice some problem. The code that we wrote is not clean. Namely, there is unnecessarily nested if statements.
That is why we have our "refactor" step in the 3. The code from a TDD process can be quite convoluted since we never thought about the overall structure or logic of our code in the process. After finishing the test, and we have a complete version of our code. We might want to refactor our code!.
const fizzBuzz = x => {
if (x % 15 === 0)
return 'fizz buzz';
if (x % 3 === 0)
return 'fizz';
if (x % 5 === 0)
return 'buzz';
return x.toString();
};
After refactoring, our code is cleaner, easy to read but still have all the correct functionalities guaranteed by the test that we wrote. For reference, here is all the test code that we wrote.
const { fizzBuzz } = require('./utils');
test('fizz is returned on 3', () => {
expect(fizzBuzz(3)).toBe('fizz');
});
test('fizz buzz is returned on 15', () => {
expect(fizzBuzz(15)).toBe('fizz buzz');
});
test('buzz is returned on 5', () => {
expect(fizzBuzz(5)).toBe('buzz');
});
test('a number string 17 is returned on 17', () => {
expect(fizzBuzz(17)).toBe('17');
});
Code Coverage: Don't Even Try to Get to 100%
How do we know that we have written a set of comprehensive test cases? Having all of our test passes does not mean we have caught all the edge cases. Ask yourself: when you are testing your code, how would you come up with edge cases? Coming with edge cases is extremely hard. What if there is a better way that use less of our brain power?
In testing, code coverage is a measurement of how many
lines of code are executed/exercised by the test cases.
For instance, we use the fizzBuzz
example above.
If we remove the last test case
'a number string 17 is returned on 17'
, we know that
the code return x.toString()
will not be ran by
any test cases. Therefore, our test cases cannot
guarantee that the line return x.toString()
will
be correct.
How can we see the code coverage then? There is an
option in Jest for just that! We modify our test
script in package.json
.
{
"scripts": {
"test": "jest --watchAll --coverage"
}
}
We execute npm run test
. You should see the following
output.
PASS ./utils.test.js
✓ fizz is returned on 3 (3ms)
✓ fizz buzz is returned on 15
✓ buzz is returned on 5 (7ms)
✓ a number string 17 is returned on 17 (1ms)
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 100 | 100 | 100 | 100 | |
utils.js | 100 | 100 | 100 | 100 | |
----------|----------|----------|----------|----------|-------------------|
If you remove the last test case, you wil see the code coverage drops.
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 88.89 | 83.33 | 100 | 88.89 | |
utils.js | 88.89 | 83.33 | 100 | 88.89 | 8 |
----------|----------|----------|----------|----------|-------------------|
Typically, a large code base will not have 100% coverage. Can you think of why?
Types of Tests
Tests can be generally classified into several categories by the number of components in the application that it tests for. Is it testing just one function? Is it testing an API endpoint? Is it testing the entire application from the UI (web/mobile app) to the backend (server and database)? The difficulty of writing tests increases with the number of components that it tests for. It can summarized in the Test Pyramid.
(Image credit to Jess Champion, The Enduring Value of Unit Tests)
Unit Testing
Unit testing refers to testing a single unit of code.
It can be a module, a function, or a component.
The fizzBuzz
example above is an example of a unit test.
We are conducting tests that only on a single function.
Integrated Testing
Integrated testing refers to testing an integration between 2 or more modules. Usually, this means that you are testing your API endpoints, since API endpoints usually involve multiple modules, like routing, actual endpoint logic, and database etc.
Let's put our fizzBuzz
into an express
server.
express
is a popular routing library for Node.js.
Using the previous repository that we were working on,
we first install express
.
npm install --save express
We create a file named server.js
to contain our
server.
// server.js
const express = require('express');
const { fizzBuzz } = require('./utils');
const app = express();
app.use('/fizzBuzz/:num', (req, res) => {
const number = parseInt(req.params.num);
const answer = fizzBuzz(number);
res.json({ answer });
});
module.exports = {
app
};
Yes, I wrote the code before the test here. But this is for demonstration purpose. If you do follow TDD practice, this is probably not the best idea.
Notice that we exported our app
. This is because
that we need to use the app
during testing.
How would you go about testing this?
At this point, we can start our application server,
by calling app.listen(3000)
.
Send some requests to localhost:3000/fizzBuzz
,
and thus writing test cases based on that.
However, this requires us to go to the network level
for testing. Also, our test will be very verbal since
we need to make network request but those are not
as readable and the intention is not as clear.
For testing HTTP server, we will use a package called SuperTest.
npm install --save-dev supertest
This package allow us to test express application without having to start a server and explicitly send network request. Most importantly, it allows us to write tests in a very readable manner.
Time to write our test. Where should we put our test files?
We can put them in server.test.js
. However, the XXX.test.js
is usually where we put all the unit tests. The convention for
integration tests is to put them under a directory called
__tests__
. We are going to put the tests under a file
named fizzBuzzEndpoint-test.js
.
For reference, this is what your file structure should look like.
├── __tests__
│ └── fizzBuzzEndpoint-test.js
├── index.js
├── package-lock.json
├── package.json
├── server.js
├── utils.js
└── utils.test.js
// __tests__/fizzBuzzEndpoint-test.js
const request = require('supertest');
const { app } = require('../server');
describe('/fizzBuzz endpoint', () => {
it('should respond to request', done => {
request(app)
.get('/fizzBuzz/1')
.expect(200)
.end(done);
});
});
The describe
function is provided by Jest. It allows
you to group individual tests into one group. Here,
we are grouping all the tests we want to do on the
/fizzBuzz
endpoint in one describe
.
it
is the same as the test
function we used above.
It takes in a string and a callback function containing
our code for one test. Note that requesting for an API
endpoint is an asynchronous action. Therefore, the
test callback also takes in a done
function, which is
passed in by Jest. We call the done
function when
we are done with the test. We are using it
here
since it is readable: the /fizzBuzz
endpoint should or
should not be doing something.
Since the test is async, we can also use an alternative
async/await
syntax.
it('should not respond to request without number', async () => {
await request(app)
.get('/fizzBuzz/')
.expect(404);
});
Exercise: Can you translate the unit test that we wrote into integration tests using
supertest
?
Mocking
Imagine that you are testing a backend system with database. There is a function that interacts with some other server API. For instance, it retrieves a user from another database APi. Then, it returns the full name of the user as string. Here is the code for it.
// utils.js
const axios = require('axios');
const getUserFullName = async userId => {
const user = await axios.get('http://my.api.com/user/' + userId);
return user.data.firstName + ' ' + user.data.lastName;
};
Before we move on, how would you test it?
If you write test for this function, you are not doing
a unit test. Instead, you are actually doing an
integration testing, since the function depends on
the correctness of the axios
module. How do we
actually test the logic within the function without
having to depend on other modules/systems?
We use a common testing technique called mocking. We mock a fake but simplistic version of an external dependencies. I emphasize simplicity since simple code means less mistakes.
Let's see how we can mock a database within Jest.
// utils.test.js
const axios = require('axios');
const { getUserFullName } = require('./utils');
jest.mock('axios');
test('should return full name', async () => {
const user = {
firstName: 'Galen',
lastName: 'Wong'
};
axios.get.mockResolvedValue({ data: user });
const fullName = await getUserFullName(1);
expect(fullName).toBe('Galen Wong');
});
With jest.mock('axios')
, we tell Jest that we want
to mock a module called axios
. Within the test code body,
we overwrite the axios.get
function, such that it returned
a promise with resolved value equal to { data: user }
.
When our getUserFullName
is executed, the object that we mocked
will be used as the return value of axios.get
.
Why mock?
Apart from simply adhering to the definition of a unit test, what are the other benefits of mocking in test? There are 2 important motivations to use mocking:
- reduce set up complexity of running tests
- reduce time taken to run tests
If we were to test without mock, we would have to make sure that we have a server set up to receive calls from our tested function during test time. We have to know what data exists on the server. For instance, what is the name of the user with id 1. With all these things to worry about, we cannot focus on our testing. Writing the infrastructure needed to support the testing is going to take way longer than actually implementing your desired functionality. With mocking, we reduce the set up complexity of testing.
If we were to test by calling to a server, our test will run for longer, since network call is time consuming operation. As developer, we would like our test to run fast such that we can know what is wrong quickly.