Skip to content

Testing a Stack

This is the end-to-end pattern for integration-testing a deployed Stack against the real cloud: deploy once in beforeAll, drive the live URL from Effect-aware tests, destroy (or don’t) in afterAll. The harness API is documented at Test harness, the model at Testing, and the cloud-specific step-by-steps are Cloudflare Tutorial Part 3 and AWS Tutorial Part 3.

Configure Providers and state once per file — Test.make returns everything the file needs:

import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Drizzle from "alchemy/Drizzle";
import * as Neon from "alchemy/Neon";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Layer.mergeAll(
Cloudflare.providers(),
Drizzle.providers(),
Neon.providers(),
),
state: Alchemy.localState(),
});

Bun vs Vitest is a two-line import swap — see Bun vs Vitest.

beforeAll(deploy(Stack)) deploys once for the whole file and returns a lazy accessor every test can yield*:

const stack = beforeAll(deploy(Stack));
test(
"worker returns a url",
Effect.gen(function* () {
const { url } = yield* stack;
expect(url).toBeString();
}),
);

The default hook timeout is 120s — give slow stacks more room:

// This stack deploys a Container whose image build + push can take
// well over the default 120s hook budget.
const stack = beforeAll(deploy(Stack), { timeout: 600_000 });

beforeAll accessors compose — seed data in a second beforeAll that tests yield* alongside the stack; see Hooks.

Run the suite with your runner:

Terminal window
bun test test/integ.test.ts

The first run deploys; re-runs diff and skip unchanged Resources, and tests default to the isolated test stage so they never clobber your dev deployment.

HttpClient is already in scope in every test Effect:

import * as HttpBody from "effect/unstable/http/HttpBody";
import * as HttpClient from "effect/unstable/http/HttpClient";
test(
"PUT and GET round-trip an object",
Effect.gen(function* () {
const { url } = yield* stack;
const put = yield* HttpClient.put(`${url}/hello.txt`, {
body: HttpBody.text("Hello, World!"),
});
expect(put.status).toBe(201);
const get = yield* HttpClient.get(`${url}/hello.txt`);
expect(yield* get.text).toBe("Hello, World!");
}),
);

How the client is wired is covered in Test harness.

Fresh workers.dev URLs and Lambda Function URLs transiently 404/5xx while routes, bindings, DNS, and IAM propagate — ride out the cold-start window on the first request of each test:

const { getWhenReady } = Test;
test(
"worker answers once the edge converges",
Effect.gen(function* () {
const { url } = yield* stack;
const response = yield* getWhenReady(url);
expect(response.status).toBe(200);
}),
);

getWhenReady (and executeWhenReady for arbitrary requests) retries only 404/5xx — 20 attempts by default, exponential from 500ms — so deliberate 400/401/403 assertions observe those statuses immediately. For arbitrary effects, use a bounded manual retry:

import * as Schedule from "effect/Schedule";
const response = yield* HttpClient.get(`${url}/health`).pipe(
Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }),
);

Queues drain, workflows complete, and crons fire asynchronously — poll with a bounded declarative schedule, never a deadline loop:

const status = yield* HttpClient.get(`${url}/workflow/status/${instanceId}`).pipe(
Effect.flatMap((res) => res.json),
Effect.map((body) => body as { status: string }),
Effect.repeat({
schedule: Schedule.spaced("2 seconds"),
until: (s) => s.status === "complete",
times: 30,
}),
);

Bounded times fails fast instead of leaking into the runner timeout; while (Date.now() < deadline) loops ignore interruption.

Destroy on CI, keep the Stack alive locally for fast iteration:

afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); // keep alive with NO_DESTROY=1
afterAll.skipIf(!process.env.CI)(destroy(Stack)); // destroy on CI only

Locally, the deployed Stack plus cached state make re-runs near-instant. Hook semantics live in Hooks.

Already ran alchemy deploy and just want to hit the URL? Swap the deploy for a read of the outputs:

const stack = beforeAll(
process.env.SKIP_DEPLOY
? Effect.succeed({ url: process.env.STACK_URL! })
: deploy(Stack),
);

Give every file’s Test.make the same remote state and the same stage — identical state + stage means the second file’s deploy(Stack) is a no-op diff:

// test/api.integ.test.ts AND test/queue.integ.test.ts
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Cloudflare.providers(),
state: Cloudflare.state(), // remote, shared across files and runners
stage: "test", // same stage → same Stack instance
});

See State store for choosing a remote store.

Give every PR its own stage so suites run in parallel against one account:

const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Cloudflare.providers(),
stage: `pr-${process.env.PR_NUMBER}`,
});

Harness deploy/destroy never prompt for approval — --yes is only for CI workflows invoking the CLI (alchemy deploy --stage pr-42 --yes); see Stage and the CI guide for full workflow YAML.