Generating Typed Test Clients from OpenAPI Specs (TypeScript, Python)

Generating Typed Test Clients from OpenAPI Specs (TypeScript, Python)

Writing API tests by hand means writing a lot of boilerplate: constructing URLs, setting headers, serializing request bodies, parsing responses. When the API changes, you update the tests manually and hope you caught every callsite.

OpenAPI Generator eliminates that boilerplate. Given an OpenAPI spec, it generates a fully typed API client — in TypeScript, Python, Java, Go, or a dozen other languages — with request/response models that match your schema exactly. Your tests call methods like usersApi.createUser(body) instead of constructing raw HTTP requests, and a TypeScript compile error tells you instantly when the API contract changes.

Installing OpenAPI Generator

OpenAPI Generator runs as a JAR file, an npm package, or a Docker image.

npm (recommended for JavaScript/TypeScript projects):

npm install -g @openapitools/openapi-generator-cli

Docker (no installation required):

docker run --rm openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ./generated-client

Generating a TypeScript Client

Generate an Axios-based TypeScript client:

openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ./src/api-client \
  --additional-properties=supportsES6=true,withInterfaces=<span class="hljs-literal">true

The generated output includes:

  • Model interfaces — TypeScript interfaces for every schema in your spec
  • API classes — one class per tag, with methods for each operation
  • Configuration — base URL, auth, interceptors

Example generated code:

// generated/models/User.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "viewer" | "editor";
  createdAt: string;
}

// generated/api/UsersApi.ts
export class UsersApi extends BaseAPI {
  async createUser(createUserRequest: CreateUserRequest): Promise<User> {
    // ... generated HTTP logic
  }

  async getUserById(userId: string): Promise<User> {
    // ... generated HTTP logic
  }

  async listUsers(params?: { page?: number; limit?: number }): Promise<UserList> {
    // ... generated HTTP logic
  }
}

Writing Tests with the Generated TypeScript Client

Install the generated client's dependencies:

cd ./src/api-client
npm install

Write type-safe tests with Jest:

import { UsersApi, Configuration, CreateUserRequest } from "./api-client";

const config = new Configuration({
  basePath: process.env.API_URL || "http://localhost:8000",
  accessToken: process.env.API_TOKEN,
});

const usersApi = new UsersApi(config);

describe("Users API", () => {
  let createdUserId: string;

  it("creates a user with valid data", async () => {
    const body: CreateUserRequest = {
      email: "test@example.com",
      name: "Test User",
      role: "viewer",
    };

    const user = await usersApi.createUser(body);

    expect(user.id).toBeDefined();
    expect(user.email).toBe("test@example.com");
    expect(user.role).toBe("viewer");

    createdUserId = user.id;
  });

  it("retrieves the created user", async () => {
    const user = await usersApi.getUserById(createdUserId);
    expect(user.email).toBe("test@example.com");
  });

  it("rejects invalid email format", async () => {
    await expect(
      usersApi.createUser({
        email: "not-an-email",
        name: "Bad User",
        role: "viewer",
      })
    ).rejects.toMatchObject({ response: { status: 422 } });
  });
});

When the API changes — say, role becomes permissions — TypeScript compilation fails immediately on role: "viewer". The change in the spec propagates to compile-time errors in your tests, not runtime failures in CI.

Generating a Python Client

For Python projects, generate a client with the python generator:

openapi-generator-cli generate \
  -i openapi.yaml \
  -g python \
  -o ./api-client-python \
  --additional-properties=packageName=api_client,projectName=api-client

Install the generated package:

cd api-client-python
pip install -e .

The generated Python client uses type annotations and dataclasses:

from api_client import Configuration, ApiClient
from api_client.api import UsersApi
from api_client.models import CreateUserRequest

config = Configuration(
    host="http://localhost:8000",
    access_token=os.environ["API_TOKEN"],
)

with ApiClient(config) as client:
    users_api = UsersApi(client)
    user = users_api.create_user(
        CreateUserRequest(
            email="test@example.com",
            name="Test User",
            role="viewer",
        )
    )

Writing Tests with the Generated Python Client

Use pytest with the generated client:

import pytest
from api_client import Configuration, ApiClient
from api_client.api import UsersApi
from api_client.models import CreateUserRequest
from api_client.exceptions import ApiException

@pytest.fixture
def users_api():
    config = Configuration(host=os.environ.get("API_URL", "http://localhost:8000"))
    with ApiClient(config) as client:
        yield UsersApi(client)

def test_create_user(users_api):
    user = users_api.create_user(
        CreateUserRequest(email="test@example.com", name="Test", role="viewer")
    )
    assert user.id is not None
    assert user.email == "test@example.com"

def test_get_nonexistent_user(users_api):
    with pytest.raises(ApiException) as exc:
        users_api.get_user_by_id("nonexistent-id")
    assert exc.value.status == 404

def test_invalid_role_rejected(users_api):
    # Python's generated client validates enums before sending
    with pytest.raises(ValueError):
        CreateUserRequest(email="test@example.com", name="Test", role="superuser")

Note the last test: the generated Python client validates enum values client-side before making the HTTP request, giving you a ValueError rather than an API error.

Automating Client Regeneration

Add client generation to your build process so it stays in sync with the spec:

# .github/workflows/generate-client.yml
name: Regenerate API Client
on:
  push:
    paths:
      - "openapi.yaml"
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm install -g @openapitools/openapi-generator-cli
      - run: |
          openapi-generator-cli generate \
            -i openapi.yaml \
            -g typescript-axios \
            -o ./src/api-client
      - name: Commit generated client
        run: |
          git config --global user.email "ci@example.com"
          git config --global user.name "CI Bot"
          git add src/api-client
          git diff --staged --quiet || git commit -m "chore: regenerate API client from updated spec"
          git push

Alternatively, commit the generated client and treat a failing openapi-generator-cli generate diff as a CI failure — the spec changed without regenerating the client.

What to Commit: Generated Code vs. Git-ignored

Teams differ on whether to commit generated code. The tradeoff:

Commit generated code:

  • Clients are immediately available without a build step
  • Diffs show exactly what changed when the spec changes
  • No build tooling required to run tests

Git-ignore generated code:

  • Spec is the single source of truth
  • No merge conflicts in generated files
  • Requires a make generate or similar step before running tests

Either approach works. Committing is simpler for smaller teams; ignoring is cleaner for larger monorepos with many consumers.

The Productivity Gain

The shift from hand-written HTTP clients to generated typed clients is significant in practice:

  • Fewer test bugs — no typos in URL paths or field names; the compiler catches them
  • Faster refactoring — when an endpoint changes, find all callsites with a type error instead of a grep
  • Better IDE support — autocomplete for request bodies and response fields
  • Self-updating tests — regenerate the client, fix compile errors, done

For teams that want behavior tests on top of the typed API layer — verifying user journeys rather than individual endpoints — HelpMeTest provides plain-English test automation that works alongside code-level tests. The two layers are complementary: generated clients give you type-safe unit and integration tests; scenario tests cover the flows users actually experience.

Read more