You're testing a REST API. You need to:
- Auth with email/SMS
- Create some resources
- Upload files
- Validate responses
- Chain requests using captured session tokens and IDs
Your options:
- Postman collections: Click-fest UI, version control nightmare, no real programming
- Imperative test code: 200 lines of boilerplate for what should be 20 lines of intent
- Raw curl + bash: Works until you need state management, then becomes spaghetti
There's a better way.
zilla-script lets you write API tests as data structures. Your test describes what you want, not how to do it.
it('should authenticate and fetch user data', async () => {
// Request auth code
const authRes = await fetch('http://localhost:3030/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contactEmail: 'user@example.com' })
});
expect(authRes.status).to.equal(200);
// Simulate getting token from email (in real test: check mock mailbox)
const token = await getMockEmailToken('user@example.com');
// Verify token
const verifyRes = await fetch('http://localhost:3030/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
expect(verifyRes.status).to.equal(200);
const { session } = await verifyRes.json();
expect(session).to.exist;
// Use session to fetch account data
const accountRes = await fetch('http://localhost:3030/api/account', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Cookie': `session=${session}`
}
});
expect(accountRes.status).to.equal(200);
const account = await accountRes.json();
expect(account.email).to.equal('user@example.com');
});
export const AuthFlow: ZillaScript = {
script: "auth-flow",
steps: [
{
step: "request auth code",
request: { post: "auth", body: { contactEmail: "user@example.com" } },
handlers: [{ handler: "get_email_token", params: { tokenVar: "token" } }]
},
{
step: "verify and start session",
request: { post: "auth/verify", body: { token: "{{token}}" } },
response: { session: { name: "userSession", from: { body: "session" } } }
},
{
step: "fetch account data",
request: { session: "userSession", get: "account" },
response: {
validate: [{ id: "correct email", check: ["eq body.email 'user@example.com'"] }]
}
}
]
};
// Run it
await runZillaScript(AuthFlow, { env: process.env });
Result: Half the code, zero boilerplate, 100% intent.
Capture values from responses, use them in subsequent requests:
{
step: "create post",
request: { post: "posts", body: { title: "Hello World" } },
response: {
capture: { postId: { body: "id" } } // JSONPath with implied $.
}
},
{
step: "add comment",
request: {
post: "posts/{{postId}}/comments", // Use captured value
body: { text: "Great post!" }
}
}
Capture a session once, use it everywhere:
response: {
session: {
name: "adminSession",
from: { body: "session.token" } // or header/cookie
}
}
// Later...
request: {
session: "adminSession", // Automatically sent in header/cookie
get: "admin/users"
}
validate: [
{ id: "status is active", check: ["eq body.status 'active'"] },
{ id: "created recently", check: ["gt body.createdAt 1704067200000"] },
{ id: "has items", check: ["notEmpty body.items"] }
]
Available checks: eq
, neq
, gt
, gte
, lt
, lte
, empty
, notEmpty
, null
, notNull
, undefined
, notUndefined
, startsWith
, endsWith
, includes
Break complex flows into reusable modules:
const SignUp: ZillaScript = {
script: "sign-up",
steps: [/* ... signup steps ... */]
};
const FullWorkflow: ZillaScript = {
script: "full-workflow",
steps: [
{ step: "sign up user", include: SignUp, params: { email: "test@example.com" } },
{ step: "do stuff", /* ... */ }
]
};
When you need programmatic control:
handlers: [{
handler: "check_database",
params: {
query: "SELECT count(*) FROM users WHERE email = ?",
args: ["{{userEmail}}"],
expectedCount: 1
}
}]
Register handlers in your test setup:
const options: ZillaScriptOptions = {
init: {
handlers: {
check_database: async (ctx, params) => {
const count = await db.query(params.query, params.args);
if (count !== params.expectedCount) {
throw new Error(`Expected ${params.expectedCount}, got ${count}`);
}
}
}
}
};
This is a real test from our production API (simplified):
export const VisitGuestScript: ZillaScript = {
script: "visit-guest",
steps: [
{
step: "visit location (scan QR code)",
request: { get: "visit/location/{{locationShortName}}" },
handlers: [{
handler: "new_appGuest_key",
params: { var: "guestKey", authVar: "guestAuth", location: "{{locationShortName}}" }
}],
response: {
capture: { orgInfo: { body: null } }, // Capture entire body
validate: [
{ id: "org found", check: ["eq body.org.id orgId"] },
{ id: "has logo", check: ["notEmpty body.assets.logo"] }
]
}
},
{
step: "start guest session as minor (under 13)",
request: {
post: "visit/location/{{locationShortName}}",
body: {
under13: true,
publicKey: "{{guestAuth.publicKey}}",
nonce: "{{guestAuth.nonce}}",
token: "{{guestAuth.token}}"
}
},
response: {
session: { name: "guestSession", from: { body: "id" } }
}
},
{
step: "upload 3 photos as guest",
loop: {
items: ["photo1.jpg", "photo2.jpg", "photo3.jpg"],
varName: "filename",
steps: [{
step: "upload {{filename}}",
request: {
session: "guestSession",
post: "visit/location/{{locationShortName}}/asset",
contentType: "multipart/form-data",
body: { file: "{{filename}}" }
}
}]
}
},
{
step: "list uploaded photos",
request: {
session: "guestSession",
get: "visit/location/{{locationShortName}}/asset"
},
response: {
capture: { photos: { body: null } },
validate: [{ id: "3 photos uploaded", check: ["eq body.length 3"] }]
}
},
{
step: "delete first photo",
request: {
session: "guestSession",
delete: "visit/location/{{locationShortName}}/asset/{{photos.[0].id}}"
}
},
{
step: "verify deletion",
request: {
session: "guestSession",
get: "visit/location/{{locationShortName}}/asset"
},
response: {
validate: [{ id: "2 photos remain", check: ["eq body.length 2"] }]
}
}
]
};
This test:
- Simulates scanning a QR code
- Creates cryptographically signed guest credentials
- Starts a session for a minor
- Uploads files
- Lists and deletes assets
- Validates state throughout
Try writing this imperatively. I'll wait.
npm install zilla-script
import { ZillaScript, runZillaScript } from "zilla-script";
const MyTest: ZillaScript = {
script: "my-first-test",
init: {
servers: [{
base: "http://localhost:3000/api",
session: { cookie: "sessionId" }
}],
vars: { username: "testuser", password: "{{env.TEST_PASSWORD}}" }
},
steps: [
{
step: "login",
request: {
post: "auth/login",
body: { username: "{{username}}", password: "{{password}}" }
},
response: {
session: { name: "userSession", from: { body: "token" } },
validate: [{ id: "login success", check: ["eq body.success true"] }]
}
},
{
step: "get profile",
request: { session: "userSession", get: "user/profile" },
response: {
validate: [{ id: "correct username", check: ["eq body.username username"] }]
}
}
]
};
// Run with Mocha
describe("API tests", () => {
it("should login and fetch profile", async () => {
await runZillaScript(MyTest, { env: process.env });
});
});
Read the full guide for an exhaustive review.
init: {
servers: [
{ name: "api", base: "http://localhost:3000/api", session: { cookie: "sid" } },
{ name: "cdn", base: "http://localhost:4000", session: { header: "X-Token" } }
]
}
// Use in steps
request: { server: "cdn", get: "images/logo.png" }
servers: [{
base: "http://{{env.API_HOST}}:{{env.API_PORT}}/api"
}]
response: {
capture: {
rateLimitRemaining: { header: { name: "X-RateLimit-Remaining" } },
sessionCookie: { cookie: { name: "connect.sid" } }
}
}
response: {
capture: { userId: { body: "id" } },
validate: [
{ id: "user id matches", check: ["eq body.owner.id userId"] },
{ id: "header check", check: ["eq header.content_type 'application/json'"] }
]
}
response: {
status: 422,
validate: [
{ id: "validation error", check: ["includes body.error 'invalid email'"] }
]
}
{
step: "create multiple users",
loop: {
items: [
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" }
],
varName: "user",
steps: [{
step: "create {{user.name}}",
request: {
post: "users",
body: { name: "{{user.name}}", email: "{{user.email}}" }
}
}]
}
}
{
step: "update user object",
edits: {
user: {
status: "active",
lastLogin: "{{now}}"
}
},
request: {
post: "users/{{user.id}}",
bodyVar: "user" // Send entire modified user object
}
}
Postman/Insomnia: Great for manual testing, terrible for CI/CD. Version control is painful, no programmatic control.
Supertest/Axios: Imperative code. Every test becomes 50% boilerplate, 50% intent. State management is manual.
GraphQL/gRPC test tools: Domain-specific. zilla-script works with any HTTP/REST API.
Cucumber/Gherkin: Natural language is great for stakeholders, terrible for developers. BDD adds ceremony without adding value for API tests.
Raw test code: Maximum flexibility, maximum pain. You end up reinventing zilla-script badly.
- Tests are documentation: Your test suite should read like API documentation
- Declare intent, not implementation: Describe what you're testing, not how to test it
- State is explicit: Variables and sessions are first-class concepts
- Composition over inheritance: Build complex tests from simple, reusable pieces
- Escape hatches everywhere: Custom handlers for when declarative isn't enough
Our production API test suite:
- 15+ integration test files covering auth, profiles, posts, moderation, payments
- 600+ test steps across all scenarios
- Average test: 40 steps, 10-15 validations per test
- Boilerplate reduction: ~70% less code vs imperative approach
- Maintenance time: Down 60% (changes propagate via includes)
The killer feature: Junior devs can write these tests. The declarative format makes it obvious what's happening. No more "what does this fetch chain do?"
- License: MIT
- Repo: [Your repo here]
- Issues: [Your issues here]
- Discussions: [Your discussions here]
Stop writing 200-line imperative API tests. Start writing 30-line declarative scripts that actually communicate intent.
npm install zilla-script
Your future self will thank you.
Built by developers who got tired of API test boilerplate. Used in production to test a multi-tenant social platform with millions of API calls per day.