Skip to content

Test Step Authoring Guide (Adder + Executor)

This guide is for authors of new test steps and maintainers updating existing ones.

A “test step” always has two halves:

  • a Fixture-phase adder (a method on DancesTestCase) that specifies and registers the step, and
  • an Execution-phase executor (in execution_steps) that runs the step and records its outcome.

Before adding either, confirm a new step type is actually the right answer — see §7.

The goal is to make TestCase authoring easy, fast, and safe by encapsulating subtle correctness requirements inside adders and harness helpers.


1. Core Concept: A Step Is a Contract

It is a contract composed during the Fixture Phase.

That contract specifies:

  • Source intent: what holon the executor should operate on
  • Expected outcome: what holon state the step should produce
  • Step parameters: any additional data needed to perform the operation

This contract is materialized as:

  • a TestReference (source + expected), and
  • a concrete TestStep struct holding the TestReference plus step-specific parameters.

2. Adder Responsibilities (Fixture Phase)

2.1 Purpose of an Adder

An adder’s job is to fully define and register one test step.

Specifically, an adder must:

  • derive the step’s source specification from an input TestReference
  • compute the expected holon state produced by the step
  • resolve expected relationship targets correctly when constructing expected graphs
  • mint a new TestReference describing that step
  • decide whether the step continues an existing logical holon or creates a new one
  • construct the concrete TestStep
  • append that TestStep to the TestCase

Adders exist so TestCase authors never need to reason about snapshot lifetimes, commit semantics, or token redirection.


2.2 Adder Inputs and Outputs

Conceptually, every adder follows this pattern:

Inputs

  • a source TestReference (unless the step is global, e.g. commit)
  • step-specific parameters (PropertyMap, relationship ops, flags, etc.)
  • mutable access to FixtureHolons
  • access to the fixture context (for cloning snapshots)

Outputs

  • a newly minted TestReference (except for some global steps like commit)
  • a fully-constructed TestStep appended to the TestCase

The adder is the only place where TestSteps are created and registered.


2.3 Canonical Adder Sequence (Critical)

For any step that produces an expected holon snapshot (most steps), the adder must follow this exact sequence.

Canonical Adder Sequence

  1. Derive the fixture-time source snapshot
  2. Derive the source snapshot through FixtureHolons helpers.
  3. Treat the input token as a logical-holon handle, not as a guarantee that its embedded snapshot is the authoritative current source.
  4. Never mutate this snapshot.

  5. Clone the source snapshot into a working holon

  6. Use FixtureHolons::copy_fixture_snapshot for a transient fixture snapshot.
  7. This is the only holon the adder may mutate.
  8. If the source is Deleted, error unless the step explicitly supports Deleted input.

  9. Apply the step’s effects

  10. Apply property changes, relationship edits, lifecycle transitions, etc.
  11. This produces the expected post-step holon content.

Relationship-step nuance: - when embedding relationship targets into the new expected graph, do not blindly use the literal historical snapshot carried by the target token - instead, resolve the target token through FixtureHolons so the expected graph embeds the logical holon's current expected head snapshot

  1. Freeze the expected output snapshot
  2. The working holon produced by applying the step’s effects becomes the ExpectedSnapshot snapshot and, due to "tight chaining" may become the SourceSnapshot snapshot in later TestSteps.
  3. Therefore, it should not be mutated after this point.
  4. If there is any possibility the working holon could be mutated after this point, the adder must clone it and use the clone as the expected snapshot.

  5. Mint the new TestReference

  6. Construct:
    • SourceSnapshot: derived from the input TestReference
    • ExpectedSnapshot: frozen snapshot + expected lifecycle state
  7. Mint the TestReference via FixtureHolons helpers only.

  8. Register logical holon identity

  9. Decide whether this step:
    • continues the same logical holon, or
    • creates a new logical holon
  10. Call the appropriate FixtureHolons registration API.

  11. Construct and append the TestStep

  12. Create the concrete TestStep struct containing:
    • the minted TestReference
    • all step-specific parameters
  13. Append it to the TestCase.

  14. Return the minted TestReference

  15. Return the newly created TestReference to the caller.
  16. This returned reference is what TestCase authors use to chain subsequent steps.

It is very important that this exact ordering be followed. Doing the right operations in the wrong order leads to aliasing bugs, broken chaining, and unpredictable results.

Source Derivation vs Relationship-Target Expected Resolution

Adder authors need to keep two distinct harness behaviors separate:

  • Source derivation
  • determines which snapshot should become the next step's source
  • supports head advancement across commit boundaries
  • Relationship-target expected resolution
  • determines which snapshot should be embedded as the target in a newly constructed expected relationship graph
  • uses the target token as a handle to the logical holon's current expected head

These behaviors are related, but they are not interchangeable.


2.4 Deciding Whether a Step Creates a New Logical Holon

The adder author must explicitly decide whether the step yields:

Same logical holon (continue existing FixtureHolon)

Examples:

  • with_properties
  • add_relationship
  • remove_relationship
  • abandon_staged
  • delete_saved
  • many “modify in place” lifecycle steps

Rule of thumb: If the step conceptually modifies or transitions the same entity, reuse the FixtureHolon.

New logical holon (allocate new FixtureHolon)

Examples:

  • stage_new_holon
  • stage_new_from_clone (when treated as creating a new entity)
  • explicit “create new” steps

Rule of thumb: If the step creates an independently identifiable entity, allocate a new FixtureHolon.

This decision is enforced through FixtureHolons; adders should not manage identity manually.


2.5 Special Step: Commit Adder

commit is special because it does not operate on a single source TestReference.

Validate the attempt's candidate dispositions and relationship-retry participants through FixtureHolons, then append the Commit step with its prepared expectations. Only candidates that advance receive result tokens; NoAction under Incomplete stays staged, and retry participants retain their existing saved mappings. Expected rejection or command-level failure mints no result tokens and leaves heads unchanged.

Follow the Commit outcome table for head advancement; do not advance every staged head unconditionally. TestCase authors keep using their existing TestReferences and never capture internal Commit result tokens.


3. Executor Responsibilities (Execution Phase)

3.1 Purpose of an Executor

An executor must:

  1. Resolve the correct execution-time source holon
  2. Perform the operation
  3. Validate actual vs expected outcome
  4. Record the execution result for chaining

Executors never mint tokens and never consult FixtureHolons.


3.2 Canonical Executor Sequence (Critical)

Source-bearing mutation executors follow this sequence. Global assertions, bootstrap operations, Commit, and rejection checks use their own outcome contracts and need not resolve one source or record a new holon.

  1. Resolve execution-time source holon
  2. Use the harness helper ExecutionHolons::resolve_execution_reference
  3. Provide the step’s TestReference
  4. Relationship targets resolve through resolve_relationship_targets; batches through resolve_execution_references

  5. Execute the operation

  6. Perform create / update / stage / delete using real holon APIs
  7. Capture the resulting runtime reference (or Deleted)

  8. Validate the outcome

  9. Compare lifecycle state vs ExpectedSnapshot.state
  10. If live, compare content vs ExpectedSnapshot snapshot
  11. If deleted, ensure deletion semantics match expectation

Saved-content nuance: - MatchSavedContent compares saved roots by definitional equivalence: property values plus definitional relationships - non-definitional persisted edges, including commit-generated inverse SmartLinks, are intentionally ignored by that equality check - use targeted traversal/assertion steps when a test needs to verify those navigational edges explicitly

  1. Record the result
  2. Record the outcome against the ExpectedSnapshot token
  3. Use the harness helper ExecutionHolons::record
  4. Do not record against source tokens

This recording step is what enables subsequent steps to resolve correctly.


3.3 Special Executor: Commit

Retain candidate handles before dispatch, then compare observed dispositions and newly appended operational errors with the attempt's declarations. Match saved results by committed identity, and bind only the result tokens prepared by the adder. Under Complete, NoAction binds to its saved source without consuming a saved result; under Incomplete, it receives no binding. Relationship-retry participants retain their existing mappings and produce no further saved results.

For expected rejection, record no saved outcomes; staged candidates remain available to VerifyCommitRejection. See execution correspondence and the worked retry example for the shared contract.


4. Step Parameters and Expected Outcomes

4.0 Step shapes

Not every step operates on one holon. Choose the appropriate source and outcome contract for each DanceTestStep:

Token-bearing steps carry a step_token: TestReference and follow the canonical adder sequence in §2.3 — StageHolon, WithProperties, AddRelatedHolons, DeleteHolon, AbandonStagedChanges, StageNewVersion, StageNewFromClone, LookupSavedHolonByKey, and so on.

Commit steps have no single source holon but carry tokens minted for the expected saved outcomes. Their adders reason over FixtureHolons (§2.5). VerifyCommitRejection separately carries retained staged-candidate tokens and expected findings. Rejected Commit attempts must not advance those candidates to saved expectations.

Global and assertion steps need no source token. Examples — EnsureDatabaseCount, MatchSavedContent, BeginTransaction, LoadCoreSchema, and LoadHolonsInternal — assert against database or schema state rather than against a holon the fixture is tracking. Their adders return no TestReference and skip steps 1–6 of the canonical sequence entirely; they construct the step and append it.

Variants carry a description; operation steps expose their applicable expected error or outcome contract. Consult test_steps.rs for each complete variant.


4.1 Step-specific parameters

TestReference contains only source and expected holon specifications.

All step-specific parameters belong in the TestStep struct, for example:

  • PropertyMap
  • relationship references
  • flags and modes

4.2 Expected test outcome vs expected holon outcome

Some steps assert failure or error conditions.

These expectations:

  • are not part of TestReference
  • belong in the TestStep struct (e.g., ExpectedStepOutcome)

This is required because:

  • some steps have no source TestReference (commit)
  • some steps fail without producing a meaningful holon

5. Common Footguns (Explicitly Forbidden)

Adders must not:

  • mutate snapshots from input TestReferences
  • mint tokens without FixtureHolons
  • infer commit effects from token history
  • append incomplete TestSteps

Executors must not:

  • mint fixture tokens
  • consult FixtureHolons
  • record results against source snapshot tokens
  • bypass harness resolution helpers

One important boundary:

  • executors may consult ExecutionHolons
  • executors must not depend on fixture-time head tracking or author-side token interpretation rules directly

6. Summary Invariants

  • Every adder produces exactly one complete TestStep
  • Every token-bearing TestStep has exactly one TestReference; Commit carries outcome-token collections; other global steps need no source token; rejection assertions carry candidate tokens (§4.0)
  • Adders follow clone → apply → freeze → mint → register → append
  • Source-bearing mutation executors follow resolve → execute → validate → record
  • Saved Commit expectations advance heads internally; rejection retains staged heads, and authors reuse old references
  • Relationship adders must resolve target tokens through FixtureHolons when building expected graphs
  • MatchSavedContent is for saved structural equality, not for asserting every persisted navigational edge

These rules are foundational.
New test steps must follow them to remain compatible with commit semantics, fixture-time head selection, and tight chaining.


7. When a New Step Type Is Not the Answer

Adding a step type is the right move when a client-side operation has no adder yet. It is the wrong move when the thing you actually want to assert lives below the dance layer.

Do not add a step type in order to:

  • observe which substrate action a write authored (Create vs root-addressed Update)
  • prove that an Integrity callback fired, as distinct from a coordinator rejecting the input earlier
  • pin an exact consensus-visible rejection message
  • author an operation the production APIs deliberately cannot construct
  • assert what a conductor will or will not dispatch

A client-visible error alone does not prove which internal enforcement path ran. A step that checks only that error would provide weaker evidence. They belong in a conductor test: a plain #[tokio::test] that calls the zome extern directly. See the Conductor Test Framework.

Choose by the evidence required: client-visible outcomes can use the DSL or a standalone runtime test; claims about substrate actions or Integrity execution need direct conductor evidence.