Skip to Content

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 examples can live in *.local.json files (gitignored) so teammates never conflict.

When to use what

The examples below assume a shared HTTP client created with configure():

import { configure } from "@glubean/sdk"; const { http: api } = configure({ http: { prefixUrl: "{{BASE_URL}}" }, });

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.

Use a $field placeholder in the test ID so every generated row has a unique runtime ID. Without a varying placeholder, all rows collapse to the same ID and selection, reports, retries, and Cloud evidence become ambiguous.

import { configure, test, fromYaml } from "@glubean/sdk"; // data/status-codes.yaml // - id: 1 // expected: 200 // - id: 999 // expected: 404 // - id: -1 // expected: 400 const { http: api } = configure({ http: { prefixUrl: "{{BASE_URL}}" }, }); 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 api.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 examples

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. By default, test.pick() selects one example from the map so local runs stay light. From the CLI, --pick overrides that selection:

glubean run explore/directions.test.ts --pick sg-cross-island glubean run explore/directions.test.ts --pick sg-cross-island,sg-short-trip glubean run explore/directions.test.ts --pick all

Use $_pick in the test ID so the selected example key becomes part of the runtime ID.

# 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 { configure, test, fromYaml } from "@glubean/sdk"; const { http: api } = configure({ http: { prefixUrl: "{{BASE_URL}}" }, }); 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 api.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 timeDefaults to one selected example; --pick can choose one, many, a glob, or all
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 } metadataInherited from test.each; use only for independent examples
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, …)

CLI selection for test.pick

test.pick(examples) is a thin wrapper over test.each. It injects the example key as _pick, then lets the runner select examples.

glubean run explore/directions.test.ts # default: one selected example glubean run explore/directions.test.ts --pick sg-short-trip glubean run explore/directions.test.ts --pick sg-* glubean run explore/directions.test.ts --pick all

Use the default for quick local iteration. Use --pick <key> when debugging a specific example. Use --pick all in CI only when the map is intentionally part of the regression suite.


Parallel execution

By default, test.each rows run sequentially. For independent API calls that do not share state, mark the rows as eligible for parallel execution:

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

Parallel rows still run inside the file’s harness process, preserving the single-file module model. Glubean buffers each row’s events separately so the result output stays readable, but this is not a process-isolation boundary: do not rely on module variables or session writes between parallel rows.

The actual max parallelism comes from the resolved run config, such as execution.concurrency in a glubean.yaml profile. If concurrency resolves to 1, rows still run one at a time even when parallel: true is set.

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

Path rules:

  • ./ and ../ paths resolve relative to the calling file.
  • Bare paths such as data/cases.yaml resolve relative to the project root.
  • Absolute paths are preserved as-is.

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 files 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 matching files and shallow-merges them in sorted filename order; later filenames win on duplicate keys. *.local.json files are gitignored by convention, not treated specially by the loader. 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