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-cliDocker (no installation required):
docker run --rm openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./generated-clientGenerating 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">trueThe 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 installWrite 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-clientInstall 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 pushAlternatively, 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 generateor 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.