| 1 | --- |
| 2 | title: "Part 3: Testing" |
| 3 | description: Write integration tests that deploy your stack and make HTTP requests against your live Lambda Function URL. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | In [Part 2](/aws/tutorial/part-2) you deployed a Lambda with S3 |
| 9 | bindings. Now you'll write integration tests that deploy the stack, |
| 10 | hit the live Function URL over HTTP, and verify it works. |
| 11 | |
| 12 | ## Create the test file |
| 13 | |
| 14 | Alchemy ships test utilities for Bun that wrap `bun:test` with Effect |
| 15 | support. Configure providers (and state) once at the top of the file |
| 16 | with `Test.make({...})` — the same `AWS.providers()` / `AWS.state()` |
| 17 | you used in your `Stack`: |
| 18 | |
| 19 | ```typescript |
| 20 | // test/integ.test.ts |
| 21 | import * as AWS from "alchemy/AWS"; |
| 22 | import * as Test from "alchemy/Test/Bun"; |
| 23 | import { expect } from "bun:test"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import Stack from "../alchemy.run.ts"; |
| 26 | |
| 27 | const { 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 |
| 35 | runner pieces. |
| 36 | |
| 37 | :::note[Using Vitest?] |
| 38 | `alchemy/Test/Vitest` exposes the exact same API — swap the import |
| 39 | and 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 | |
| 45 | Use `beforeAll` with `deploy` to deploy your stack once before any |
| 46 | tests run: |
| 47 | |
| 48 | ```diff lang="typescript" |
| 49 | const { 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 | |
| 63 | Write your first test. Use `yield* stack` to get the outputs you |
| 64 | returned from your Stack in Part 2: |
| 65 | |
| 66 | ```diff lang="typescript" |
| 67 | const 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 |
| 80 | instead of an async function. |
| 81 | |
| 82 | ## Run the tests |
| 83 | |
| 84 | ```sh |
| 85 | bun test test/integ.test.ts |
| 86 | ``` |
| 87 | |
| 88 | The first run deploys the stack (or reuses the existing one if |
| 89 | already deployed). Subsequent runs are fast because Alchemy diffs and |
| 90 | skips unchanged resources. |
| 91 | |
| 92 | :::note[Which stage do tests deploy to?] |
| 93 | The test harness defaults to `test_$USER` (e.g. `test_sam`), |
| 94 | separate from the `live_$USER` / `dev_$USER` stages your |
| 95 | `alchemy deploy` / `alchemy dev` runs target — so tests never |
| 96 | clobber your working deployment, and two people running the suite |
| 97 | don't collide. More on stages in [Part 4](/aws/tutorial/part-4). |
| 98 | ::: |
| 99 | |
| 100 | ## Add HTTP assertions |
| 101 | |
| 102 | The basic test just checks that a URL exists. Let's verify the |
| 103 | function actually handles requests: |
| 104 | |
| 105 | ```diff lang="typescript" |
| 106 | // test/integ.test.ts |
| 107 | import * as AWS from "alchemy/AWS"; |
| 108 | import * as Test from "alchemy/Test/Bun"; |
| 109 | import { expect } from "bun:test"; |
| 110 | import * as Effect from "effect/Effect"; |
| 111 | +import * as HttpBody from "effect/http/HttpBody"; |
| 112 | +import * as HttpClient from "effect/http/HttpClient"; |
| 113 | import Stack from "../alchemy.run.ts"; |
| 114 | |
| 115 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 116 | providers: AWS.providers(), |
| 117 | state: AWS.state(), |
| 118 | }); |
| 119 | |
| 120 | const 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 |
| 156 | setup needed. |
| 157 | |
| 158 | :::tip[Cold starts] |
| 159 | A freshly created Function URL can take a few seconds before it |
| 160 | serves its first request — DNS propagation, IAM propagation, and the |
| 161 | function's own cold start all stack up. If the very first request in |
| 162 | a fresh stage flakes, retry it with a bounded schedule: |
| 163 | |
| 164 | ```typescript |
| 165 | import * as Schedule from "effect/Schedule"; |
| 166 | |
| 167 | const 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 | |
| 175 | The PUT test writes `hello.txt` into the Bucket, so the Bucket is no |
| 176 | longer empty when the tests finish. S3 refuses to delete a Bucket |
| 177 | that still contains objects, and Alchemy keeps that protection by |
| 178 | default: destroying a non-empty Bucket fails with `BucketNotEmpty` and |
| 179 | leaves the Bucket and its objects in place. |
| 180 | |
| 181 | This Bucket only ever holds test data, so opt in to deleting its |
| 182 | objects 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 | |
| 195 | With `forceDestroy: true`, Alchemy empties the Bucket before deleting |
| 196 | it. Reserve it for Buckets whose contents are disposable (test |
| 197 | fixtures, caches, previews); leave it off for Buckets that hold data |
| 198 | you need to keep. |
| 199 | |
| 200 | ## Destroy after tests on CI |
| 201 | |
| 202 | Right now the stack stays deployed after tests finish. That's great |
| 203 | locally — you can re-run tests instantly against the already-deployed |
| 204 | stack. But on CI you want to clean up. |
| 205 | |
| 206 | Add `afterAll` with `destroy`, using `skipIf` to only tear down when |
| 207 | `CI` is set: |
| 208 | |
| 209 | ```diff lang="typescript" |
| 210 | const stack = beforeAll(deploy(Stack)); |
| 211 | |
| 212 | +afterAll.skipIf(!process.env.CI)(destroy(Stack)); |
| 213 | |
| 214 | test(/* .. */); |
| 215 | |
| 216 | test(/* .. */); |
| 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 | |
| 226 | You 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 | |
| 237 | In [Part 4](/aws/tutorial/part-4), you'll use stages to give every |
| 238 | developer, environment, and pull request its own isolated copy of the |
| 239 | stack. |