TypeScript & Jest: A Beginner’s Guide to Unit Testing

In the world of software development, writing clean, maintainable, and bug-free code is paramount. One of the most effective ways to achieve this is through unit testing. Unit tests are small, isolated tests that verify individual components of your code. They help you catch errors early, ensure that your code behaves as expected, and make it easier to refactor and update your code in the future. In this tutorial, we will dive into unit testing with TypeScript and Jest, a popular and powerful testing framework.

Why Unit Testing Matters

Imagine you’re building a complex application. Without unit tests, you might write code, run it, and hope it works. If something goes wrong, you’ll have to manually debug the entire application to find the issue. This process can be time-consuming and frustrating.

Unit tests solve this problem by providing a safety net. They allow you to:

  • Catch bugs early: Identify and fix errors as soon as they are introduced.
  • Ensure code quality: Write cleaner, more modular code that’s easier to understand and maintain.
  • Refactor with confidence: Safely modify your code without fear of breaking existing functionality.
  • Improve collaboration: Help other developers understand and contribute to your codebase.

By writing unit tests, you’re investing in the long-term health and stability of your project.

Setting Up Your Environment

Before we start writing tests, we need to set up our development environment. We’ll be using TypeScript, Jest, and Node.js. Make sure you have Node.js and npm (or yarn) installed on your system. If you don’t, you can download them from the official Node.js website.

Let’s create a new project directory and initialize it with npm:

mkdir typescript-jest-tutorial
cd typescript-jest-tutorial
npm init -y

Next, install TypeScript and Jest as dev dependencies:

npm install --save-dev typescript jest @types/jest

Here’s a breakdown of what each package does:

  • typescript: The TypeScript compiler.
  • jest: The testing framework.
  • @types/jest: TypeScript type definitions for Jest.

Now, let’s configure TypeScript. Create a tsconfig.json file in your project root with the following content:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*", "tests/**/*"]
}

This configuration tells TypeScript how to compile your code. We’re setting the target to ES5 for wider browser compatibility, using CommonJS modules, specifying an output directory (dist), enabling ES module interop, enforcing consistent casing, enabling strict type checking, skipping library checks, and using Node module resolution. The include array specifies which files to include in the compilation: our source files (src/**/*) and our test files (tests/**/*).

Finally, let’s configure Jest. Create a jest.config.js file in your project root with the following content:

/** @type {import('jest').Config} */
const config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/tests/**/*.+(ts|tsx|js)', '!**/node_modules/**']
};

module.exports = config;

This configuration tells Jest how to run your tests. We’re using the ts-jest preset to compile TypeScript files, setting the test environment to Node, and specifying the test file patterns. The testMatch array tells Jest where to find your test files, in this case, all files ending in .ts, .tsx, or .js within the tests directory (or any subdirectory), excluding the node_modules directory.

Writing Your First Unit Test

Let’s create a simple TypeScript file and write a unit test for it. Create a directory named src in your project root. Inside the src directory, create a file named math.ts. Add the following code to math.ts:

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

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

This file exports two simple functions: add and subtract. Now, let’s write a unit test for these functions. Create a directory named tests in your project root. Inside the tests directory, create a file named math.test.ts. This naming convention (.test.ts) is commonly used to indicate test files. Add the following code to math.test.ts:

import { add, subtract } from '../src/math';

test('adds two numbers correctly', () => {
  expect(add(1, 2)).toBe(3);
});

test('subtracts two numbers correctly', () => {
  expect(subtract(5, 2)).toBe(3);
});

Let’s break down this code:

  • We import the add and subtract functions from our math.ts file.
  • We use the test function from Jest to define our tests. The first argument is a descriptive string that describes the test case. The second argument is a function that contains the test logic.
  • Inside the test function, we use the expect function to make assertions. The expect function takes the value you want to test, and then you use a matcher (e.g., toBe) to assert that the value meets certain criteria. In this case, we’re checking if add(1, 2) is equal to 3 and if subtract(5, 2) is equal to 3.

Now, let’s run our tests. Add a test script in your package.json file:

{
  "name": "typescript-jest-tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "jest"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/jest": "^29.5.11",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.1",
    "typescript": "^5.3.3"
  }
}

Open your terminal, navigate to your project directory, and run the following command:

npm test

Jest will run your tests and output the results. You should see something like this:

 PASS  tests/math.test.ts
  ✓ adds two numbers correctly (5 ms)
  ✓ subtracts two numbers correctly (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.732 s
Ran all test suites matching /math.test.ts/i.

Congratulations! You’ve written and run your first unit tests with TypeScript and Jest.

Understanding Jest Matchers

Jest provides a rich set of matchers that you can use to make assertions in your tests. Matchers allow you to express different kinds of expectations about the values you’re testing.

Here are some of the most commonly used matchers:

  • toBe(value): Checks if the value is equal to the expected value.
  • toEqual(value): Checks if two objects or arrays have the same properties or elements.
  • toBeNull(): Checks if the value is null.
  • toBeUndefined(): Checks if the value is undefined.
  • toBeDefined(): Checks if the value is not undefined.
  • toBeTruthy(): Checks if the value is truthy (not false, 0, ”, null, undefined, or NaN).
  • toBeFalsy(): Checks if the value is falsy (false, 0, ”, null, undefined, or NaN).
  • toContain(item): Checks if an array contains a specific item.
  • toMatch(regexp): Checks if a string matches a regular expression.
  • toThrow(error?): Checks if a function throws an error.

Let’s see some of these matchers in action. Modify your math.test.ts file to include more tests:

import { add, subtract } from '../src/math';

test('adds two numbers correctly', () => {
  expect(add(1, 2)).toBe(3);
});

test('subtracts two numbers correctly', () => {
  expect(subtract(5, 2)).toBe(3);
});

test('returns null if input is null', () => {
  // Assuming add function handles null inputs appropriately
  expect(add(null as any, 2)).toBe(2); // Using 'as any' to bypass type checking
});

test('returns undefined if input is undefined', () => {
    expect(add(undefined as any, 2)).toBe(NaN);
});

test('handles string inputs correctly', () => {
    expect(add("1", "2")).toBe("12");
});

In this example, we’ve added tests to check for null and undefined inputs (though it’s important to consider how your functions should handle these inputs). We’ve also added a test for string inputs, showing how the add function handles different datatypes.

Run your tests again using npm test to see the results.

Testing Asynchronous Code

In real-world applications, you’ll often work with asynchronous code, such as API calls or promises. Jest provides several ways to test asynchronous code.

Let’s create a simple asynchronous function and write tests for it. Create a new file named async.ts inside the src directory. Add the following code:

export async function fetchData(url: string): Promise {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Data from ${url}`);
    }, 100);
  });
}

This function simulates fetching data from a URL using a setTimeout to mimic an asynchronous operation. Now, let’s write tests for this function. Create a new file named async.test.ts inside the tests directory. Add the following code:

import { fetchData } from '../src/async';

test('fetches data successfully', async () => {
  const data = await fetchData('https://example.com');
  expect(data).toBe('Data from https://example.com');
});

test('fetches data with .then()', () => {
  return fetchData('https://example.com').then(data => {
    expect(data).toBe('Data from https://example.com');
  });
});

Here’s what’s happening:

  • We import the fetchData function.
  • We use the async/await syntax to handle the asynchronous operation in the first test. We mark the test function as async and use await to wait for the promise to resolve.
  • In the second test, we use the .then() method to handle the promise.

Run your tests again to see the results. Both tests should pass.

Mocking Dependencies

When testing, you often need to isolate the unit of code you’re testing from its dependencies. Mocking is the process of replacing dependencies with controlled substitutes. This allows you to control the behavior of the dependencies and test how your code interacts with them. Jest provides powerful mocking capabilities.

Let’s say our fetchData function (from the previous section) makes an actual API call. We don’t want to make a real API call during our unit tests, so we can mock the fetch function.

First, modify the async.ts file to use the built-in fetch API:

export async function fetchData(url: string): Promise {
  const response = await fetch(url);
  const data = await response.text();
  return data;
}

Now, let’s mock the fetch function in our test file (async.test.ts):

import { fetchData } from '../src/async';

// Mock the fetch function
global.fetch = jest.fn().mockImplementation(() =>
  Promise.resolve({ text: () => Promise.resolve('Mocked data') })
) as jest.Mock;

test('fetches data successfully with a mock', async () => {
  const data = await fetchData('https://example.com');
  expect(data).toBe('Mocked data');
  expect(global.fetch).toHaveBeenCalledWith('https://example.com');
  expect(global.fetch).toHaveBeenCalledTimes(1);
});

Here’s what’s happening:

  • We mock the global fetch function using jest.fn().
  • We use mockImplementation to define the behavior of the mock. In this case, we’re returning a promise that resolves with the mocked data.
  • We use toHaveBeenCalledWith and toHaveBeenCalledTimes to verify that the mock was called with the correct arguments and the correct number of times.

By mocking the fetch function, we can control the response and ensure that our test is isolated and doesn’t rely on external resources.

Testing Components and Modules

As your application grows, you’ll likely have more complex components and modules. Jest allows you to test these more complex units of code.

Let’s create a simple component that displays a message. Create a new file named message.ts inside the src directory. Add the following code:

export function Message(message: string): string {
  return `<p>${message}</p>`;
}

Now, let’s write a unit test for this component. Create a new file named message.test.ts inside the tests directory. Add the following code:

import { Message } from '../src/message';

test('renders a message correctly', () => {
  const message = Message('Hello, world!');
  expect(message).toBe('<p>Hello, world!</p>');
});

This test simply checks if the Message component renders the correct HTML output.

For more complex components that interact with the DOM, you might need to use a testing library like @testing-library/dom to simulate user interactions and assert on the rendered output.

Common Mistakes and How to Fix Them

Here are some common mistakes developers make when writing unit tests and how to fix them:

  • Not writing enough tests: It’s easy to skip writing tests, especially when you’re under pressure to deliver features quickly. However, not writing enough tests can lead to bugs and make it harder to maintain your code. Make sure to write tests for all critical functionality and edge cases.
  • Writing tests that are too complex: Unit tests should be small and focused. If a test becomes too complex, it’s a sign that you might be testing too much in a single test or that your code needs to be refactored. Break down complex tests into smaller, more manageable tests.
  • Not mocking dependencies: Failing to mock dependencies can make your tests slow, unreliable, and dependent on external resources. Always mock dependencies to isolate your unit of code and control its behavior.
  • Writing tests that are tightly coupled to the implementation: Tests should focus on the behavior of your code, not on its internal implementation details. If you change the implementation, your tests should still pass, as long as the behavior remains the same.
  • Ignoring test failures: Don’t ignore failing tests. Investigate the cause of the failure and fix it promptly. Failing tests are a sign that something is wrong with your code or your tests.

Key Takeaways

  • Unit testing is essential for writing clean, maintainable, and bug-free code.
  • Jest is a powerful and easy-to-use testing framework for JavaScript and TypeScript.
  • Use matchers to make assertions about the values you’re testing.
  • Test asynchronous code using async/await or .then().
  • Mock dependencies to isolate your unit of code and control its behavior.

FAQ

Q: What is unit testing?

A: Unit testing is a software testing method where individual units or components of a software are tested. The purpose is to validate that each unit of the software performs as designed.

Q: Why should I use unit testing?

A: Unit testing helps you catch bugs early, ensure code quality, refactor with confidence, and improve collaboration.

Q: What is Jest?

A: Jest is a delightful JavaScript testing framework with a focus on simplicity. It is maintained by Facebook and is widely used for testing JavaScript and TypeScript code.

Q: How do I mock dependencies in Jest?

A: You can mock dependencies using jest.fn() and mockImplementation() to control their behavior and isolate your unit of code.

Q: What are some common mistakes to avoid when writing unit tests?

A: Some common mistakes include not writing enough tests, writing tests that are too complex, not mocking dependencies, writing tests that are tightly coupled to the implementation, and ignoring test failures.

Unit testing is a critical practice for any serious software developer. By writing thorough unit tests, you can significantly improve the quality, maintainability, and reliability of your code. It might seem like an extra step at first, but the time you invest in writing tests will pay off in the long run. You’ll spend less time debugging, be able to refactor your code with greater confidence, and ultimately, deliver better software. Embrace unit testing, and watch your coding skills and your projects thrive.