DEV Community

Cover image for How to Test a Node.js and TypeScript API with Vitest and Supertest
Felicia Udosen
Felicia Udosen

Posted on Originally published at liciacodes.hashnode.dev

How to Test a Node.js and TypeScript API with Vitest and Supertest

Introduction

When building an application, it's easy to verify that something works by running the app and testing it manually. You create a transaction, click a button, send a request through Postman, and check whether you get the result you expected.

That works, but as an application grows, manually checking every feature after every change becomes difficult. A change that fixes one part of the application can unexpectedly break another.

This is where automated testing becomes useful. Instead of repeatedly checking the same behavior yourself, you can write tests that describe what your code is expected to do and run those tests whenever the code changes.

In this tutorial, we'll look at the basics of testing a Node.js and TypeScript API using Vitest and Supertest. We'll start with a simple function, learn how assertions work, and then move on to testing an Express endpoint.

By the end of this tutorial, you'll understand how to write basic unit and API tests, how to think about which cases are worth testing, and how to run your tests as part of your development workflow.

What is Automated Testing

Automated testing is the practice of using software tools and scripts to execute test cases automatically. Instead of manually checking whether a piece of code works as expected, you write tests that run the code and compare the actual output with the expected result.

For example, if you have a function that adds two numbers, you might expect add(2, 3) to return 5. An automated test can run the function and verify that the result is actually 5.

expect(add(2, 3)).toBe(5);
Enter fullscreen mode Exit fullscreen mode

If the function returns 5, the test passes. If it returns something else, the test fails.

This allows you to validate that your code behaves as expected and quickly identify when a change breaks existing functionality.

Different Types of Automated Tests

Automated tests can be grouped into different types depending on what part of an application you want to test. Three common types are unit tests, integration tests, and end-to-end tests.

Unit Testing

Unit testing focuses on testing a small piece of an application in isolation. This could be a function, method, or another small piece of business logic.

For example, consider a function that checks whether a transaction has a settlement mismatch:

function hasSettlementMismatch(
  expected: number,
  actual: number,
): boolean {
  return expected !== actual;
}
Enter fullscreen mode Exit fullscreen mode

We can test the function directly:

expect(hasSettlementMismatch(50000, 40000)).toBe(true);
expect(hasSettlementMismatch(50000, 50000)).toBe(false);
Enter fullscreen mode Exit fullscreen mode

Here, we're checking two behaviors: different settlement amounts should produce a mismatch, while matching amounts should not.

Integration Testing

Integration testing is the process of testing multiple units or modules together to make sure they interact and work correctly as a group.

For example, in an API, a request might pass through a route, controller, service, and database layer. Each part may work correctly on its own, but an integration test can verify that they work correctly when connected.

For example, we might send a request to create a transaction:

POST /transactions
Enter fullscreen mode Exit fullscreen mode

and verify that the API returns the expected response:

expect(response.status).toBe(201);
expect(response.body).toHaveProperty("reference");
Enter fullscreen mode Exit fullscreen mode

Later in this tutorial, we'll use Supertest to see how we can test this kind of API behavior.

End-to-End Testing

End-to-end (E2E) testing checks an entire application flow from start to finish. Instead of testing an individual function or checking how a few modules work together, an E2E test simulates how a real user interacts with the application.

For example, in a transaction application, an E2E test could simulate a user:

  1. Opening the application.
  2. Creating a transaction.
  3. Viewing the transaction on the dashboard.
  4. Opening the transaction details.
  5. Confirming that the correct information is displayed.

Tools such as Playwright and Cypress are commonly used for E2E testing. We won't be using them in this tutorial because our focus will be on unit and API testing with Vitest and Supertest.

Setting up the project

Before we start writing tests, let's create a small Node.js and TypeScript API that we can use throughout the tutorial.

Create a new directory and initialize a Node.js project:

mkdir testing-api
cd testing-api
npm init -y
Enter fullscreen mode Exit fullscreen mode

Next, open package.json and add "type": "module" to configure the project to use ES modules:

{
  "name": "testing-api",
  "version": "1.0.0",
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

This allows us to use ES module syntax such as import and export in our TypeScript files.

Next, install Express:

npm install express
Enter fullscreen mode Exit fullscreen mode

Then install TypeScript and the type definitions we'll need:

npm install -D typescript tsx @types/node @types/express
Enter fullscreen mode Exit fullscreen mode

Initialize TypeScript:

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

For this tutorial, we'll keep our project structure simple:

testing-api/
├── src/
│   ├── app.ts
│   └── server.ts
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Create src/app.ts:

import express from "express";

const app = express();

app.use(express.json());

app.get("/health", (_req, res) => {
  res.status(200).json({ status: "ok" });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

Then create src/server.ts:

import app from "./app";

const PORT = 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Notice that the Express application and the code that starts the server are in separate files. This will become useful when we start testing our API because our tests can import app without starting another server every time the test suite runs.

Add a development script to package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

You can now start the API with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Visiting http://localhost:3000/health should return:

{
  "status": "ok"
}
Enter fullscreen mode Exit fullscreen mode

Now we have something to test.

Installing Vitest

What is Vitest?

Vitest is a testing framework powered by Vite. It provides the tools you need to write, run, and organize automated tests in JavaScript and TypeScript applications.

We use a testing framework because it makes writing and running tests easier. Instead of building our own system for executing tests, comparing results, handling failures, and reporting which tests passed or failed, a framework like Vitest provides these features for us.

It also gives us utilities such as test() and expect(), which help us define tests and make assertions about how our code should behave.

Vitest works particularly well with TypeScript, making it a good choice for the API we'll be testing.

Now install Vitest as a development dependency:

npm install -D vitest
Enter fullscreen mode Exit fullscreen mode

And add this to package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "test": "vitest",
    "test:run": "vitest run"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm test
Enter fullscreen mode Exit fullscreen mode

runs Vitest in watch mode during development, while:

npm run test:run
Enter fullscreen mode Exit fullscreen mode

runs the test suite once and exits.

Writing Your First Test

Now that Vitest is installed, let's write a simple test to see how it works.

Create a file called add.ts inside the src folder:

export function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

This function takes two numbers and returns their sum. There's nothing complicated happening here, which makes it a useful example for learning the structure of a test.

Next, create a file called add.test.ts inside the src folder.

src/
├── add.ts
├── add.test.ts
├── app.ts
└── server.ts
Enter fullscreen mode Exit fullscreen mode

Now, add the following test to add.test.ts:

import { expect, test } from "vitest";
import { add } from "./add";

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this test.

The test() function defines a test. Its first argument, "adds two numbers", describes the behavior we're testing, while the function passed as the second argument contains the code for the test.

Inside the test, expect() receives the actual value we want to check. In this case, add(2, 3) returns 5.

The toBe() function is a matcher that tells Vitest what we expect the result to be. So expect(add(2, 3)).toBe(5) checks that calling add(2, 3) actually returns 5.

Run the test with:

npm test
Enter fullscreen mode Exit fullscreen mode

At this point, Vitest should find add.test.ts and run the test. If add(2, 3) returns 5, the test passes.

What Happens When a Test Fails?

So far, our test passes because add(2, 3) returns 5, which is exactly what we expected.

But one of the useful things about automated testing is seeing what happens when the actual result doesn't match our expectation.

Temporarily change the test to expect the wrong value:

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(6);
});
Enter fullscreen mode Exit fullscreen mode

Run the test again:

npm test
Enter fullscreen mode Exit fullscreen mode

This time, the test should fail because add(2, 3) returns 5, but we told Vitest that we expected 6.

Vitest will show us the failed test and the difference between the expected and received values. This makes it easier to identify which behavior isn't producing the result we expected.

Change 6 back to 5 before continuing:

expect(add(2, 3)).toBe(5);
Enter fullscreen mode Exit fullscreen mode

Understanding Arrange, Act, Assert

A common way to structure tests is the Arrange, Act, Assert pattern.

It breaks a test into three parts:

  • Arrange: Set up the values or conditions needed for the test.
  • Act: Run the code you want to test.
  • Assert: Check whether the result is what you expected.

We can rewrite our add() test to make these steps easier to see:

test("adds two numbers", () => {
  // Arrange
  const a = 2;
  const b = 3;

  // Act
  const result = add(a, b);

  // Assert
  expect(result).toBe(5);
});
Enter fullscreen mode Exit fullscreen mode

The test does exactly the same thing as before, but separating the steps makes the structure clearer. This pattern becomes more useful as our tests get more complicated.

Testing an Express API with Supertest

So far, we've tested a function directly using Vitest. But when building an API, we also want to test how our application responds to HTTP requests.

This is where Supertest comes in.

Supertest is a library that allows us to send HTTP requests to our application during our tests and inspect the responses. This means we can test our API endpoints without manually sending requests through tools like Postman.

For example, earlier we created a /health endpoint that returns:

{
  "status": "ok"
}
Enter fullscreen mode Exit fullscreen mode

We can use Supertest to send a GET request to this endpoint and verify that the API returns the expected status code and response body.

Installing Supertest

Install Supertest and its TypeScript types:

npm install -D supertest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Testing the /health Endpoint

Create a new file called app.test.ts inside src. Your structure should now look like:

src/
├── add.ts
├── add.test.ts
├── app.ts
├── app.test.ts
└── server.ts
Enter fullscreen mode Exit fullscreen mode

Add this to app.test.ts:

import request from "supertest";
import { expect, test } from "vitest";
import app from "./app";

test("GET /health returns the API status", async () => {
  const response = await request(app).get("/health");

  expect(response.status).toBe(200);
  expect(response.body).toEqual({ status: "ok" });
});
Enter fullscreen mode Exit fullscreen mode

Then run:

npm test
Enter fullscreen mode Exit fullscreen mode

At this point, Vitest should run both add.test.ts and app.test.ts.

Understanding the API Test

Let's break down what's happening in this test.

const response = await request(app).get("/health");
Enter fullscreen mode Exit fullscreen mode

request(app) passes our Express application to Supertest, while .get("/health") sends a GET request to the /health endpoint. Because the request is asynchronous, we use await to wait for the response before continuing.

The response is stored in the response variable. We can then check its status code and body:

expect(response.status).toBe(200);
expect(response.body).toEqual({ status: "ok" });
Enter fullscreen mode Exit fullscreen mode

The first assertion checks that the API returned a 200 status code.

The second assertion checks that the response body contains the object { status: "ok" }. We use toEqual() instead of toBe() because we're comparing the contents of an object.

What Should You Test?

When you're new to testing, it can be tempting to try to test everything. A better place to start is with behavior that matters to your application.

Good candidates for tests include business logic, API responses, validation, error cases, and behavior that could easily break when the code changes.

You don't need to test every line of code. Focus on whether the important parts of your application behave the way you expect.

Conclusion

In this tutorial, we started with a simple unit test using Vitest and then used Supertest to test an Express endpoint.

We learned how test(), expect(), and matchers work, how to structure tests using Arrange, Act, Assert, and how Supertest can send requests to an Express application during a test.

This is only the beginning of automated testing. As your applications grow, you can build on these concepts by testing more complex business logic, validation, database interactions, and different API responses.

The important part is to start small. Pick behavior that matters, describe what you expect it to do, and write a test that verifies it.

Top comments (0)