Migrating Node.js API Tests

8 min read

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-node

Axios 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) returns AxiosResponse<any> — using the generic (axios.post<User>(...)) is what gives you type safety on response.data.
  • Using require in converted .ts files. const axios = require('axios') returns any. Convert to import 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.ts and import them wherever needed.

🎯 Practice task

Migrate a Node.js API test file and build a typed API client.

  1. Install TypeScript, the relevant @types packages, and ts-node.
  2. Create tsconfig.json and .mocharc.json (or jest.config.js for Jest).
  3. Define interfaces for the two or three API shapes your tests touch most — at minimum a User and a CreateUserRequest.
  4. Rename one test file to .ts. Replace require calls with import. Add the generic type to every Axios call.
  5. Build a minimal ApiClient class with one method per API endpoint your tests use.
  6. Rewrite the test using the ApiClient instead of raw Axios calls.
  7. Stretch: find one place where response.data is 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.

// tip to track lessons you complete and pick up where you left off across devices.