DEV Community

Eyob Samuel
Eyob Samuel

Posted on

Simple JavaScript Automation Unit Testing Using Jest

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:

  1. Arrange: Set up the test data and conditions
    • Prepare the inputs
    • Initialize any required variables or objects
    • Set up the environment for the test
  2. 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
  3. 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

  1. Create a project directory:

    mkdir jest-divide-app
    cd jest-divide-app
    
  2. Initialize a Node.js project:

    npm init -y
    
  3. Install Jest:

    npm install --save-dev jest
    
  4. Update package.json:

    Open package.json and modify the scripts section:

    "scripts": {
      "test": "jest",
      "test:coverage": "jest --coverage"
    }
    

Step 2: Create the Application Code

  1. Create divide.js:

    Create a file named divide.js in the project root with the following simple code:

    function divide(a, b) {
      return a / b;
    }
    
    module.exports = divide;
    
  2. Verify the file structure:

    jest-divide-app/
    ├── node_modules/
    ├── package.json
    ├── package-lock.json
    └── divide.js
    

Step 3: Write Unit Tests

  1. Create divide.test.js:

    Create a file named divide.test.js in 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);
    });
    
  2. Test code explanation:

    • require('./divide'): Imports the divide function from divide.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 10 and 2 are the test inputs
      • Act: divide(10, 2) calls the function
      • Assert: .toBe(5) verifies the expected result

Step 4: Run the Tests

  1. Execute the tests:

    npm test
    
  2. 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

  1. Run the coverage command:

    npm run test:coverage
    
  2. 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.
    
  3. View the HTML coverage report:

    • Jest creates a coverage directory in your project root
    • Open coverage/lcov-report/index.html in a web browser
    • The report displays which lines, functions, and branches are covered by tests

💡

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

  1. Test results:
    • All 3 tests should pass
    • Tests cover basic division operations
    • If any test fails, review the test case and function implementation
  2. Coverage report:
    • The report should show 100% coverage for divide.js
    • All lines of the simple divide function are exercised by the tests

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

  1. 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;
    
  2. Run existing tests:

    npm test
    

    Important: 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

  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. Run the updated 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)
      ✓ 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
    
  2. Verify coverage:

    npm run test:coverage
    

    The 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

  1. Update divide.js with 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
Enter fullscreen mode Exit fullscreen mode
  1. Run existing tests (don't add new ones yet):

    npm test
    

    All 5 tests should still pass!

  2. Check coverage:

    npm run test:coverage
    

    Critical 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
Enter fullscreen mode Exit fullscreen mode
  1. Open the HTML coverage report:

    Open coverage/lcov-report/index.html in 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
Enter fullscreen mode Exit fullscreen mode

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 < 0 is true (left side of ||)
  • a < 0 is false AND b < 0 is 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

  1. Update divide.test.js with 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');
        });
      });
    });
    
  2. Run the complete test suite:

    npm test
    

    Expected 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
    
  3. Generate final coverage report:

    npm run test:coverage
    

    Now 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!

  4. 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
Enter fullscreen mode Exit fullscreen mode

References

Top comments (0)