Repeatable Znode test data: stable keys, readback, and no-op reruns

Create reusable test records by stable key, verify saved values, and make an unchanged rerun perform no writes.

Znode 10GuideIntermediateGCG engineering guide
In this guide
  1. Stable key
  2. Read current state
  3. Change owned fields
  4. Exact readback
  5. No-op rerun
A fixture is complete when desired state is verified and a second execution makes no unnecessary mutations.

The fixture that changes on every run

A developer creates test accounts, products, and prices to reproduce a checkout bug. Another developer runs the script and gets duplicates. A third run repairs some associations but resets a setting someone changed in Admin. The fixture has become another source of environment drift.

A repeatable seed should converge on a declared state. It needs stable business keys, clear ownership, exact readback, and a second run that makes no changes. Recording returned IDs is useful during execution, but environment-specific numeric IDs should not be the identity of the fixture.

Define ownership before mutation

Use a small fictional namespace for owned accounts, products, and price lists. Resolve those records by their stable codes. Refuse collisions where an existing record with the same key clearly belongs to another fixture or has incompatible identity. Avoid temporary usernames that resemble real staff or clients.

Decide which fields the script owns and which administrators may edit. Missing-only defaults are different from enforced configuration. If the API replaces an entire collection or attribute set, load and preserve the required existing values before writing. Verify the exact replacement scope instead of assuming that a narrow-looking request behaves like a patch.

Reconcile one owned fixture record

Use ensureRecord in a Node.js fixture script. Pass an OwnedRecord with a stable key and the label your script owns. FixturePort supplies three operations for one supported Znode resource: find by exact key, create the record, and update only the owned fields.

The helper rejects duplicate or mismatched identities, creates an absent record, corrects a different label, and reads the result back. If create throws after the server may have saved the row, it reads the same key before deciding whether the operation failed. Serialize seed runs or enforce uniqueness in the destination.

fixtures/reconcile-owned-record.ts typescript

export type OwnedRecord = Readonly<{ key: string; label: string }>;
export interface FixturePort {
  findExact(key: string): Promise<readonly OwnedRecord[]>;
  create(record: OwnedRecord): Promise<void>;
  updateOwnedFields(record: OwnedRecord): Promise<void>;
}
export async function ensureRecord(
  desired: OwnedRecord, port: FixturePort
): Promise<{ outcome: "existing" | "created" | "updated"; record: OwnedRecord }> {
  if (!/^[A-Z0-9-]{1,60}$/.test(desired.key) || desired.label.length > 200)
    throw new Error("Invalid fixture.");
  const read = async () => {
    const rows = await port.findExact(desired.key);
    if (rows.length > 1 || rows.some(row => row.key !== desired.key))
      throw new Error("Fixture identity conflict.");
    return rows[0];
  };
  let current = await read();
  let outcome: "existing" | "created" | "updated" = "existing";
  if (!current) {
    try { await port.create(desired); }
    catch (failure) {
      current = await read(); // Reconcile a commit followed by a lost response.
      if (!current) throw failure;
    }
    current = await read();
    if (!current) throw new Error("Created fixture is awaiting readback.");
    outcome = "created";
  }
  if (current.label !== desired.label) {
    await port.updateOwnedFields(desired);
    outcome = "updated";
  }
  const verified = await read();
  if (!verified || verified.label !== desired.label)
    throw new Error("Fixture readback differs from desired state.");
  return { outcome, record: verified };
}
// Adapter writes only the fields owned by this fixture. Preserve other fields.
// Serialize seed runs or enforce stable-key uniqueness in the target contract.

For key 'DEMO-RESOURCES' and label 'Technical resources', the first run creates the record and returns outcome 'created'. The same input on the next run returns 'existing' without a write. If only the label differs, it calls updateOwnedFields and returns 'updated'. An endpoint that replaces a whole record must preserve its other fields inside the adapter.

Provision in dependency order

Create prerequisites before the records that reference them. An account-pricing fixture might need Store, account, product, price list, and association steps in that order. The supplemental runner processes a list in its supplied order; your fixture definition is responsible for choosing that order.

After catalog creation, complete the supported assignment and publication steps and read the product through the storefront path. A successful Admin API write establishes that a record was saved, not that a buyer can see or purchase it. Keep publication and theme activation as explicit steps in the fixture run.

Make the second run a real test

After the first run, read every owned record and relationship. Run the same fixture again and count actual mutations, not merely printed 'unchanged' messages. A valid no-op result means the script called no create or update operation unnecessarily and the readback still matches the desired state.

Then interrupt a run between dependencies, simulate a write that commits before returning an error, and manually edit an unowned setting. Confirm that recovery is bounded and the administrator's value survives. Use distinct prices and product characteristics so accidental cross-associations can be recognized on the storefront, not just in an API response.

  • Reject duplicate keys instead of normalizing them away.
  • Report IDs as resolved evidence, not portable fixture definitions.
  • Separate component fixtures from end-to-end purchase coverage.

Treat fixture changes like application changes

Version fixture definitions and explain why a desired value changed. Retiring an old owned record needs an explicit cleanup policy; deletion should not be an incidental side effect of a missing row in a file. Preserve useful failure reports without including authentication material.

Reliable test data reduces the cost of every later investigation. A developer can reproduce the starting state, verify what exists, and rerun safely after a partial failure. That is a stronger foundation than a large seed script whose only success signal is that it reached its final line.

Run a bounded fixture set in dependency order

Pass 1 to 50 fixture records in the order their prerequisites require. The function validates the whole list before its first write, then calls ensureRecord sequentially and counts existing, created, and updated outcomes. For an unchanged second run, created and updated should both be zero. This helper preserves the supplied order; it does not discover dependencies or prevent a second process from running the same seed.

fixtures/ensure-fixture-set.ts typescript

import { ensureRecord, type FixturePort, type OwnedRecord } from "./reconcile-owned-record";

export async function ensureFixtureSet(
  records: readonly OwnedRecord[], port: FixturePort
): Promise<{ existing: number; created: number; updated: number }> {
  if (records.length < 1 || records.length > 50)
    throw new RangeError("Supply 1 to 50 fixture records.");
  const keys = new Set<string>();
  for (const record of records) {
    if (!record || typeof record.key !== "string" ||
        !/^[A-Z0-9-]{1,60}$/.test(record.key) || keys.has(record.key) ||
        typeof record.label !== "string" || record.label.length > 200)
      throw new Error("Fixture keys and labels must be valid and unique.");
    keys.add(record.key);
  }
  const counts = { existing: 0, created: 0, updated: 0 };
  for (const record of records) {
    const result = await ensureRecord(record, port);
    counts[result.outcome]++;
  }
  return counts;
}

References and further reading

Bring your next engineering question.

Improve implementation and release quality

Explore how GCG can help

Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.

Previous
Previous

Build a Znode 9 upgrade inventory before touching packages

Next
Next

Znode 9 to 10: migrate behavior before translating classes