- 1. JavaScript testing #1. Explaining types of tests. Basics of unit testing with Jest
- 2. JavaScript testing #2. Introducing Enzyme and testing React components
- 3. JavaScript testing #3. Testing props, the mount function and snapshot tests.
- 4. JavaScript testing #4. Mocking API calls and simulating React components interactions
- 5. JavaScript testing #5. Testing hooks with react-hooks-testing-library and Redux
- 6. JavaScript testing #6. Introduction to End-to-End testing with Cypress
- 7. JavaScript testing #7. Diving deeper into commands and selectors in Cypress
- 8. JavaScript testing #8. Integrating Cypress with Cucumber and Gherkin
- 9. JavaScript testing #9. Replacing Enzyme with React Testing Library
- 10. JavaScript testing #10. Advanced mocking with Jest and React Testing Library
- 11. JavaScript testing #11. Spying on functions. Pitfalls of not resetting Jest mocks
- 12. JavaScript testing #12. Testing downloaded files and file inputs with Cypress
- 13. JavaScript testing #13. Mocking a REST API with the Mock Service Worker
- 14. JavaScript testing #14. Mocking WebSockets using the mock-socket library
- 15. JavaScript testing #15. Interpreting the code coverage metric
- 16. JavaScript testing #16. Snapshot testing with React, Jest, and Vitest
- 17. JavaScript testing #17. Introduction to End-to-End testing with Playwright
- 18. JavaScript testing #18. E2E Playwright tests for uploading and downloading files
With code coverage, we can measure the percentage of our code that runs with our test. In this article, we learn how to measure our coverage and discuss whether it is a metric worth considering. We will understand its benefits and limitations and see how a high code coverage can mislead us into thinking that we tested our code thoroughly. Thanks to that, we will be better equipped to use code coverage effectively as part of our testing process.
Measuring the code coverage
Let’s create a straightforward function that will help us illustrate how the code coverage is measured.
getAgeGroup.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
export enum AgeGroup { Child = 'Child', Teenager = 'Teenager', Adult = 'Adult', } function validateAge(age: number) { if (age < 0) { throw new Error('The age can not be negative'); } } export function getAgeGroup(age: number) { validateAge(age); if (age < 13) { return AgeGroup.Child; } if (age < 18) { return AgeGroup.Teenager; } return AgeGroup.Adult; } |
Above, we return a different age group based on the provided number. If the number is negative, we throw an error.
Let’s write a simple test using Jest.
getAgeGroup.test.ts
1 2 3 4 5 6 7 8 9 10 |
import { AgeGroup, getAgeGroup } from './getAgeGroup'; describe('When the getAgeGroup function is called', () => { describe('and a number smaller than 13 is provided', () => { it('should return the Child age group', () => { const result = getAgeGroup(10); expect(result).toBe(AgeGroup.Child); }); }); }); |
Our test ensures that if we provide a number smaller than 13, the function returns the child age group.
PASS src/getAgeGroup.test.ts
When the getAgeGroup function is called
and a number smaller than 13 is provided
✓ should return the Child age group
Pointing to the correct files
We should tell Jest which files to collect coverage from using the collectCoverageFrom property.
jest.config.ts
1 2 3 4 5 6 7 8 9 10 11 |
import type { Config } from 'jest'; const config: Config = { testRegex: '.*.test.ts$', preset: 'ts-jest', collectCoverageFrom: [ './src/**/*.ts' ], }; export default config; |
If we don’t do that, Jest will collect the coverage only from the tested files. If we had a file with no tests, it would not have affected the coverage number. We should avoid that.
Running the correct command
To measure the code coverage, we must run Jest with the --coverage flag.
package.json
1 2 3 4 5 6 7 8 9 10 |
{ "scripts": { "start": "ts-node index.ts", "lint": "eslint . --ext .ts", "prettier": "prettier --write \"src/**/*.ts\"", "test": "jest", "test:coverage": "jest --coverage" }, ... } |
Let’s run the test:coverage command and inspect the output.
1 2 3 4 5 6 |
----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ----------------|---------|----------|---------|---------|------------------- All files | 69.23 | 60 | 100 | 69.23 | getAgeGroup.ts | 69.23 | 60 | 100 | 69.23 | 9,18-21 ----------------|---------|----------|---------|---------|------------------- |
Explaining each coverage metric
In the output above, we can see various coverage metrics. Let’s go through all of them.
Statement coverage
Our test has a 69.23 percent statement coverage. It means that 30.77 percent of executable statements remain untested, potentially hiding some bugs.
An executable statement is a statement that performs an action, such as assigning a value to a variable or calling a function.
Branch coverage
In our code, there are places where decisions are made, for example, with if statements.
1 2 3 |
if (age < 18) { return AgeGroup.Teenager; } |
Our code is branching out. Only in some cases does it reach the return AgeGroup.Teenager part. Branch coverage measures the percentage of executed branches such as the one above. Since our branch coverage is 60 percent, 40 percent of branches are not covered by our test.
Function coverage
Function coverage determines the percentage of functions called during testing. It’s important to notice that even if we don’t test the validateAge function explicitly, we call it in the getAgeGroup function. Thanks to that, our function coverage is 100 percent. However, that does not mean that each function is thoroughly tested. This metric simply shows us that all functions in a particular file have been called.
Line coverage
The line coverage shows the number of lines that have been executed. Our line coverage is 69.23 percent, meaning 30.77 percent of lines are untested.
This metric is very similar to the statement coverage metric. In our code, we put one statement per line, so those metrics are identical in our case. However, it could be different if we would put more than one statement per line, such as the following:
1 |
const age = 20; console.log(age); |
Uncovered lines
This part of the coverage output tells us which lines of code are not covered by the tests. This makes it easier to pinpoint the exact places we should focus on.
Improving our test to increase the coverage
Let’s modify our test suite to cover all of the possible cases.
getAgeGroup.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import { AgeGroup, getAgeGroup } from './getAgeGroup'; describe('When the getAgeGroup function is called', () => { describe('and a negative number is provided', () => { it('should throw an error', () => { expect(() => getAgeGroup(-1)).toThrow(); }); }); describe('and a number smaller than 13 is provided', () => { it('should return the Child age group', () => { const result = getAgeGroup(10); expect(result).toBe(AgeGroup.Child); }); }); describe('when a number between 13 and 18 is provided', () => { it('should return the Teenager age group', () => { const result = getAgeGroup(15); expect(result).toBe(AgeGroup.Teenager); }); }); describe('when a number bigger than 18 is provided', () => { it('should return the Adult age group', () => { const result = getAgeGroup(20); expect(result).toBe(AgeGroup.Adult); }); }); }); |
PASS src/getAgeGroup.test.ts
When the getAgeGroup function is called
and a negative number is provided
✓ should throw an error
and a number smaller than 13 is provided
✓ should return the Child age group
when a number between 13 and 18 is provided
✓ should return the Teenager age group
when a number bigger than 18 is provided
✓ should return the Adult age group
Our code coverage is now 100% across all metrics, thanks to covering all cases.
1 2 3 4 5 6 |
----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ----------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | getAgeGroup.ts | 100 | 100 | 100 | 100 | ----------------|---------|----------|---------|---------|------------------- |
The flaws of code coverage metrics
Low code coverage can be a good indicator that our tests are lacking. However, even 100 percent coverage does not guarantee a function is thoroughly tested.
getUserAgeFromJson.ts
1 2 3 4 |
export function getUserAgeFromJson(json: string) { const parsedJson = JSON.parse(json); return parsedJson.age; } |
We only need a straightforward test to achieve 100 percent coverage for the above function.
getUserAgeFromJson.test.ts
1 2 3 4 5 6 7 8 9 10 |
import { getUserAgeFromJson } from './getUserAgeFromJson'; describe('The parseUserJson function', () => { describe('when provided with a string containing a JSON representing a user', () => { it('should return their age', () => { const result = getUserAgeFromJson('{ "age": 25 }'); expect(result).toBe(25); }); }); }); |
Our test achieves full code coverage, but we are far from testing every case. For example, if we provide a string that is not a valid JSON, our function throws an error. If we provide a valid JSON that does not contain the age property, our function returns undefined.
Summary
The code coverage is a good way to track how much of our code is executed by our tests. However, it does not tell us much about the quality of our tests. Even if we have the full code coverage, our tests might still miss important cases that we should cover.
Striving to have high code coverage can benefit our application, but it does not yet mean our code is bug-free. Therefore, we must also focus on creating meaningful tests that challenge our code in various scenarios to ensure the reliability of our codebase.