Module 3: Testing with Jest
Configure Jest
Testing is an essential skill for a web developer to have. It's hard to anticipate every way that a user might interact with your site, not to mention it is incredibly time-consuming to test all of those options manually. That's where automated testing comes in. Any major company will use automated testing on its websites as a safety net to prevent regressions and get a better overall understanding of how an application works. As such, testing is a great thing to have on your resume!
We'll quickly review what testing is before jumping into tooling we can use for automated testing. Generally speaking, testing is a code that checks if the application code is correct.
If we don't have tests, it's safe to assume the following:
- application code has to be tested manually
- there is no way to know if a change broke another piece of code
- you cannot be sure if the code is correct
- manually testing takes a lot of unnecessary time
- adding new features becomes slow
Advantages of Testing
- verifies edge cases
- developer can concentrate on current changes (safety net)
Drawbacks of Testing
- more code to write and maintain
- more tooling
- additional dependencies
- may provide a false sense of security
- trivial test failures may break the build
- regressions (when a new feature breaks existing code)
What tools do we use for testing?
Hopefully, you're convinced that testing is essential and want to start using it in your projects by now. In this course, you've already used React testing library to write tests for React components, but there are other tools available. Examples of those tools are Jest, Mocha/Chai, Jasmine, Qunit, Enzyme, Supertest, Istambul, Karma, and Cypress.
How do you even begin to set up custom testing for a project with so many testing tools available? First, it helps to know why you want to test so that you can pick the tool most suited to your needs.
Jest
We'll use the testing library Jest to start setting up our tests. Jest runs under the hood in React testing library, so a lot of what we do moving forward should look somewhat familiar. With create-react-app and React testing library, there was no need to install and set up Jest, but as you grow as a web developer, you will likely run into a need to install and use Jest on its own.
Jest is a test runner and command-line interface npm package. It was originally made by Facebook and is included out-of-the-box with create-react-app. Jest is a very general-purpose testing tool, and it works best with React applications, though it works with other frameworks. In addition to the types of tests we've seen, Jest can run asynchronous tests, snapshot testing, and produce coverage reports.
Watch Mode
You'll learn how to install and configure Jest in the tutorial below, but first, let's talk briefly about watch mode. Instead of running tests manually, Jest has a built-in watch mode feature that will run tests automatically as files change. Thus, Jest detects these changes automatically and only runs the tests about the changes. This is one of the reasons developers love Jest so much and hopefully one that you'll find equally compelling.
How to Build It
-
Install jest with npm
We first need to install Jest as a development dependency. As soon as we do, Jest dependencies will show up in our package.json file.
npm install -D jest
-
Add test script
In package.json we'll need to indicate that we're using jest for testing. This can be done by simply adding "test": "jest --watch", to your "scripts" object.
-
Run Tests
We can start Jest by typing npm test in a terminal window at the root of the project. However, since there are no tests written, it will return an error "No tests found" because we haven't actually written any tests yet, so let's move on.
-
Create test files
By convention, Jest will find your tests in two ways:
- by placing .js files inside a folder called __tests__
- by ending the name of a file in .test.js or .spec.js
Technically, you could give the __tests__ folder a different name, but then you'd need to manually change where Jest looks for test files.
Here we aren't going to write tests, but at this point you are all set up to do so.
Using Jest
In the last objective, we configured Jest in a project. Now we want to write and run unit tests.
Unit Tests
You'll recall from unit 3 that unit tests are where we isolate smaller units of software (often functions or methods). There are usually many unit tests in a codebase, and because these tests are meant to be run often, they need to run fast. As a result, unit tests are fast, they're simple to write and execute, and they're the preferred tool for test driven development (TDD) and behavior driven development (BDD). In addition, developers regularly use them to test correctness in units of code (usually functions).
What makes a good test?
A good unit test is independent, focused, and, as you might assume, tests only one unit of code. This type of test focuses on one behavior or functionality (even if you have to make multiple assertions), therefore testing what needs to be tested and no more.
Another important consideration with testing is that you should try to avoid unnecessary preconditions. For example, if your test relies on outside dependencies or other tests running first, you should factor to isolate the test (much like a pure function).
Jest Globals
The it
global is a method you pass a function to; that function is executed as a block of tests
by the test runner.
The describe
is optional for grouping several related it
statements; this is also
known as a test suite.
Hello World Test
Let's consider a constant function. We'll use the ever-so-simple hello function for testing purposes.
export const hello = () => "hello world!";
Next we'd move into our tests folder and set up a test asserting that we expect the return value of this function to be hello world.
import { hello } from "./App";
//arrange
describe("hello", () => {
//act
it("should return hello world!", () => {
//assert
expect(hello()).toBe("hello world!");
});
});
Thanks to our watcher, the test should run automatically in the terminal, and you'd see that the test passed. Hopefully, this looks familiar to you from our work with React testing library.
Before we dive into writing our tests with Jest, let's look at a few more details.
Important Globals in Jest
A few objects exist in the global scope like describe
and it
. You are already
familiar with their use cases. When writing custom tests you may find that some tests need to be run more than
once, like a test to render without crashing, for example. Jest has built in globals for this use case:
Global | Description |
---|---|
beforeAll |
runs once before the first test |
beforeEach |
runs before the tests, good for setup code |
afterEach |
runs after the tests, good for clean up |
afterAll |
runs once after the last test |
If there's ever a scenario in which you want to skip or isolate a test, use the following globals:
Global | Description |
---|---|
it.skip() |
skips the test |
it.only() |
isolates a test |
The remaining globals can be found in the Jest documentation
How to Build It
Let's walk through building a unit test for a JavaScript function. We'll create a function called
averageTestScore
that calculates the average of an array of test scores.
First, create a file called mathHelpers.js
with this function:
const averageTestScore = testScores => {
if (!Array.isArray(testScores)) throw new Error('Not array!')
let totalSumScores = 0;
for (let i = 0; i < testScores.length; i++) {
totalSumScores += testScores[i];
}
return totalSumScores / testScores.length;
};
module.exports = averageTestScore;
Next, create mathHelpers.test.js
. We'll start by writing placeholder tests using
it.todo()
to plan what we want to test:
describe('mathHelpers', () => {
describe('averageTestScore', () => {
it.todo('should calculate the average for an array of numbers');
it.todo('should throw "Not array!" if the argument is not an array');
});
});
Now let's implement the actual tests by replacing the it.todo()
s with full test cases:
const { averageTestScore } = require("./mathHelpers.js");
describe('mathHelpers', () => {
describe('averageTestScore', () => {
it('should calculate the average for an array of numbers', () => {
const scores = [2, 4, 6, 6, 2];
const expected = 4
const actual = averageTestScore(scores);
expect(actual).toBe(expected);
});
it('should throw "Not array!" if the argument is not an array', () => {
expect(() => averageTestScore(5)).toThrow('Not array!');
expect(() => averageTestScore('five and two')).toThrow('Not array!');
expect(() => averageTestScore({ number: 5 })).toThrow('Not array!');
expect(() => averageTestScore(undefined)).toThrow('Not array!');
// etc
});
});
});
Test Driven Development
Test-driven development is the process of writing tests before code. In theory, you can write much higher-quality code when you start with the end (the tests) in mind. You might be familiar with similar philosophies in teaching (backward planning) -- or from the famous book "7 Habits of Highly Effective People" (starting with the end in mind).
Let's consider some units of code. First, we want this function to take two numbers and return the first number to the power of the second number.
Some assertions we might want to check are:
- The function returns a number
- The function returns a to the power of b
- The function returns undefined if one parameter is not a number
There's an endless amount of assertions we could check, but it helps to think of the most likely scenarios where the unit could fail for test-driven development.
In this example, we'd write all of these tests in Jest. After that, we could start hacking away at the function. So as long as all the tests pass, you can be confident in what you've created.
How to Build It
Let's walk through an example of test-driven development using Jest. We'll build a function that filters salaries below $50,000.
Brainstorm
First, spend 3-5 minutes brainstorming test ideas. Consider:
- Core functionality
- Edge cases
- Ways the function could be used incorrectly
Your brainstorm might include tests for:
- Throwing an error if passed a non-array argument
- Throwing if array contains non-numbers
- Confirming it returns an array
- Removing salaries below 50,000
- Keeping salaries equal to 50,000
TDD Cycle Part 1: Write a Failing Test
Start with one simple failing test:
it('throws if passed a non-array as the salaries argument', () => {
const expected = 'You must pass an array of numbers'
expect(() => removeSalaries('Definitely not an array')).toThrow(expected)
});
TDD Cycle Part 2: Write Minimal Passing Code
Write just enough code to pass the test:
function removeSalaries(salaries) {
if (!Array.isArray(salaries)) throw new Error('You must pass an array of numbers')
}
TDD Cycle Part 3: Refactor
Clean up the code while keeping tests passing. Continue the Red-Green-Refactor cycle with more tests:
// Final Tests
it('throws if passed a non-array as the salaries argument', () => {
const expected = 'You must pass an array of numbers';
expect(() => removeSalaries('Definitely not an array')).toThrow(expected);
});
it('throws if any element inside the array is not a number', () => {
const expected = 'You must pass an array of numbers';
expect(() => removeSalaries([1, 2, 'Definitely not a number'])).toThrow(expected);
});
it('returns an array', () => {
expect(Array.isArray(removeSalaries([1, 2, 3]))).toBe(true);
});
it('removes all salaries less than 50,000', () => {
const salaries = [55000, 45000, 1, 60000];
const expected = [55000, 60000];
expect(removeSalaries(salaries)).toEqual(expected);
});
it('does not remove salaries equal to 50,000', () => {
const salaries = [50000, 50000];
const expected = [50000, 50000];
expect(removeSalaries(salaries)).toEqual(expected);
});
Final implementation after completing TDD cycles:
function removeSalaries(salaries) {
const errorMessage = 'You must pass an array of numbers';
if (!Array.isArray(salaries)) throw new Error(errorMessage);
let result = [];
for (let salary of salaries) {
if (typeof salary !== 'number') throw new Error(errorMessage);
if (salary >= 50000) {
result.push(salary);
}
}
return result;
}
While strict TDD can be challenging to master, it's a proven method for writing quality code. You can start by writing tests up-front, then implement the code. The key is to write tests - they're essential for maintaining and improving code quality!
Guided Project
NOTE
The versions of project dependencies used in the recording are slightly different from the ones used in the starter and solution repositories, but this should not have any impact on the relevant code of the Guided Project.
The versions used in the repositories are more recent, and thus more similar to the versions you will install if you create a project from scratch.
Module 3 Project: Unit Testing
The module project contains advanced problems that will challenge and stretch your understanding of the module's content. The project has built-in tests for you to check your work, and the solution video is available in case you need help or want to see how we solved each challenge, but remember, there is always more than one way to solve a problem. Before reviewing the solution video, be sure to attempt the project and try solving the challenges yourself.
Instructions
The link below takes you to Bloom's code repository of the assignment. You'll need to fork the repo to your own GitHub account, and clone it down to your computer:
Starter Repo: Unit Testing
- Fork the repository,
- clone it to your machine, and
- open the README.md file in VSCode, where you will find instructions on completing this Project.
- submit your completed project to the BloomTech Portal