| 1 | --- |
| 2 | title: Testing a Stack |
| 3 | description: Deploy your real Stack once per suite, drive it over HTTP, tear it down. |
| 4 | --- |
| 5 | |
| 6 | 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](/testing/test-harness), the model at [Testing](/testing), and the cloud-specific step-by-steps are [Cloudflare Tutorial Part 3](/cloudflare/tutorial/part-3) and [AWS Tutorial Part 3](/aws/tutorial/part-3). |
| 7 | |
| 8 | ## Set up the harness |
| 9 | |
| 10 | Configure Providers and state once per file — `Test.make` returns everything the file needs: |
| 11 | |
| 12 | ```typescript |
| 13 | import * as Alchemy from "alchemy"; |
| 14 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 15 | import * as Drizzle from "alchemy/Drizzle"; |
| 16 | import * as Neon from "alchemy/Neon"; |
| 17 | import * as Test from "alchemy/Test/Bun"; |
| 18 | import { expect } from "bun:test"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Layer from "effect/Layer"; |
| 21 | import Stack from "../alchemy.run.ts"; |
| 22 | |
| 23 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 24 | providers: Layer.mergeAll( |
| 25 | Cloudflare.providers(), |
| 26 | Drizzle.providers(), |
| 27 | Neon.providers(), |
| 28 | ), |
| 29 | state: Alchemy.localState(), |
| 30 | }); |
| 31 | ``` |
| 32 | |
| 33 | Bun vs Vitest is a two-line import swap — see [Bun vs Vitest](/testing/test-harness#bun-vs-vitest). |
| 34 | |
| 35 | ## Deploy once with beforeAll |
| 36 | |
| 37 | `beforeAll(deploy(Stack))` deploys once for the whole file and returns a lazy accessor every test can `yield*`: |
| 38 | |
| 39 | ```typescript |
| 40 | const stack = beforeAll(deploy(Stack)); |
| 41 | |
| 42 | test( |
| 43 | "worker returns a url", |
| 44 | Effect.gen(function* () { |
| 45 | const { url } = yield* stack; |
| 46 | |
| 47 | expect(url).toBeString(); |
| 48 | }), |
| 49 | ); |
| 50 | ``` |
| 51 | |
| 52 | The default hook timeout is 120s — give slow stacks more room: |
| 53 | |
| 54 | ```typescript |
| 55 | // This stack deploys a Container whose image build + push can take |
| 56 | // well over the default 120s hook budget. |
| 57 | const stack = beforeAll(deploy(Stack), { timeout: 600_000 }); |
| 58 | ``` |
| 59 | |
| 60 | `beforeAll` accessors compose — seed data in a second `beforeAll` that tests `yield*` alongside the stack; see [Hooks](/testing/test-harness#hooks). |
| 61 | |
| 62 | ## Run it |
| 63 | |
| 64 | Run the suite with your runner: |
| 65 | |
| 66 | ```sh |
| 67 | bun test test/integ.test.ts |
| 68 | ``` |
| 69 | |
| 70 | The first run deploys; re-runs diff and skip unchanged Resources, and tests default to the isolated `test_$USER` [stage](/environments/stages) so they never clobber your `live_$USER` / `dev_$USER` deployments. |
| 71 | |
| 72 | ## Run it locally with dev mode |
| 73 | |
| 74 | The same suite can run entirely on your machine. `dev: true` gives the file [`alchemy dev`](/environments/local-development) 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>`: |
| 75 | |
| 76 | ```typescript |
| 77 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 78 | providers: Cloudflare.providers(), |
| 79 | dev: true, |
| 80 | }); |
| 81 | ``` |
| 82 | |
| 83 | 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](/testing/test-harness#dev) for the full semantics and [Tutorial Part 4](/cloudflare/tutorial/part-4) for the walkthrough. |
| 84 | |
| 85 | ## Drive the live URL |
| 86 | |
| 87 | `HttpClient` is already in scope in every test Effect: |
| 88 | |
| 89 | ```typescript |
| 90 | import * as HttpBody from "effect/http/HttpBody"; |
| 91 | import * as HttpClient from "effect/http/HttpClient"; |
| 92 | |
| 93 | test( |
| 94 | "PUT and GET round-trip an object", |
| 95 | Effect.gen(function* () { |
| 96 | const { url } = yield* stack; |
| 97 | |
| 98 | const put = yield* HttpClient.put(`${url}/hello.txt`, { |
| 99 | body: HttpBody.text("Hello, World!"), |
| 100 | }); |
| 101 | expect(put.status).toBe(201); |
| 102 | |
| 103 | const get = yield* HttpClient.get(`${url}/hello.txt`); |
| 104 | expect(yield* get.text).toBe("Hello, World!"); |
| 105 | }), |
| 106 | ); |
| 107 | ``` |
| 108 | |
| 109 | How the client is wired is covered in [Test harness](/testing/test-harness#httpclient-is-in-scope). |
| 110 | |
| 111 | ## Retry the first request |
| 112 | |
| 113 | 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: |
| 114 | |
| 115 | ```typescript |
| 116 | const { getWhenReady } = Test; |
| 117 | |
| 118 | test( |
| 119 | "worker answers once the edge converges", |
| 120 | Effect.gen(function* () { |
| 121 | const { url } = yield* stack; |
| 122 | |
| 123 | const response = yield* getWhenReady(url); |
| 124 | expect(response.status).toBe(200); |
| 125 | }), |
| 126 | ); |
| 127 | ``` |
| 128 | |
| 129 | `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: |
| 130 | |
| 131 | ```typescript |
| 132 | import * as Schedule from "effect/Schedule"; |
| 133 | |
| 134 | const response = yield* HttpClient.get(`${url}/health`).pipe( |
| 135 | Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }), |
| 136 | ); |
| 137 | ``` |
| 138 | |
| 139 | ## Poll async effects |
| 140 | |
| 141 | Queues drain, workflows complete, and crons fire asynchronously — poll with a bounded declarative schedule, never a deadline loop: |
| 142 | |
| 143 | ```typescript |
| 144 | const status = yield* HttpClient.get(`${url}/workflow/status/${instanceId}`).pipe( |
| 145 | Effect.flatMap((res) => res.json), |
| 146 | Effect.map((body) => body as { status: string }), |
| 147 | Effect.repeat({ |
| 148 | schedule: Schedule.spaced("2 seconds"), |
| 149 | until: (s) => s.status === "complete", |
| 150 | times: 30, |
| 151 | }), |
| 152 | ); |
| 153 | ``` |
| 154 | |
| 155 | Bounded `times` fails fast instead of leaking into the runner timeout; `while (Date.now() < deadline)` loops ignore interruption. |
| 156 | |
| 157 | ## Tear down — or don't |
| 158 | |
| 159 | Destroy on CI, keep the Stack alive locally for fast iteration: |
| 160 | |
| 161 | ```typescript |
| 162 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); // keep alive with NO_DESTROY=1 |
| 163 | afterAll.skipIf(!process.env.CI)(destroy(Stack)); // destroy on CI only |
| 164 | ``` |
| 165 | |
| 166 | Locally, the deployed Stack plus cached state make re-runs near-instant. Hook semantics live in [Hooks](/testing/test-harness#hooks). |
| 167 | |
| 168 | ## Run against an existing deployment |
| 169 | |
| 170 | Already ran `alchemy deploy` and just want to hit the URL? Swap the deploy for a read of the outputs: |
| 171 | |
| 172 | ```typescript |
| 173 | const stack = beforeAll( |
| 174 | process.env.SKIP_DEPLOY |
| 175 | ? Effect.succeed({ url: process.env.STACK_URL! }) |
| 176 | : deploy(Stack), |
| 177 | ); |
| 178 | ``` |
| 179 | |
| 180 | ## Share one Stack across files |
| 181 | |
| 182 | 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. The default `test_$USER` stage already does this for a single developer; pin an explicit stage when files must share a stack across users or CI jobs: |
| 183 | |
| 184 | ```typescript |
| 185 | // test/api.integ.test.ts AND test/queue.integ.test.ts |
| 186 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 187 | providers: Cloudflare.providers(), |
| 188 | state: Cloudflare.state(), // remote, shared across files and runners |
| 189 | stage: "shared", // same stage → same Stack instance |
| 190 | }); |
| 191 | ``` |
| 192 | |
| 193 | See [State store](/state-store) for choosing a remote store. |
| 194 | |
| 195 | ## CI |
| 196 | |
| 197 | Give every PR its own stage so suites run in parallel against one account: |
| 198 | |
| 199 | ```typescript |
| 200 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 201 | providers: Cloudflare.providers(), |
| 202 | stage: `pr-${process.env.PR_NUMBER}`, |
| 203 | }); |
| 204 | ``` |
| 205 | |
| 206 | Harness `deploy`/`destroy` never prompt for approval — `--yes` is only for CI workflows invoking the CLI (`alchemy deploy --stage pr-42 --yes`); see [Stage](/testing/test-harness#stage) and the [CI guide](/environments/ci) for full workflow YAML. |
| 207 | |
| 208 | ## Where next |
| 209 | |
| 210 | - [Testing](/testing) — the model behind the harness. |
| 211 | - [Test harness](/testing/test-harness) — every helper, hook, and option on `Test.make`. |
| 212 | - [Testing Providers](/testing/testing-providers) — lifecycle-testing a custom provider with `test.provider`. |
| 213 | - [CI](/environments/ci) — running these suites in CI with per-PR stages. |
| 214 | - [Cloudflare Tutorial Part 3](/cloudflare/tutorial/part-3) / [AWS Tutorial Part 3](/aws/tutorial/part-3) — your first integration test, step by step. |
| 215 | - [State store](/state-store) — local vs remote state. |
| 216 | - [Stages](/environments/stages) — isolating deployments per stage. |