Postman is fine. Collections sync, environments are manageable, and the UI is polished. But I keep hitting 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. It’s fast, TypeScript-native, needs no config, and gives you a real JS runtime where you can do anything.
I use it as my API client for everything from local 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, with no config files, plugins, or GUI.
Shared helpers
In Postman, sharing an auth token between requests means juggling pre-request scripts and environment variables by hand. In a test file it’s 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, and the token is shared across every test in the file. There’s no environment variable juggling and no collection runner to configure.
Chaining requests: real workflows
Multi-step workflows are where Postman really falls apart. 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");
});
});
That’s three requests with shared state, readable from top to bottom in plain TypeScript, without a “test runner” tab or chained environment variables.
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
If your API ships an openapi.yaml (or api.yaml), you can validate responses against it and catch 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 errors at compile time if you pass the wrong parameter types, use a path that isn’t in the spec, or read a response field the schema doesn’t define, so the tests double as 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, and if the server drifts from the spec, the tests fail.
Dynamic tests driven by your OpenAPI spec
Once the spec is loaded you can go further and parse it at runtime to generate tests from the parameter definitions, so the spec decides which cases to cover instead of you handwriting each one.
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) without writing a single case by hand. Add a new enum value to the spec and the suite grows to match.
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);
});
}
});
It won’t replace specific tests, but it’s a useful canary: any endpoint that starts returning 500s shows up immediately, and there’s nothing to maintain.
In all three cases the spec is the source of truth and the test file just reads it, so 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();
});
The first run creates the snapshot, and later runs fail if the response changes. That’s useful for configuration endpoints, feature flags, or any response where an unexpected change is a bug.
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, 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
The tests live in the repo, so they’re version controlled, reviewed in PRs, and have a visible history. They can do anything JavaScript can do. They’re fully typed, with IDE autocomplete and compile-time errors, and responses are checked against your OpenAPI schema. CI runs them with one command against any environment, no GUI needed. And Bun’s test runner is noticeably faster than Jest or Vitest for this kind of 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 still has its place for quickly prodding an unfamiliar API you don’t own. For everything I’m building and testing myself, I find this setup better on every count I care about.