Node.js API test projects come in many shapes: Jest with Supertest for full-stack projects, Mocha with Axios for service integration tests, Vitest with the Fetch API for modern setups. This lesson covers the migration for the most common configuration — Mocha with Axios — and then touches on Jest with Supertest and how typed API clients transform the testing experience. The patterns transfer to any combination.
The starting codebase
A typical JavaScript API test project before migration:
// test/users.test.js
const axios = require('axios');
const { expect } = require('chai');
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
describe('User API', () => {
it('should create a user', async () => {
const response = await axios.post(`${BASE_URL}/api/users`, {
name: 'Alice',
email: 'alice@test.com',
password: 'pass123',
});
expect(response.status).to.equal(201);
expect(response.data.id).to.be.a('number');
expect(response.data.eamil).to.equal('alice@test.com'); // typo — caught at runtime only
});
});That last line has a typo: eamil instead of email. JavaScript and Axios pass it through silently. The assertion fails with "expected undefined to equal 'alice@test.com'" — and finding the typo takes longer than it should.
Step 1: Install TypeScript dependencies
npm install --save-dev typescript @types/node @types/mocha @types/chai ts-nodeAxios ships its own types since v0.21 — no @types/axios needed.
Step 2: Create tsconfig.json for API tests
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": false,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "mocha", "chai"]
},
"include": ["test/**/*.ts", "src/**/*.ts"]
}"types": ["node", "mocha", "chai"] restricts auto-loaded type packages. Without this restriction, both Mocha's and Jest's describe and it globals can co-exist in the same project and produce confusing type conflicts.
Step 3: Configure Mocha to run TypeScript
// .mocharc.json
{
"require": ["ts-node/register"],
"extensions": ["ts"],
"spec": ["test/**/*.test.ts"],
"timeout": 10000
}ts-node/register is a Node.js module loader hook that compiles TypeScript on the fly. With this in place, npm test runs .ts files directly without a separate compile step.
For Jest projects, use ts-jest instead (covered in the Chapter 2 tooling lesson).
Step 4: Define interfaces for API shapes
The highest-value step in API test migration. Every response you receive and every request you send should have an interface:
// test/types.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'tester' | 'viewer';
createdAt: string;
}
export interface CreateUserRequest {
name: string;
email: string;
password: string;
role?: 'admin' | 'tester' | 'viewer';
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}Step 5: Migrate test files
git mv test/users.test.js test/users.test.ts// test/users.test.ts
import axios from 'axios';
import { expect } from 'chai';
import type { User, CreateUserRequest } from './types';
const BASE_URL = process.env.API_BASE_URL ?? 'http://localhost:3000';
describe('User API', () => {
let createdUserId: number;
it('should create a user', async () => {
const payload: CreateUserRequest = {
name: 'Alice',
email: 'alice@test.com',
password: 'pass123',
};
const response = await axios.post<User>(`${BASE_URL}/api/users`, payload);
expect(response.status).to.equal(201);
expect(response.data.id).to.be.a('number');
expect(response.data.email).to.equal('alice@test.com');
// response.data.eamil — compile error: Property 'eamil' does not exist on type 'User'
createdUserId = response.data.id;
});
it('should get a user by ID', async () => {
const response = await axios.get<User>(`${BASE_URL}/api/users/${createdUserId}`);
expect(response.status).to.equal(200);
expect(response.data.role).to.be.oneOf(['admin', 'tester', 'viewer']);
});
it('should reject unknown roles', async () => {
const payload = { name: 'Bob', email: 'bob@test.com', password: 'pass', role: 'superadmin' };
// TypeScript catches this before the test runs:
// Type '"superadmin"' is not assignable to type '"admin" | "tester" | "viewer" | undefined'
});
});The generic axios.post<User>() tells TypeScript that response.data is of type User. The typo response.data.eamil is now a compile error — caught in the editor, not in CI.
Step 6: Build a typed API client
Centralising API calls in a typed client class eliminates repetition and gives every test consistent type information:
// test/api-client.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import type { User, CreateUserRequest, PaginatedResponse } from './types';
export class ApiClient {
private readonly http: AxiosInstance;
constructor(baseURL: string, token?: string) {
this.http = axios.create({
baseURL,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
async createUser(data: CreateUserRequest): Promise<User> {
const response: AxiosResponse<User> = await this.http.post('/api/users', data);
return response.data;
}
async getUser(id: number): Promise<User> {
const response: AxiosResponse<User> = await this.http.get(`/api/users/${id}`);
return response.data;
}
async listUsers(page = 1, pageSize = 20): Promise<PaginatedResponse<User>> {
const response: AxiosResponse<PaginatedResponse<User>> = await this.http.get('/api/users', {
params: { page, pageSize },
});
return response.data;
}
async deleteUser(id: number): Promise<void> {
await this.http.delete(`/api/users/${id}`);
}
}Tests using this client are clean and fully typed:
import { ApiClient } from './api-client';
const client = new ApiClient(process.env.API_BASE_URL ?? 'http://localhost:3000');
describe('User lifecycle', () => {
it('creates, reads, and deletes a user', async () => {
const user = await client.createUser({ name: 'Alice', email: 'alice@test.com', password: 'pass' });
expect(user.id).to.be.a('number');
// user.eamil — compile error immediately
const fetched = await client.getUser(user.id);
expect(fetched.email).to.equal('alice@test.com');
await client.deleteUser(user.id);
});
});API tests — JavaScript vs TypeScript
JavaScript API tests
axios.post() returns response.data: any
response.data.eamil — undefined, caught at runtime
Wrong request body field: 400 in CI
Pagination shape: guessed from memory
Refactor API shape: grep all test files
TypeScript API tests
axios.post<User>() returns response.data: User
response.data.eamil — compile error in editor
Wrong request body field: compile error
Pagination shape: typed as PaginatedResponse<User>
Refactor API shape: compiler finds every usage
Supertest with TypeScript
If the project tests an Express app directly via Supertest:
npm install --save-dev @types/supertest// test/app.test.ts
import request from 'supertest';
import { app } from '../src/app';
import type { User } from './types';
describe('GET /api/users', () => {
it('returns a list of users', async () => {
const response = await request(app).get('/api/users').expect(200);
const users = response.body as User[];
expect(users).to.be.an('array');
expect(users[0].email).to.be.a('string');
});
});response.body from Supertest is typed as any — the as User[] cast is appropriate here because Supertest can't know your API's schema. For stronger guarantees, run the result through a Zod schema instead.
⚠️ Common mistakes
- Not typing the Axios generic.
axios.post('/api/users', data)returnsAxiosResponse<any>— using the generic (axios.post<User>(...)) is what gives you type safety onresponse.data. - Using
requirein converted.tsfiles.const axios = require('axios')returnsany. Convert toimport axios from 'axios'so TypeScript resolves the module's types. - Typing fixture data in test files instead of a shared types file. Inline interface definitions that duplicate across three test files will drift. Define all API interfaces in one
test/types.tsand import them wherever needed.
🎯 Practice task
Migrate a Node.js API test file and build a typed API client.
- Install TypeScript, the relevant
@typespackages, andts-node. - Create
tsconfig.jsonand.mocharc.json(orjest.config.jsfor Jest). - Define interfaces for the two or three API shapes your tests touch most — at minimum a
Userand aCreateUserRequest. - Rename one test file to
.ts. Replacerequirecalls withimport. Add the generic type to every Axios call. - Build a minimal
ApiClientclass with one method per API endpoint your tests use. - Rewrite the test using the
ApiClientinstead of raw Axios calls. - Stretch: find one place where
response.datais accessed without a generic on the Axios call. Deliberately introduce a typo in the property access (e.g.,response.data.emial). Confirm that adding the generic (axios.get<User>(...)) turns the typo from a runtime surprise into a compile error.