Skip to Content
SDK & PluginsData-Driven

Data-Driven Testing

Why separate code from data?

When you hardcode test inputs inside your test file, every new case means editing code. This causes merge conflicts, makes non-developers unable to contribute cases, and mixes “what to verify” with “how to verify”.

Data-driven testing separates the two:

  • Code defines the verification logic (one function)
  • Data defines the inputs and expectations (files anyone can edit)

This model is git-safe by design. Data files are plain JSON or YAML — they live in your repo, go through PR review, and merge like any other code. Unlike Postman Examples that live in a proprietary cloud, your test data is version-controlled, diffable, and owned by you. Personal overrides use .local.json (gitignored) so teammates never conflict.

When to use what

test.each — run every case, every time

Scenario: You have a list of products, users, or edge cases and want to verify all of them on every CI run. Each row is independent.

import { test, fromYaml } from "@glubean/sdk"; // data/status-codes.yaml // - id: 1 // expected: 200 // - id: 999 // expected: 404 // - id: -1 // expected: 400 const cases = await fromYaml("./data/status-codes.yaml"); export const checkStatus = test.each(cases)( { id: "status-$id", tags: ["regression"] }, async (ctx, { id, expected }) => { const res = await ctx.http.get(`/products/${id}`); ctx.expect(res).toHaveStatus(expected); }, );

Every row runs. If you add a row to the YAML, CI picks it up automatically. No code changes needed.

test.pick — named parameter sets, pick one

Scenario: You’re working on a directions API. There are many valid input combinations — different origins, destinations, transport modes. You don’t want to run all of them every time, you want to pick the one you’re currently working with.

This is exactly like Postman’s “Examples” feature — save multiple request variations, click the one you need. In VS Code, each case name appears as a CodeLens button. From CLI: glubean run --pick sg-cross-island.

# data/directions/shared.yaml # Cross-island route — tests longest common route type sg-cross-island: description: Cross-island route via expressway request: origin: "1.290,103.851" destination: "1.340,103.681" mode: car expect: minRoutes: 1 maxDuration: 3600 # Short trip — tests minimum-distance edge case sg-short-trip: description: Short neighborhood trip request: origin: "1.300,103.850" destination: "1.305,103.855" mode: car expect: minRoutes: 1 maxDuration: 300
import { test, fromYaml } from "@glubean/sdk"; const cases = await fromYaml.map("./data/directions/shared.yaml"); export const directions = test.pick(cases)( { id: "dir-$_pick", name: "Directions: $_pick", tags: ["geo"] }, async (ctx, { description, request, expect: exp }) => { ctx.log(description); const res = await ctx.http.post("directions/json", { json: request }); ctx.expect(res).toHaveStatus(200); const data = await res.json<{ routes: { duration: number }[] }>(); ctx.expect(data.routes.length).toBeGreaterThanOrEqual(exp.minRoutes); ctx.expect(data.routes[0].duration).toBeLessThan(exp.maxDuration); }, );

Key difference

test.eachtest.pick
RunsAll cases, every timePick one, run it
Use forCI regression — full coverageDaily development — the case you’re working on
Like PostmanCollection Runner (run all)Examples (pick one, send)
Data structureArrayKey-value (named)
Best intests/ (CI)explore/ (iteration)
Parallel{ parallel: true } optionNot supported
CodeLensPlay runs allPlay per case name — click the one you need

Template variables in test IDs

Both test.each and test.pick support $variable placeholders in the test ID and name. These are replaced at runtime with values from each data row.

test.each$fieldName

Use $fieldName to insert any field value from the data row:

// Data: [{ id: 1, expected: 200 }, { id: 999, expected: 404 }] export const check = test.each(cases)( { id: "status-$id", name: "GET /products/$id → $expected" }, async (ctx, { id, expected }) => { ... }, ); // Generates: "status-1", "status-999" // Names: "GET /products/1 → 200", "GET /products/999 → 404"

Any field in the data row can be used as $fieldName. If the field doesn’t exist, the placeholder stays as-is.

test.pick$_pick

$_pick is a special variable that resolves to the key name from the key-value data:

// Data: { "sg-cross-island": { ... }, "sg-short-trip": { ... } } export const route = test.pick(cases)( { id: "dir-$_pick", name: "Directions: $_pick" }, async (ctx, data) => { ... }, ); // Generates: "dir-sg-cross-island", "dir-sg-short-trip"

$_pick always refers to the object key, not a field inside the value. You can also use $fieldName with pick — it resolves from the value object:

// Data: { "sg-cross-island": { description: "Cross-island route", mode: "car" } } { id: "dir-$_pick", name: "$description ($mode)" } // Name: "Cross-island route (car)"

Summary

SyntaxWorks withResolves to
$fieldNametest.each and test.pickValue of that field in the data row
$_picktest.pick onlyThe key name from the key-value data
$_keytest.each onlyAuto-generated index (0, 1, 2, …)

Parallel execution

By default, test.each runs cases sequentially. For independent API calls that don’t share state, run them in parallel:

const cases = await fromYaml("./data/status-codes.yaml"); export const checkStatus = test.each(cases, { parallel: true })( { id: "status-$id", tags: ["regression"] }, async (ctx, { id, expected }) => { const res = await ctx.http.get(`/products/${id}`); ctx.expect(res).toHaveStatus(expected); }, );

Each case runs in its own subprocess with isolated state — one slow or failing case doesn’t block others. The CLI controls the concurrency level with --concurrency (default: CPU cores).

When to use: Cases are independent (no shared state, no ordering dependency). Typical speedup is 3-5x on multi-core machines.

When not to use: Cases depend on each other, share a session, or hit a rate-limited API where parallel requests would cause throttling.


Data from files

Don’t hardcode data in your test files. Use data loaders:

Array loaders — for test.each

LoaderInputOutputBest for
fromYaml("./file.yaml")YAML fileT[]Structured data with comments
fromJson("./file.json")JSON fileT[]JSON arrays (like native import, but with path resolution)
fromCsv("./file.csv")CSV fileRow[]Tabular data, spreadsheet exports
fromJsonl("./file.jsonl")JSONL fileRow[]Log-style data, one object per line
fromDir("./dir/")DirectoryT[]One file = one case
fromDir.concat("./dir/")DirectoryT[]Arrays concatenated from files

Map loaders — for test.pick

LoaderInputOutputBest for
fromYaml.map("./file.yaml")YAML fileRecord<string, T>Named scenarios in a single file
fromJson.map("./file.json")JSON fileRecord<string, T>Named scenarios in a single file
fromDir.merge("./dir/")DirectoryRecord<string, T>Named cases split across files, supports .local.json

All paths resolve relative to the calling file, not the project root. The default file extensions for fromDir are .json, .yaml, and .yml.

Structured data — not just flat key-value

Data cases can be as deep as you need:

checkout-happy-path: description: Standard checkout with valid card request: items: [{sku: "PHONE-128", qty: 1}] payment: {method: credit_card, token: "tok_visa"} shipping: {address_id: "addr_123"} expect: status: 201 has_confirmation: true

Destructure in your test: async (ctx, { description, request, expect: exp }) => { ... }. The data drives both the input and the assertions.

Personal test data with .local.json

Shared data lives in git. Personal overrides don’t:

data/directions/ ├── shared.json # committed — team baseline ├── shared.yaml # committed — YAML with comments └── mine.local.json # gitignored — your personal cases

fromDir.merge() loads all files and merges them. .local.json keys override shared keys with the same name. See Local Data for details.

Rule of thumb

  • Exploring an API? test.pick in explore/ — save parameter sets, pick the one you’re working on
  • Ready for CI? Move to test.each in tests/ — every case runs, every time
  • Complex request bodies? Structured YAML with description + request + expect
  • Team collaboration? fromDir.merge + .local.json — everyone adds cases without merge conflicts
Last updated on