Show HN: JSON-driven REST test framework, built-in session and state management

1 month ago 8

You're testing a REST API. You need to:

  1. Auth with email/SMS
  2. Create some resources
  3. Upload files
  4. Validate responses
  5. 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.

The Solution: Declarative API Testing

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: '[email protected]' }) }); expect(authRes.status).to.equal(200); // Simulate getting token from email (in real test: check mock mailbox) const token = await getMockEmailToken('[email protected]'); // 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('[email protected]'); });
export const AuthFlow: ZillaScript = { script: "auth-flow", steps: [ { step: "request auth code", request: { post: "auth", body: { contactEmail: "[email protected]" } }, 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 '[email protected]'"] }] } } ] }; // Run it await runZillaScript(AuthFlow, { env: process.env });

Result: Half the code, zero boilerplate, 100% intent.

1. State Management Is Built-In

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!" } } }

2. Sessions Are Automatic

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

4. Composition via Include

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: "[email protected]" } }, { step: "do stuff", /* ... */ } ] };

5. Custom Handlers for Complex Logic

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}`); } } } } };

6. Real-World Example: Guest Upload Flow

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.

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" }

Environment Variables in URLs

servers: [{ base: "http://{{env.API_HOST}}:{{env.API_PORT}}/api" }]

Extract from Headers/Cookies

response: { capture: { rateLimitRemaining: { header: { name: "X-RateLimit-Remaining" } }, sessionCookie: { cookie: { name: "connect.sid" } } } }

Validation with Variables

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: "[email protected]" }, { name: "Bob", email: "[email protected]" } ], 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.

  1. Tests are documentation: Your test suite should read like API documentation
  2. Declare intent, not implementation: Describe what you're testing, not how to test it
  3. State is explicit: Variables and sessions are first-class concepts
  4. Composition over inheritance: Build complex tests from simple, reusable pieces
  5. 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.

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.

Read Entire Article