Postman is fine. Collections sync, environments are manageable, the UI is polished. But I keep running into the same friction: sharing state between requests is clunky, logic is buried in pre-request scripts written in a janky sandbox, collections aren’t great in version control, and the free tier keeps getting worse.
Meanwhile, bun test is sitting right there — fast, TypeScript-native, zero config, with a real JS runtime where you can do anything.
Here’s how I use it as my API client for everything from local development smoke tests to full contract validation against an OpenAPI spec.
The basic setup
Create a tests/api/ directory in your project. This keeps API tests separate from unit tests and lets you run them independently.
tests/
api/
auth.test.ts
users.test.ts
posts.test.ts
helpers.ts
api.yaml # your OpenAPI spec (optional but great)
A minimal test that hits an endpoint:
// tests/api/users.test.ts
import { describe, it, expect } from "bun:test";
const BASE = process.env.API_URL ?? "http://localhost:3000";
describe("GET /users", () => {
it("returns a list of users", async () => {
const res = await fetch(`${BASE}/users`);
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(0);
});
it("returns users with the expected shape", async () => {
const res = await fetch(`${BASE}/users`);
const [user] = await res.json();
expect(user).toHaveProperty("id");
expect(user).toHaveProperty("email");
expect(typeof user.id).toBe("number");
expect(typeof user.email).toBe("string");
});
});
Run it:
bun test tests/api/
# or against staging:
API_URL=https://api-staging.example.com bun test tests/api/
That’s the entire setup. No config files, no plugins, no GUI.
Shared helpers: the thing Postman gets wrong
In Postman, sharing auth tokens between requests means pre-request scripts and environment variables with a manual dance. In a real test file, it’s just a function:
// tests/api/helpers.ts
const BASE = process.env.API_URL ?? "http://localhost:3000";
export async function login(email: string, password: string) {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
const { token } = await res.json();
return token;
}
export function authHeaders(token: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
}
export function api(path: string, token?: string, init?: RequestInit) {
return fetch(`${BASE}${path}`, {
...init,
headers: {
...(token ? authHeaders(token) : { "Content-Type": "application/json" }),
...init?.headers,
},
});
}
Now authenticated tests are trivial:
// tests/api/posts.test.ts
import { describe, it, expect, beforeAll } from "bun:test";
import { login, api } from "./helpers";
let token: string;
beforeAll(async () => {
token = await login("test@example.com", "password");
});
describe("POST /posts", () => {
it("creates a post", async () => {
const res = await api("/posts", token, {
method: "POST",
body: JSON.stringify({ title: "Hello", body: "World" }),
});
expect(res.status).toBe(201);
const post = await res.json();
expect(post.id).toBeDefined();
expect(post.title).toBe("Hello");
});
});
The beforeAll block runs once per describe. The token is shared across all tests in that file. No environment variable juggling, no collection runner configuration.
Chaining requests: real workflows
The place Postman really falls apart is multi-step workflows. In bun test, you just… write code:
describe("order flow", () => {
it("creates, pays, and confirms an order", async () => {
// Step 1: create order
const createRes = await api("/orders", token, {
method: "POST",
body: JSON.stringify({ items: [{ sku: "WIDGET-1", qty: 2 }] }),
});
expect(createRes.status).toBe(201);
const { id: orderId } = await createRes.json();
// Step 2: pay
const payRes = await api(`/orders/${orderId}/pay`, token, {
method: "POST",
body: JSON.stringify({ method: "card", token: "tok_test" }),
});
expect(payRes.status).toBe(200);
// Step 3: confirm order is now in paid state
const getRes = await api(`/orders/${orderId}`, token);
const order = await getRes.json();
expect(order.status).toBe("paid");
});
});
Three requests, shared state, readable from top to bottom. No “test runner” tab, no chained environment variables. Just TypeScript.
Environments via .env files
Different targets (local, staging, production) are just different env files:
# .env.local
API_URL=http://localhost:3000
TEST_USER_EMAIL=test@localhost
TEST_USER_PASSWORD=devpassword
# .env.staging
API_URL=https://api-staging.example.com
TEST_USER_EMAIL=ci@example.com
TEST_USER_PASSWORD=...
Bun loads .env automatically. For other environments:
# run against staging
API_URL=https://api-staging.example.com \
TEST_USER_EMAIL=ci@example.com \
TEST_USER_PASSWORD=secret \
bun test tests/api/
Or wrap common targets in package.json scripts:
{
"scripts": {
"test:api": "bun test tests/api/",
"test:api:staging": "dotenv -e .env.staging -- bun test tests/api/"
}
}
OpenAPI spec validation
This is where it gets genuinely powerful. If your API ships an openapi.yaml (or api.yaml), you can validate your responses against it — catching schema drift before it causes production bugs.
Install the validator:
bun add -d openapi-fetch openapi-typescript
Generate types from your spec:
bunx openapi-typescript ./tests/api/api.yaml -o ./tests/api/schema.ts
Now your api helper is typed against your actual spec:
// tests/api/helpers.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";
const BASE = process.env.API_URL ?? "http://localhost:3000";
export const client = createClient<paths>({ baseUrl: BASE });
And tests get full autocompletion and compile-time checking:
import { describe, it, expect, beforeAll } from "bun:test";
import { client, login } from "./helpers";
let token: string;
beforeAll(async () => {
token = await login("test@example.com", "password");
});
describe("GET /users/{id}", () => {
it("returns the user", async () => {
const { data, error, response } = await client.GET("/users/{id}", {
params: { path: { id: 1 } },
headers: { Authorization: `Bearer ${token}` },
});
expect(response.status).toBe(200);
expect(error).toBeUndefined();
// `data` is typed as the response schema from api.yaml
expect(data?.email).toBeDefined();
});
});
TypeScript will error at compile time if you pass the wrong parameter types, use a path that doesn’t exist in the spec, or try to access a response field that isn’t in the schema. Your tests become a live contract check.
Add the type generation step to CI:
# .github/workflows/api-tests.yml
- name: Generate types from spec
run: bunx openapi-typescript ./tests/api/api.yaml -o ./tests/api/schema.ts
- name: Run API tests
run: bun test tests/api/
env:
API_URL: ${{ secrets.STAGING_API_URL }}
If the spec changes in a way that breaks existing tests, CI fails. If the server drifts from the spec, tests fail. Both directions are covered.
Dynamic tests driven by your OpenAPI spec
Once you have the spec loaded, you can go further: parse it at runtime to generate tests automatically from the parameter definitions. Instead of handwriting a test case per scenario, you let the spec tell you what cases to cover.
A minimal example using js-yaml to read your spec:
bun add -d js-yaml
bun add -d @types/js-yaml
// tests/api/spec-driven.test.ts
import { describe, it, expect } from "bun:test";
import { load } from "js-yaml";
import { readFileSync } from "fs";
const spec = load(readFileSync("./tests/api/api.yaml", "utf8")) as any;
const BASE = process.env.API_URL ?? "http://localhost:3000";
Testing every required query param permutation
Say your spec defines /search with required q and optional limit and sort:
# api.yaml (excerpt)
/search:
get:
parameters:
- name: q
in: query
required: true
schema:
type: string
- name: limit
in: query
schema:
type: integer
enum: [10, 25, 50]
- name: sort
in: query
schema:
type: string
enum: [asc, desc]
Pull the enum values out of the spec and generate a test per combination:
const params = spec.paths["/search"].get.parameters;
const limitEnum: number[] =
params.find((p: any) => p.name === "limit")?.schema?.enum ?? [];
const sortEnum: string[] =
params.find((p: any) => p.name === "sort")?.schema?.enum ?? [];
describe("GET /search — all sort/limit combinations", () => {
for (const limit of limitEnum) {
for (const sort of sortEnum) {
it(`limit=${limit} sort=${sort} returns 200`, async () => {
const url = new URL(`${BASE}/search`);
url.searchParams.set("q", "test");
url.searchParams.set("limit", String(limit));
url.searchParams.set("sort", sort);
const res = await fetch(url.toString());
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body.results)).toBe(true);
expect(body.results.length).toBeLessThanOrEqual(limit);
});
}
}
});
That’s 6 test cases (3 limits × 2 sorts) generated from 0 lines of manual case definition. Add a new enum value to the spec, the test suite expands automatically.
Testing required vs optional body fields
The same approach works for request body schemas. If your spec says a field is required, test that omitting it returns a 422. If it’s optional, test both branches:
# api.yaml (excerpt)
/posts:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
tags:
type: array
items:
type: string
const postSchema =
spec.paths["/posts"].post.requestBody.content["application/json"].schema;
const requiredFields: string[] = postSchema.required ?? [];
const allFields = Object.keys(postSchema.properties);
const optionalFields = allFields.filter((f) => !requiredFields.includes(f));
const validBase = { title: "Hello", body: "World" };
describe("POST /posts — required field validation", () => {
for (const field of requiredFields) {
it(`returns 422 when '${field}' is missing`, async () => {
const payload = { ...validBase, [field]: undefined };
const res = await fetch(`${BASE}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
expect(res.status).toBe(422);
});
}
for (const field of optionalFields) {
it(`returns 201 when optional '${field}' is omitted`, async () => {
const { [field]: _, ...payload } = validBase as any;
const res = await fetch(`${BASE}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
expect(res.status).toBe(201);
});
}
});
Smoke-testing every GET endpoint in the spec
If you just want a baseline “does everything respond” check across the whole spec, iterate over every path:
describe("smoke — all GET endpoints return non-500", () => {
const paths = spec.paths as Record<string, any>;
for (const [path, methods] of Object.entries(paths)) {
if (!methods.get) continue;
// skip paths with required path params for now
if (path.includes("{")) continue;
it(`GET ${path}`, async () => {
const res = await fetch(`${BASE}${path}`);
expect(res.status).toBeLessThan(500);
});
}
});
This isn’t a replacement for specific tests, but it’s a useful canary. Any endpoint that starts 500ing shows up immediately, with no maintenance overhead.
The pattern in all three cases is the same: your spec is the source of truth, the test file just reads it. When the spec changes, the tests change with it.
Snapshot testing for response bodies
For endpoints where you want to catch unexpected changes in the response shape:
it("matches the snapshot", async () => {
const res = await api("/config/features");
const body = await res.json();
expect(body).toMatchSnapshot();
});
First run creates the snapshot. Subsequent runs fail if the response changes. Good for configuration endpoints, feature flags, or any response where unexpected changes are bugs.
Running specific tests during development
The full suite against a real server can be slow. During development, run just what you’re working on:
# one file
bun test tests/api/users.test.ts
# one describe block by name
bun test --test-name-pattern "POST /posts"
# watch mode (reruns on file save)
bun test --watch tests/api/
bun run api — a Postman-style CLI from package.json
Tests are great for assertions, but sometimes you just want to fire off a one-shot request and see the response — exactly like clicking “Send” in Postman. You can wire that up as a bun run command with a small script.
The request script
// scripts/api.ts
const BASE = process.env.API_URL ?? "http://localhost:3000";
const [, , rawUrl = "/", method = "GET", ...rest] = process.argv;
// support both full URLs and paths
const url = rawUrl.startsWith("http") ? rawUrl : `${BASE}${rawUrl}`;
const upperMethod = method.toUpperCase();
// optional: pass a JSON body as the last arg
let body: string | undefined;
let headers: Record<string, string> = { "Content-Type": "application/json" };
if (rest.length > 0) {
try {
body = JSON.stringify(JSON.parse(rest.join(" ")));
} catch {
console.error("⚠️ Could not parse body as JSON:", rest.join(" "));
process.exit(1);
}
}
if (process.env.API_TOKEN) {
headers["Authorization"] = `Bearer ${process.env.API_TOKEN}`;
}
console.log(`→ ${upperMethod} ${url}\n`);
const res = await fetch(url, { method: upperMethod, headers, body });
const contentType = res.headers.get("content-type") ?? "";
console.log(`← ${res.status} ${res.statusText}`);
console.log("Headers:", Object.fromEntries(res.headers.entries()), "\n");
if (contentType.includes("application/json")) {
const data = await res.json();
console.log(JSON.stringify(data, null, 2));
} else {
console.log(await res.text());
}
Wire it up in package.json:
{
"scripts": {
"api": "bun run scripts/api.ts"
}
}
Now you can do:
# GET request
bun run api /users
# POST with a body
bun run api /posts POST '{"title":"Hello","body":"World"}'
# PATCH
bun run api /posts/42 PATCH '{"title":"Updated"}'
# DELETE
bun run api /posts/42 DELETE
# against staging with a token
API_URL=https://api-staging.example.com API_TOKEN=mytoken bun run api /users
Named shortcuts for common endpoints
For endpoints you hit constantly, add named scripts:
{
"scripts": {
"api": "bun run scripts/api.ts",
"api:users": "bun run api /users",
"api:me": "bun run api /auth/me",
"api:health": "bun run api /health"
}
}
bun run api:health
bun run api:me
Shell autocomplete for the URL argument
If you want tab-completion for paths directly from your OpenAPI spec, add a small completion script. For zsh, drop this in your .zshrc (or a project-local .zshenv):
# .zshrc
_bun_api_complete() {
local spec="./tests/api/api.yaml"
if [[ -f "$spec" ]]; then
local paths
paths=$(grep -E '^\s{0,2}/[a-zA-Z]' "$spec" | sed 's/://;s/^ *//')
compadd -- $paths
fi
}
compdef _bun_api_complete 'bun run api'
After source ~/.zshrc, typing bun run api /us<TAB> will complete to paths from your spec.
For a more robust solution that handles nested paths and parameters, read the spec with Node and write completions dynamically:
// scripts/completions.ts
import { load } from "js-yaml";
import { readFileSync, writeFileSync } from "fs";
const spec = load(readFileSync("./tests/api/api.yaml", "utf8")) as any;
const paths = Object.keys(spec.paths ?? {});
// emit a zsh completion file
const zsh = `#compdef bun-api
_bun_api_paths() { compadd -- ${paths.map((p) => `'${p}'`).join(" ")} }
compdef _bun_api_paths 'bun run api'
`;
writeFileSync("./.zsh-api-completions", zsh);
console.log("Written to .zsh-api-completions — source it in your .zshrc");
bun run scripts/completions.ts
echo "source $(pwd)/.zsh-api-completions" >> ~/.zshrc
source ~/.zshrc
The script regenerates whenever your spec changes. Pair it with a postinstall hook and completions stay in sync automatically:
{
"scripts": {
"postinstall": "bun run scripts/completions.ts"
}
}
The complete picture
What you end up with:
- Version-controlled — tests live in the repo, reviewed in PRs, change history visible
- Scriptable — anything JavaScript can do, your tests can do
- Typed — full TypeScript, IDE autocomplete, compile-time errors
- Spec-validated — responses checked against your OpenAPI schema
- CI-ready — one command, any environment, no GUI required
- Fast — Bun’s test runner is meaningfully faster than Jest or Vitest for this workload
The total investment is: a helpers.ts file, one .env file per environment, and test files that read like the API documentation you wish you had.
Postman has its place — for quickly prodding an unfamiliar API you don’t own. For everything you’re building and testing yourself, this setup is better in every dimension that matters.