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.

The same suite can run entirely on your machine. dev: true gives the file alchemy dev semantics — Workers, Durable Objects, KV, R2, D1, Queues, and Workflows run in workerd, AWS Lambda and its emulated services run in Docker — so deploy(Stack) boots local simulators and the url output points at http://localhost:<port>:

const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Cloudflare.providers(),
dev: true,
});

Every assertion below works unchanged against the local stack. To keep one file that runs locally on your laptop and live in CI, omit the flag and set ALCHEMY_DEV=1 in your shell instead — see Test harness → dev for the full semantics and Tutorial Part 4 for the walkthrough.

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.