Categories Javascript

Testing Node.js Applications with Jest and TypeScript

Testing is a crucial aspect of software development that ensures our applications work as intended. In this guide, we’ll explore how to effectively test Node.js applications using Jest and TypeScript. Jest, maintained by Facebook, has become the de facto standard for JavaScript testing, while TypeScript adds type safety to our JavaScript code, making it more reliable and maintainable.

We’ll walk through creating a simple calculator application and learn how to test it thoroughly using Jest with TypeScript. By the end of this tutorial, you’ll have a solid understanding of how to set up, write, and maintain tests for your Node.js applications.

Goals of the Tutorial

This tutorial is designed to provide you with practical, hands-on experience in testing Node.js applications. By following along, you’ll learn essential testing skills that will improve your code quality and confidence in your applications. Here’s what we’ll cover:

  • Set up a testing environment with Jest and TypeScript
  • Create and test a simple calculator application
  • Learn best practices for Node.js testing
  • Understand advanced testing concepts and patterns

Setting Up the Development Environment

Prerequisites

Before we begin, ensure you have:

  • Node.js (version 18 or higher) installed
  • npm (comes with Node.js)
  • Basic knowledge of TypeScript
  • A code editor (VS Code recommended)

Project Initialization

First, create a new directory and initialize your project:

mkdir calculator-test
cd calculator-test
npm init -y

Install the required dependencies:

npm install --save-dev jest typescript ts-jest @types/jest
npm install --save-dev @types/node
  • npm init -y: Creates a package.json file with default values (the -y flag accepts all defaults)
  • The first install command adds Jest (the testing framework), TypeScript (for type safety), ts-jest (a Jest transformer for TypeScript), and type definitions for Jest
  • The second install adds type definitions for Node.js, which provides TypeScript types for built-in Node.js modules

Project Structure

A well-organized project structure helps maintain clear separation of concerns and makes it easier to locate files. For our calculator application, we’ll use the following structure:

calculator-test/
├── src/
│   └── calculator.ts
├── tests/
│   └── calculator.test.ts
├── jest.config.js
├── tsconfig.json
└── package.json

This structure separates source code (src/) from test files (tests/), making it easy to distinguish between implementation and test code. Configuration files are kept at the root level for easy access.

Configuration Setup

Create a TypeScript configuration file (tsconfig.json):

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["jest", "node"]
  }
}
  • target: "es6": Compiles TypeScript to ECMAScript 2015 (ES6)
  • module: "commonjs": Uses CommonJS module system, compatible with Node.js
  • strict: true: Enables strict type checking options
  • esModuleInterop: true: Allows default imports from modules with no default export
  • types: ["jest", "node"]: Includes type definitions for Jest and Node.js

Create a Jest configuration file (jest.config.js):

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleFileExtensions: ['ts', 'js'],
  testMatch: ['**/*.test.ts'],
  collectCoverage: true,
  coverageReporters: ['text', 'html']
};
  • preset: 'ts-jest': Configures Jest to use ts-jest for processing TypeScript files
  • testEnvironment: 'node': Runs tests in a Node.js environment rather than a browser
  • moduleFileExtensions: File extensions Jest will look for
  • testMatch: Pattern to find test files (any file ending with .test.ts)
  • collectCoverage: Automatically collects code coverage information
  • coverageReporters: Outputs coverage reports in text and HTML formats

Update your package.json scripts:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
  • test: Runs Jest once to execute all tests
  • test:watch: Runs Jest in watch mode, which reruns tests when files change
  • test:coverage: Runs Jest with coverage reporting enabled

Sample Application Overview

Building a Simple Calculator Application

Let’s create a simple calculator class that performs basic arithmetic operations. Create a new file src/calculator.ts:

export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }

  subtract(a: number, b: number): number {
    return a - b;
  }

  multiply(a: number, b: number): number {
    return a * b;
  }

  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Division by zero is not allowed');
    }
    return a / b;
  }
}
  • This class defines four basic arithmetic operations as methods
  • Each method is strongly typed with TypeScript, taking two numbers as parameters and returning a number
  • The divide method includes input validation that throws an error if attempting to divide by zero
  • The export keyword makes the class available to other files that import it

Writing Our First Tests

Basic Test Structure

Create a new file tests/calculator.test.ts:

import { Calculator } from '../src/calculator';

describe('Calculator', () => {
  let calculator: Calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  describe('add', () => {
    test('should add two positive numbers correctly', () => {
      expect(calculator.add(2, 3)).toBe(5);
    });

    test('should handle negative numbers', () => {
      expect(calculator.add(-1, -2)).toBe(-3);
    });
  });

  describe('divide', () => {
    test('should divide two numbers correctly', () => {
      expect(calculator.divide(6, 2)).toBe(3);
    });
    
    test('should throw error when dividing by zero', () => {
      expect(() => calculator.divide(6, 0)).toThrow('Division by zero is not allowed');
    });
  });
});
  • import { Calculator }: Imports the Calculator class from our src directory
  • describe('Calculator', () => {}): Creates a test suite for the Calculator class
  • let calculator: Calculator;: Declares a variable with TypeScript type annotation
  • beforeEach(() => {}): Jest hook that runs before each test, creating a fresh Calculator instance
  • describe('add', () => {}): Creates a nested test suite specifically for the add method
  • test('should add...', () => {}): Defines an individual test case with a descriptive name
  • expect(...).toBe(...): Jest assertion that verifies the actual result matches the expected value
  • expect(() => calculator.divide(6, 0)).toThrow(): Tests that an exception is thrown by wrapping the function call in a callback

Running the Tests

To run the tests, use the npm scripts we defined earlier:

npm test           # Run tests once
npm run test:watch # Run tests in watch mode

The test output will show the results of each test case and overall coverage statistics. When tests pass, you’ll see green checkmarks with the test names. Failed tests will display red X marks with detailed error messages showing the expected versus actual values. The coverage report will indicate which parts of your code were executed during tests and highlight any uncovered sections.


> calculator-test@1.0.0 test
> jest
 PASS  tests/calculator.test.ts
  Calculator
    add
      ✓ should add two positive numbers correctly (2 ms)
      ✓ should handle negative numbers
    divide
      ✓ should divide two numbers correctly
      ✓ should throw error when dividing by zero (4 ms)

---------------|---------|----------|---------|---------|-------------------
File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files      |   71.42 |      100 |      50 |   71.42 |
 calculator.ts |   71.42 |      100 |      50 |   71.42 | 7-11
---------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.342 s
Ran all test suites.   

Additional Test Matchers

Jest provides a rich set of matchers for different types of assertions. Understanding these matchers will help you write more expressive and precise tests, making your test intentions clearer and debugging easier. Here are some commonly used ones:

describe('Calculator matchers example', () => {
  test('demonstration of different matchers', () => {
    const calc = new Calculator();
    
    // Exact equality
    expect(calc.add(2, 2)).toBe(4);
    
    // Truthiness
    expect(calc.multiply(2, 3)).toBeTruthy();
    
    // Number comparisons
    expect(calc.subtract(5, 3)).toBeGreaterThan(1);
    
    // Error throwing
    expect(() => calc.divide(1, 0)).toThrow();
  });
});
  • toBe(4): Uses JavaScript’s strict equality comparison (===) to check if the result equals 4
  • toBeTruthy(): Checks if the result is truthy (any value that evaluates to true in a boolean context)
  • toBeGreaterThan(1): Verifies the result is numerically greater than 1
  • toThrow(): Verifies that the function throws an exception when executed
    • Note how the function is wrapped in another function () => ... when checking for exceptions
    • This is necessary because Jest needs to catch the error during execution

These matchers allow you to verify different aspects of your code’s behavior, from simple equality checks to validating error conditions. Jest offers dozens more matchers for specialized assertions like partial object matching, array contents, and string patterns.

Common Patterns

describe('Calculator common patterns', () => {
  let calculator: Calculator;
  
  // Setup
  beforeEach(() => {
    calculator = new Calculator();
  });
  
  // Teardown
  afterEach(() => {
    // Clean up if needed
  });
  
  test('should handle multiple operations', () => {
    // Arrange
    const firstNumber = 10;
    const secondNumber = 5;
    
    // Act
    const sum = calculator.add(firstNumber, secondNumber);
    const difference = calculator.subtract(sum, secondNumber);
    
    // Assert
    expect(difference).toBe(10);
  });
});
  • beforeEach(): Hook that runs before each test to set up fresh state
  • afterEach(): Hook that runs after each test for cleanup (empty in this example)
  • The test demonstrates the AAA pattern:
    • Arrange: Setup test data (firstNumbersecondNumber)
    • Act: Perform the operations being tested (add and subtract)
    • Assert: Verify the outcome matches expectations
  • This pattern makes tests clearer and more maintainable

Testing a REST API

Real-world Node.js applications often provide RESTful APIs, which present unique testing challenges due to their asynchronous nature and HTTP protocol interactions. We’ll add a REST API to our project to demonstrate how to test these scenarios effectively, focusing on proper request handling, response validation, and error management.

First, we need to install the necessary dependencies:

  • Express – The most popular Node.js web framework for building REST APIs:
npm install express
npm install --save-dev @types/express
  • Body-parser – To parse JSON request bodies (though newer Express versions include this):
npm install body-parser
npm install --save-dev @types/body-parser
  • Cors – For handling Cross-Origin Resource Sharing:
npm install cors
npm install --save-dev @types/cors
  • For testing the API:
npm install --save-dev supertest @types/supertest

Supertest is particularly important for our async testing example as it allows us to make HTTP requests to our API and assert on the responses in our Jest tests without having to start a real HTTP server.

First, we need to create a basic Express application. Let’s create these files:

Calculator API Server (src/calculatorApi.ts)

// src/calculatorApi.ts
import express, { RequestHandler } from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import { Calculator } from './calculator';

export function createServer() {
  const app = express();
  const calculator = new Calculator();
  
  // Middleware
  app.use(cors());
  app.use(bodyParser.json());
  
  // Routes
  app.post('/calculate/:operation', (req, res) => {
    try {
      const { a, b } = req.body;
      const operation = req.params.operation;
      
      // Validate inputs
      if (typeof a !== 'number' || typeof b !== 'number') {
        res.status(400).json({ 
          error: 'Parameters "a" and "b" must be numbers' 
        });
        return;
      }
      
      let result: number;
      
      // Perform the requested operation
      switch (operation) {
        case 'add':
          result = calculator.add(a, b);
          break;
        case 'subtract':
          result = calculator.subtract(a, b);
          break;
        case 'multiply':
          result = calculator.multiply(a, b);
          break;
        case 'divide':
          try {
            result = calculator.divide(a, b);
          } catch (error) {
            res.status(400).json({ 
              error: 'Division by zero is not allowed' 
            });
            return;
          }
          break;
        default:
          res.status(404).json({ 
            error: `Operation "${operation}" not supported` 
          });
          return;
      }
      
      res.status(200).json({ result });
    } catch (error) {
      res.status(500).json({ 
        error: 'Internal server error' 
      });
    }
  });
  
  return app;
}

The API server code above demonstrates several important concepts:

  1. Separation of concerns: We’ve separated the server creation logic (createServer()) from the server startup code, making it easier to test without starting an actual HTTP server.
  2. Input validation: The API validates that the input parameters are numbers and returns appropriate HTTP status codes (400) when validation fails.
  3. Error handling: We have proper error handling for both expected errors (division by zero) and unexpected errors (using try-catch blocks).
  4. RESTful design: The API follows REST principles by using appropriate HTTP methods (POST) and status codes (200 for success, 400 for client errors, 404 for not found, 500 for server errors).
  5. Middleware usage: We use standard Express middleware for CORS support and JSON body parsing.

This structure allows us to write comprehensive tests that can verify both the happy path and error scenarios without worrying about network issues or actual server startup concerns.

Server Entry Point (src/server.ts)

// src/server.ts
import { createServer } from './calculatorApi';

const app = createServer();
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Calculator API is running on port ${PORT}`);
});

Asynchronous Testing

Testing asynchronous code presents unique challenges since the test runner needs to know when to evaluate assertions after asynchronous operations complete. Jest provides excellent support for async testing using promises, async/await syntax, and callback patterns. This is particularly important when testing APIs that make HTTP requests or database operations.

The following test suite demonstrates how to test our Calculator API using Supertest:

// tests/calculatorApi.test.ts
import request from 'supertest';
import { createServer } from '../src/calculatorApi';
import { Server } from 'http';

describe('Calculator API', () => {
  const app = createServer();
  let server: Server;
  
  // Start the server before tests
  beforeAll((done) => {
    server = app.listen(0, () => {
      done();
    });
  });
  
  // Close the server after all tests
  afterAll((done) => {
    if (server) {
      server.close(done);
    } else {
      done();
    }
  });
  
  describe('POST /calculate/add', () => {
    test('adds two numbers correctly', async () => {
      const response = await request(app)
        .post('/calculate/add')
        .send({ a: 5, b: 3 })
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(response.body).toEqual({ result: 8 });
    });
 
    test('returns 400 if parameters are not numbers', async () => {
      const response = await request(app)
        .post('/calculate/add')
        .send({ a: 'not-a-number', b: 3 })
        .expect('Content-Type', /json/)
        .expect(400);
      
      expect(response.body.error).toBeDefined();
    });
  });
  
  describe('POST /calculate/divide', () => {
    test('divides two numbers correctly', async () => {
      const response = await request(app)
        .post('/calculate/divide')
        .send({ a: 10, b: 2 })
        .expect('Content-Type', /json/)
        .expect(200);
      
      expect(response.body).toEqual({ result: 5 });
    });
    
    test('returns 400 when dividing by zero', async () => {
      const response = await request(app)
        .post('/calculate/divide')
        .send({ a: 10, b: 0 })
        .expect('Content-Type', /json/)
        .expect(400);
      
      expect(response.body.error).toBe('Division by zero is not allowed');
    });
  });
  
  describe('POST /calculate/unknown', () => {
    test('returns 404 for unknown operations', async () => {
      const response = await request(app)
        .post('/calculate/unknown')
        .send({ a: 5, b: 3 })
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(response.body.error).toBeDefined();
    });
  });
});

This test suite demonstrates several important async testing techniques:

  1. Server lifecycle management: Using beforeAll and afterAll hooks to start and stop the server for the duration of the tests.
  2. HTTP request testing: Using Supertest to make HTTP requests to our API without having to manually manage ports or connections.
  3. Async/await syntax: Making the tests readable by using async and await instead of promise chains.
  4. Status code assertions: Verifying that the API returns the correct HTTP status codes for different scenarios.
  5. Response body assertions: Checking that the response body contains the expected data or error messages.
  6. Content-Type verification: Ensuring the API returns the correct content type headers.
  7. Edge case testing: Testing both successful operations and error conditions like division by zero.

These techniques help ensure that your API handles all scenarios correctly, including input validation, error conditions, and successful operations.

Testing Best Practices

Code Organization

How you organize your test files can significantly impact your team’s productivity and the maintainability of your codebase. Following consistent patterns makes it easier to locate tests, understand their purpose, and detect coverage gaps. Here are some recommended practices:

  • Keep test files close to their implementation files
  • Use descriptive test names that explain the expected behavior
  • Group related tests using describe blocks
  • Follow the AAA (Arrange-Act-Assert) pattern

These practices improve readability, make navigation easier, and help new team members understand the test suite’s structure and purpose.

Test Coverage

Test coverage metrics help you understand how much of your code is being exercised by your tests. While high coverage doesn’t guarantee quality tests, low coverage often indicates testing gaps. Jest can generate detailed coverage reports showing which lines, statements, branches, and functions were executed during your tests.

Configure Jest to generate coverage reports:

// jest.config.js
module.exports = {
  // ... other config
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};
  • collectCoverage: true: Enables collection of code coverage metrics
  • coverageThreshold: Sets minimum coverage requirements for your project
    • branches: 80: Requires 80% of all code branches to be tested
    • functions: 80: Requires 80% of functions to be called in tests
    • lines: 80: Requires 80% of code lines to be executed during tests
    • statements: 80: Requires 80% of statements to be covered
  • If these thresholds are not met, the test run will fail, helping enforce coverage standards

By setting these thresholds, you establish quality gates that prevent code with insufficient testing from being merged or deployed, gradually improving your test coverage and code quality.

Debugging and Troubleshooting

Common Issues

When working with Jest and TypeScript, you may encounter some common configuration issues. Being aware of these problems and their solutions can save you troubleshooting time and frustration:

  1. TypeScript Configuration Problems
    • Ensure tsconfig.json includes test files
    • Verify proper module resolution settings
    • Check that @types/jest is properly installed
  2. Jest Configuration Issues
    • Verify the correct preset is set for TypeScript
    • Check test file patterns in testMatch
    • Ensure transform settings are correct

Resolving these issues early will help you maintain a smooth testing workflow and avoid intermittent testing problems.

Debugging Techniques

When tests fail, effective debugging becomes essential. VS Code provides powerful debugging tools that work seamlessly with Jest tests, allowing you to set breakpoints, inspect variables, and step through code execution to identify issues quickly.

VS Code Launch Configuration (launch.json):

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Tests",
      "program": "${workspaceFolder}/node_modules/jest/bin/jest",
      "args": ["--runInBand"],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen"
    }
  ]
}
  • This VS Code debug configuration allows step-by-step debugging of Jest tests
  • type: "node": Runs the debug session in Node.js
  • program: "...": Points to Jest’s CLI executable
  • args: ["--runInBand"]: Forces Jest to run tests serially (not in parallel)
  • console: "integratedTerminal": Shows output in VS Code’s integrated terminal
  • internalConsoleOptions: "neverOpen": Prevents the debug console from opening

With this configuration, you can set breakpoints in your tests or source code, run the debugger, and step through execution to see exactly what’s happening at each stage. This is particularly valuable for complex asynchronous tests or when investigating subtle logic errors.

Conclusion

We’ve covered the essential aspects of testing Node.js applications using Jest and TypeScript. Key takeaways include:

  • Proper setup of Jest with TypeScript
  • Writing effective tests using various matchers and patterns
  • Handling async operations and mocking
  • Implementing continuous integration

You May Also Like