Skip to content

Local Testing — The Test Harness

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/local-testing/testing-harness/.
Write a vitest suite for this project with `deployStack` from `@intentius/chant/testing`: deploy in `beforeAll`, assert with `assertLive`, and `destroy()` in `afterAll`.
Set `ownership.stack` and a `{ name: "test-*", endpoint: "http://localhost:4566" }` environment in `chant.config.ts`, so the suite runs against a local emulator.
Boot the emulator with `npx chant emulator up`, run the suite, and show me the output. Then open a pull request with the suite and the config.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

@intentius/chant/testing turns “deploy my stack, assert against it, destroy it” into a vitest suite. One call deploys a real instance of your project into a per-run environment; the handle it returns carries the built outputs, the discovered entities, the environment name, and a destroy() that sweeps exactly what the deploy created. Locally the suite runs against an emulator for $0; in CI the identical suite can target a real account.

import { beforeAll, afterAll, test, expect } from "vitest";
import { deployStack, type DeployedStack } from "@intentius/chant/testing";
let stack: DeployedStack;
beforeAll(async () => {
stack = await deployStack({ dir: "src", suite: "my-service" });
});
afterAll(async () => {
if (stack) await stack.destroy();
});
test("the queue is declared and deployed", () => {
expect(stack.entities.has("taskQueue")).toBe(true);
});

One deploy per suite, not per test: beforeAll/afterAll, with assertions in between running against the same live instance.

deployStack(options) takes:

OptionMeaning
dirThe chant project directory (where the infra source lives). Required.
suiteSuite name folded into the derived environment name. Defaults to the directory’s basename.
envExplicit environment name, overriding the derivation — for a shared long-lived test environment. Must be legal for the project’s declared environments, exactly as --env would be.
paramsBuild parameters, exactly as --param name=value flags would supply them.

It builds the project the way chant build would (build parameters, the ownership marker, build roots), then applies each built output additively through the local Op executor — the same nativeApply path chant run uses, with deleteMode: "never". A build error, an unresolved parameter, or a failed apply rejects the promise; nothing is returned half-deployed.

The resolved handle is a DeployedStack:

  • outputs — the built outputs, keyed by lexicon. What was deployed, available for template-level assertions.
  • entities — the discovered entities, keyed by name.
  • env — the environment this deploy targeted. The teardown key.
  • assertLive(name, options?), observation-backed assertions against the live stack. Covered below.
  • destroy() — the marker-scoped sweep of that environment, in-process. The same operation as chant lifecycle teardown <env> --yes.

destroy() throws a TeardownIncompleteError when any candidate failed to delete or the plan had holes (something chant may own could not be read). An environment that cannot be called clean is a test failure, never a silent leak. It is safe to call again: teardown is stateless, and a second call over a clean environment plans nothing.

entities.has("taskQueue") and the outputs map both check what was declared and built, not what is actually running. assertLive is the other half, calling describeResources() against exactly one named entity and reporting back with either a resolved value or a thrown error. Reach for it whenever a test needs to know the live stack, not just the plan that was sent to it.

assertLive(name: string, options?: { status?: string }): Promise<ResourceMetadata>

The call takes two forms.

// exists, marker-verified
const bucket = await stack.assertLive("dataBucket");
// exists, marker-verified, and reports this status
const queue = await stack.assertLive("taskQueue", { status: "CREATE_COMPLETE" });

On success it resolves to the entity’s ResourceMetadata. On failure it throws rather than returning a falsy result, for three distinct reasons.

  • The entity is observed absent. describeResources looked, and the environment reported no such resource, so assertLive throws LiveAssertionError.
  • The entity is not observed at all, an unmapped kind, a failed call, missing credentials. That counts as neither a pass nor an absence, the same tri-state the observation contract draws for lifecycle plan, so assertLive throws UnobservedAssertionError, a distinct type so a suite can catch could-not-tell apart from a confirmed miss.
  • A same-named resource is observed, but it carries another deploy’s { stack, env } marker, or no marker at all where the lexicon’s ownership channel says one should be present, so assertLive throws LiveAssertionError naming what it found.

Both error types are exported from @intentius/chant/testing:

import { LiveAssertionError, UnobservedAssertionError } from "@intentius/chant/testing";

Endpoint resolution is identical to deployStack and destroy(): ambient endpoint variables win, then the target environment’s declared endpoint, then the real cloud. See Emulator or real cloud below.

assertLive checks existence and status only. Property-level assertions, comparing a live resource’s actual configuration against what was declared, wait on deep observation (chant #1014); status is as deep as v1 goes.

Two config requirements, both about identity:

chant.config.ts
export default {
lexicons: ["aws"],
ownership: { stack: "my-service" },
environments: ["dev", "prod", { name: "test-*", endpoint: "http://localhost:4566" }],
} satisfies ChantConfig;

ownership.stack is required. The harness stamps every deployed resource with the marker { stack, env }, and destroy() selects on that marker and nothing else — a deploy that stamps nothing is a deploy nothing can sweep, so the harness refuses it up front.

The "test-*" pattern entry legalizes the environment names the harness derives. A project that declares environments rejects unknown names, exactly as --env does; the pattern entry makes every test-<anything> name legal without listing each one. Without it, deployStack fails with the hint to add it.

The default environment for one run is test-<suite>-<nonce> — the suite name slugged, plus a six-character run nonce. Parallel CI jobs of the same suite get different environments, so they never collide. The environment name is also the deploy boundary’s name where the target has one (the CloudFormation stack name on AWS), so physical names that fold in the stack name inherit the isolation:

export const taskQueue = new Queue({
QueueName: Sub`${AWS.StackName}-tasks`,
});

Pass env explicitly for a shared long-lived test environment instead — the derivation is a default, not a rule.

The harness resolves its target the way --live reads do. In order:

  1. Ambient endpoint variables win. If AWS_ENDPOINT_URL (or the other lexicons’ endpoint variables) is set, the apply and the teardown go there. This is what chant emulator up --json reports and what the aws lexicon’s flociUp activity exports, so a suite that boots its own emulator needs nothing else.
  2. The environment’s declared endpoint applies otherwise. A { name: "test-*", endpoint: "http://localhost:4566" } entry points every harness run at the local emulator by default, injected only for the duration of the call.
  3. With neither, the real cloud. CI pointing the identical suite at a real account just leaves both unset and provides credentials.

Nothing about the suite text changes between the three.

Teardown is stateless: it reads live ownership markers, never a local record. If a suite crashes after deploying — the process was killed, afterAll never ran — nothing is lost except the sweep itself. Rerun destroy:

Terminal window
chant lifecycle teardown test-my-service-k3x9a2 --yes

The same marker-scoped sweep destroy() runs in-process, against whatever the crashed run left behind. chant lifecycle teardown <env> without --yes shows the plan first.

The local-testing overview covers the per-cloud apply paths and emulators the harness rides on; the per-cloud pages (AWS, Azure, GCP) walk each loop by hand. The harness packages that loop for a test runner: same build, same appliers, same teardown, one call each way.

chant