1---
2title: "Part 3: Testing"
3description: Write integration tests that deploy your stack and make HTTP requests against your live Lambda Function URL.
4sidebar:
5 order: 3
6---
7
8In [Part 2](/aws/tutorial/part-2) you deployed a Lambda with S3
9bindings. Now you'll write integration tests that deploy the stack,
10hit the live Function URL over HTTP, and verify it works.
11
12## Create the test file
13
14Alchemy ships test utilities for Bun that wrap `bun:test` with Effect
15support. Configure providers (and state) once at the top of the file
16with `Test.make({...})` — the same `AWS.providers()` / `AWS.state()`
17you used in your `Stack`:
18
19```typescript
20// test/integ.test.ts
21import * as AWS from "alchemy/AWS";
22import * as Test from "alchemy/Test/Bun";
23import { expect } from "bun:test";
24import * as Effect from "effect/Effect";
25import Stack from "../alchemy.run.ts";
26
27const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
28 providers: AWS.providers(),
29 state: AWS.state(),
30});
31```
32
33`expect` (and any other `bun:test` helpers like `describe`) come from
34`bun:test` directly — `Test.make` only provides the Effect-aware test
35runner pieces.
36
37:::note[Using Vitest?]
38`alchemy/Test/Vitest` exposes the exact same API — swap the import
39and take `expect` from `@effect/vitest` instead. See
40[Test harness → Bun vs Vitest](/testing/test-harness#bun-vs-vitest).
41:::
42
43## Deploy the stack before tests
44
45Use `beforeAll` with `deploy` to deploy your stack once before any
46tests run:
47
48```diff lang="typescript"
49const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
50 providers: AWS.providers(),
51 state: AWS.state(),
52});
53
54+const stack = beforeAll(deploy(Stack));
55```
56
57`deploy(Stack)` returns an Effect that plans and applies the stack.
58`beforeAll` runs it once, then returns a lazy accessor you can
59`yield*` inside each test to get the stack outputs.
60
61## Access the stack outputs
62
63Write your first test. Use `yield* stack` to get the outputs you
64returned from your Stack in Part 2:
65
66```diff lang="typescript"
67const stack = beforeAll(deploy(Stack));
68
69+test(
70+ "stack exposes a function url",
71+ Effect.gen(function* () {
72+ const { url } = yield* stack;
73+
74+ expect(url).toBeString();
75+ }),
76+);
77```
78
79`test(name, effect)` wraps `bun:test` — you write an Effect generator
80instead of an async function.
81
82## Run the tests
83
84```sh
85bun test test/integ.test.ts
86```
87
88The first run deploys the stack (or reuses the existing one if
89already deployed). Subsequent runs are fast because Alchemy diffs and
90skips unchanged resources.
91
92:::note[Which stage do tests deploy to?]
93The test harness defaults to `test_$USER` (e.g. `test_sam`),
94separate from the `live_$USER` / `dev_$USER` stages your
95`alchemy deploy` / `alchemy dev` runs target — so tests never
96clobber your working deployment, and two people running the suite
97don't collide. More on stages in [Part 4](/aws/tutorial/part-4).
98:::
99
100## Add HTTP assertions
101
102The basic test just checks that a URL exists. Let's verify the
103function actually handles requests:
104
105```diff lang="typescript"
106// test/integ.test.ts
107import * as AWS from "alchemy/AWS";
108import * as Test from "alchemy/Test/Bun";
109import { expect } from "bun:test";
110import * as Effect from "effect/Effect";
111+import * as HttpBody from "effect/http/HttpBody";
112+import * as HttpClient from "effect/http/HttpClient";
113import Stack from "../alchemy.run.ts";
114
115const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
116 providers: AWS.providers(),
117 state: AWS.state(),
118});
119
120const stack = beforeAll(deploy(Stack));
121
122-test(
123- "stack exposes a function url",
124- Effect.gen(function* () {
125- const { url } = yield* stack;
126-
127- expect(url).toBeString();
128- }),
129-);
130+test(
131+ "PUT and GET round-trip an object",
132+ Effect.gen(function* () {
133+ const { url } = yield* stack;
134+
135+ const put = yield* HttpClient.put(`${url}/hello.txt`, {
136+ body: HttpBody.text("Hello, World!"),
137+ });
138+ expect(put.status).toBe(204);
139+
140+ const get = yield* HttpClient.get(`${url}/hello.txt`);
141+ expect(yield* get.text).toBe("Hello, World!");
142+ }),
143+);
144+
145+test(
146+ "GET missing key returns 404",
147+ Effect.gen(function* () {
148+ const { url } = yield* stack;
149+ const response = yield* HttpClient.get(`${url}/no-such-key`);
150+ expect(response.status).toBe(404);
151+ }),
152+);
153```
154
155`HttpClient` is provided automatically by the test harness — no extra
156setup needed.
157
158:::tip[Cold starts]
159A freshly created Function URL can take a few seconds before it
160serves its first request — DNS propagation, IAM propagation, and the
161function's own cold start all stack up. If the very first request in
162a fresh stage flakes, retry it with a bounded schedule:
163
164```typescript
165import * as Schedule from "effect/Schedule";
166
167const get = yield* HttpClient.get(`${url}/hello.txt`).pipe(
168 Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }),
169);
170```
171:::
172
173## Allow the Bucket to be destroyed with objects in it
174
175The PUT test writes `hello.txt` into the Bucket, so the Bucket is no
176longer empty when the tests finish. S3 refuses to delete a Bucket
177that still contains objects, and Alchemy keeps that protection by
178default: destroying a non-empty Bucket fails with `BucketNotEmpty` and
179leaves the Bucket and its objects in place.
180
181This Bucket only ever holds test data, so opt in to deleting its
182objects on destroy with `forceDestroy: true`:
183
184```diff lang="typescript"
185// src/api.ts
186 Effect.gen(function* () {
187- const bucket = yield* S3.Bucket("Bucket");
188+ const bucket = yield* S3.Bucket("Bucket", {
189+ forceDestroy: true,
190+ });
191 const putObject = yield* S3.PutObject(bucket);
192 const getObject = yield* S3.GetObject(bucket);
193```
194
195With `forceDestroy: true`, Alchemy empties the Bucket before deleting
196it. Reserve it for Buckets whose contents are disposable (test
197fixtures, caches, previews); leave it off for Buckets that hold data
198you need to keep.
199
200## Destroy after tests on CI
201
202Right now the stack stays deployed after tests finish. That's great
203locally — you can re-run tests instantly against the already-deployed
204stack. But on CI you want to clean up.
205
206Add `afterAll` with `destroy`, using `skipIf` to only tear down when
207`CI` is set:
208
209```diff lang="typescript"
210const stack = beforeAll(deploy(Stack));
211
212+afterAll.skipIf(!process.env.CI)(destroy(Stack));
213
214test(/* .. */);
215
216test(/* .. */);
217```
218
219- **Locally** — `CI` is not set, so `skipIf` skips the destroy. You
220 iterate fast against the live stack.
221- **On CI** — set `CI=true` and the stack is torn down automatically
222 after tests complete.
223
224## Recap
225
226You now have:
227
228- `Test.make({ providers, state })` to wire your provider Layer and
229 state store into the test runner once per file
230- `beforeAll(deploy(Stack))` to deploy once before tests
231- `yield* stack` to access outputs in each test
232- HTTP assertions using Effect's `HttpClient`
233- `forceDestroy: true` so destroy can delete a Bucket that tests wrote to
234- `afterAll.skipIf(!process.env.CI)(destroy(Stack))` for automatic
235 cleanup on CI with fast iteration locally
236
237In [Part 4](/aws/tutorial/part-4), you'll use stages to give every
238developer, environment, and pull request its own isolated copy of the
239stack.