Objective
Learn to implement automated unit testing using Jest in a Node.js application. By the end of this lab, you will:
- Set up a Node.js project with Jest for unit testing
- Write unit tests for functions with validation and error handling
- Understand the difference between statement and branch coverage
- Run tests and generate coverage reports to evaluate code quality
Prerequisites
- Node.js (version 14 or higher) installed
- Basic knowledge of JavaScript and Node.js
- A code editor (e.g., VS Code)
- Terminal or command-line interface
Lab Overview
This lab guides you through creating a Node.js application with a divide function. You will progressively enhance the function while learning about unit testing, the AAA pattern, and the critical difference between statement and branch coverage.
Why Unit Testing is Important
Unit testing ensures individual code components work as expected, catching errors early and maintaining software quality. Without unit tests, bugs can reach production and cause serious issues.
Real-world example: Suppose a developer accidentally modifies a divide function to use multiplication instead of division while fixing a bug. This would make divide(10, 2) return 20 instead of 5, causing incorrect calculations. A unit test like expect(divide(10, 2)).toBe(5) would immediately fail, alerting the developer to the error.
CI/CD Integration: Unit tests integrate with Continuous Integration/Continuous Deployment pipelines. In systems like GitHub Actions, tests run automatically on every code change. If any test fails, the pipeline blocks the code from being merged or deployed to production, ensuring faulty code never reaches users.
The AAA Pattern: Structure of a Good Unit Test
Before diving into testing, it's important to understand the AAA pattern (Arrange-Act-Assert), a fundamental structure for writing clear and effective unit tests:
-
Arrange: Set up the test data and conditions
- Prepare the inputs
- Initialize any required variables or objects
- Set up the environment for the test
-
Act: Execute the code being tested
- Call the function or method
- Perform the action you want to test
- This is usually a single line of code
-
Assert: Verify the expected outcome
- Check that the result matches expectations
- Use assertion methods like
expect().toBe() - Confirm the code behaved correctly
The AAA pattern makes tests easier to read, understand, and maintain. You'll see this pattern throughout the lab.
Step-by-Step Instructions
Step 1: Set Up the Node.js Project
-
Create a project directory:
mkdir jest-divide-app cd jest-divide-app -
Initialize a Node.js project:
npm init -y -
Install Jest:
npm install --save-dev jest -
Update
package.json:Open
package.jsonand modify thescriptssection:
"scripts": { "test": "jest", "test:coverage": "jest --coverage" }
Step 2: Create the Application Code
-
Create
divide.js:Create a file named
divide.jsin the project root with the following simple code:
function divide(a, b) { return a / b; } module.exports = divide; -
Verify the file structure:
jest-divide-app/ ├── node_modules/ ├── package.json ├── package-lock.json └── divide.js
Step 3: Write Unit Tests
-
Create
divide.test.js:Create a file named
divide.test.jsin the project root:
const divide = require('./divide'); test('divides 10 by 2 to equal 5', () => { expect(divide(10, 2)).toBe(5); // Arrange, Act, Assert combined }); test('divides 20 by 4 to equal 5', () => { expect(divide(20, 4)).toBe(5); }); test('divides 100 by 10 to equal 10', () => { expect(divide(100, 10)).toBe(10); }); -
Test code explanation:
-
require('./divide'): Imports thedividefunction fromdivide.js -
test(): Defines individual test cases with a description and assertion -
expect(divide(10, 2)).toBe(5): Follows the AAA pattern in a single line-
Arrange: The values
10and2are the test inputs -
Act:
divide(10, 2)calls the function -
Assert:
.toBe(5)verifies the expected result
-
Arrange: The values
-
Step 4: Run the Tests
-
Execute the tests:
npm test -
Expected output:
PASS ./divide.test.js ✓ divides 10 by 2 to equal 5 (2 ms) ✓ divides 20 by 4 to equal 5 (1 ms) ✓ divides 100 by 10 to equal 10 (1 ms) Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 0 total Time: 0.5 s
Step 5: Generate Test Coverage Report
-
Run the coverage command:
npm run test:coverage -
Expected output:
PASS ./divide.test.js √ divides 10 by 2 to equal 5 (3 ms) √ divides 20 by 4 to equal 5 (1 ms) √ divides 100 by 10 to equal 10 (1 ms) -----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -----------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | divide.js | 100 | 100 | 100 | 100 | -----------------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 0 total Time: 4.653 s, estimated 13 s Ran all test suites. -
View the HTML coverage report:
- Jest creates a
coveragedirectory in your project root - Open
coverage/lcov-report/index.htmlin a web browser - The report displays which lines, functions, and branches are covered by tests
- Jest creates a
💡
Tip: Coverage doesn’t guarantee correctness. You can still have 100% statement/branch coverage and a failing test suite (for example, if the code is correct but a test has the wrong expected value, uses stale requirements, or has a flawed assertion). Always run and fix failing tests first, then use coverage to find what you’re missing—not to prove everything is correct.
Step 6: Analyze the Results
-
Test results:
- All 3 tests should pass
- Tests cover basic division operations
- If any test fails, review the test case and function implementation
-
Coverage report:
- The report should show 100% coverage for
divide.js - All lines of the simple
dividefunction are exercised by the tests
- The report should show 100% coverage for
Adding Error Handling: Division by Zero
Mathematical operations have rules. Division by zero is undefined and should be handled properly.
Step 7: Add Zero-Division Protection
-
Update
divide.js:Add error handling for division by zero:
function divide(a, b) { if (b === 0) { throw new Error('Cannot divide by zero'); } return a / b; } module.exports = divide; -
Run existing tests:
npm testImportant: All 3 existing tests should still pass. They use non-zero divisors, but the coverage report will look like this:
PASS ./divide.test.js √ divides 10 by 2 to equal 5 (3 ms) √ divides 20 by 4 to equal 5 (1 ms) √ divides 100 by 10 to equal 10 (1 ms) -----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -----------------|---------|----------|---------|---------|------------------- All files | 75 | 50 | 100 | 75 | divide.js | 75 | 50 | 100 | 75 | 3 -----------------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 0 total Time: 4.653 s, estimated 13 s Ran all test suites.
Step 8: Add Tests for Error Handling
-
Update
divide.test.js:Add test cases for the zero-division error:
const divide = require('./divide'); // Tests for valid division operations test('divides 10 by 2 to equal 5', () => { expect(divide(10, 2)).toBe(5); }); test('divides 20 by 4 to equal 5', () => { expect(divide(20, 4)).toBe(5); }); test('divides 100 by 10 to equal 10', () => { expect(divide(100, 10)).toBe(10); }); // Tests for error handling (AAA pattern for error testing) test('throws error when dividing by zero', () => { // Arrange - prepare invalid input const divisor = 0; // Act & Assert - expect function to throw error expect(() => divide(10, divisor)).toThrow('Cannot divide by zero'); }); test('throws error when dividing zero by zero', () => { expect(() => divide(0, 0)).toThrow('Cannot divide by zero'); });Note on testing errors with AAA:
- **Arrange**: Prepare invalid inputs
- **Act & Assert**: Use `expect(() => ...)` to wrap the function call and verify it throws
- The arrow function prevents the error from stopping the test execution
-
Run the updated tests:
npm testExpected output:
PASS ./divide.test.js ✓ divides 10 by 2 to equal 5 (2 ms) ✓ divides 20 by 4 to equal 5 (1 ms) ✓ divides 100 by 10 to equal 10 (1 ms) ✓ throws error when dividing by zero (3 ms) ✓ throws error when dividing zero by zero (1 ms) Test Suites: 1 passed, 1 total Tests: 5 passed, 5 total Snapshots: 0 total Time: 0.6 s -
Verify coverage:
npm run test:coverageThe coverage should still be 100% as all code paths are tested.
Understanding Statement vs Branch Coverage
Now we'll learn a critical lesson: 100% statement coverage does NOT guarantee fully tested code.
Step 9: Add Complex Logic with Branches
-
Update
divide.jswith conditional logic:Add a feature to handle negative numbers by returning the absolute value:
function divide(a, b) { if (b === 0) { throw new Error('Cannot divide by zero'); } if (a < 0 || b < 0) { return Math.abs(a / b); } return a / b; } module.exports = divide;New logic explained:
- If either number is negative, return the absolute value of the division
- This creates multiple branches: both positive, first negative, second negative, both negative
-
Run existing tests (don't add new ones yet):
npm testAll 5 tests should still pass!
-
Check coverage:
npm run test:coverageCritical observation:
-----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -----------------|---------|----------|---------|---------|------------------- All files | 100 | 50 | 100 | 100 | divide.js | 100 | 50 | 100 | 100 | 5 -----------------|---------|----------|---------|---------|-------------------Notice:
- **Statement coverage**: 100% ✓ (all lines executed)
- **Branch coverage**: 50% ✗ (not all decision paths tested)
- Line 5 (the `if (a < 0 || b < 0)` condition) shows partially covered
-
Open the HTML coverage report:
Open
coverage/lcov-report/index.htmlin a browser:
- Line 5 will be highlighted in yellow or have an indicator showing 2/4 or similar
- This means the condition was executed, but not all branches were taken
Step 10: Understanding the Gap
Why 100% statement coverage but only 50% branch coverage?
The condition if (a < 0 || b < 0) has multiple branches:
-
a < 0is true (left side of||) -
a < 0is false ANDb < 0is true (right side of||) - Both are false (skip the if block)
Our current tests only cover:
- Both positive:
divide(10, 2)→ condition is false, line 5 executes but if-block doesn't - This gives 100% statement coverage (line 5 executed) but misses the branches!
The danger: We've never actually tested what happens when negative numbers are passed!
Step 11: Achieve 100% Branch Coverage
-
Update
divide.test.jswith comprehensive tests:
const divide = require('./divide'); describe('divide function', () => { describe('positive numbers', () => { test('divides 10 by 2 to equal 5', () => { expect(divide(10, 2)).toBe(5); }); test('divides 20 by 4 to equal 5', () => { expect(divide(20, 4)).toBe(5); }); test('divides 100 by 10 to equal 10', () => { expect(divide(100, 10)).toBe(10); }); }); describe('negative numbers (returns absolute value)', () => { test('divides negative dividend by positive divisor', () => { // Arrange const dividend = -10; const divisor = 2; // Act const result = divide(dividend, divisor); // Assert expect(result).toBe(5); // |-10 / 2| = 5 }); test('divides positive dividend by negative divisor', () => { expect(divide(10, -2)).toBe(5); // |10 / -2| = 5 }); test('divides two negative numbers', () => { expect(divide(-10, -2)).toBe(5); // |-10 / -2| = 5 }); test('divides negative by negative with different result', () => { expect(divide(-20, -4)).toBe(5); // |-20 / -4| = 5 }); }); describe('error handling', () => { test('throws error when dividing by zero', () => { expect(() => divide(10, 0)).toThrow('Cannot divide by zero'); }); test('throws error when dividing zero by zero', () => { expect(() => divide(0, 0)).toThrow('Cannot divide by zero'); }); test('throws error when dividing negative by zero', () => { expect(() => divide(-10, 0)).toThrow('Cannot divide by zero'); }); }); }); -
Run the complete test suite:
npm testExpected output:
PASS ./divide.test.js divide function positive numbers ✓ divides 10 by 2 to equal 5 (2 ms) ✓ divides 20 by 4 to equal 5 (1 ms) ✓ divides 100 by 10 to equal 10 (1 ms) negative numbers (returns absolute value) ✓ divides negative dividend by positive divisor (1 ms) ✓ divides positive dividend by negative divisor (1 ms) ✓ divides two negative numbers (1 ms) ✓ divides negative by negative with different result (1 ms) error handling ✓ throws error when dividing by zero (3 ms) ✓ throws error when dividing zero by zero (1 ms) ✓ throws error when dividing negative by zero (1 ms) Test Suites: 1 passed, 1 total Tests: 10 passed, 10 total Snapshots: 0 total Time: 0.8 s -
Generate final coverage report:
npm run test:coverageNow you should see:
-----------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -----------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | divide.js | 100 | 100 | 100 | 100 | -----------------|---------|----------|---------|---------|-------------------Perfect! 100% coverage across all metrics!
-
Check the HTML report:
Open
coverage/lcov-report/index.html:
- All lines should be highlighted in green
- No yellow or red indicators
- All branches fully tested
References
- Jest Documentation: https://jestjs.io/docs/getting-started
- Node.js Documentation: https://nodejs.org/en/docs/
- Jest Expect API: https://jestjs.io/docs/expect
- Jest Coverage: https://jestjs.io/docs/cli#--coverageboolean
Top comments (0)