| 1 | --- |
| 2 | title: "Part 3: Testing" |
| 3 | description: Write integration tests that deploy your stack and make HTTP requests against your live Worker. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | In [Part 2](/cloudflare/tutorial/part-2) you deployed a Worker with R2 Bucket |
| 9 | bindings. Now you'll write integration tests that deploy the stack, |
| 10 | hit the live Worker 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 `Cloudflare.providers()` / |
| 17 | `Cloudflare.state()` you used in your `Stack`: |
| 18 | |
| 19 | ```typescript |
| 20 | // test/integ.test.ts |
| 21 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 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: Cloudflare.providers(), |
| 29 | state: Cloudflare.state(), |
| 30 | }); |
| 31 | ``` |
| 32 | |
| 33 | `expect` (and any other `bun:test` helpers like `describe`) come |
| 34 | from `bun:test` directly — `Test.make` only provides the |
| 35 | Effect-aware test runner pieces. |
| 36 | |
| 37 | ## Deploy the stack before tests |
| 38 | |
| 39 | Use `beforeAll` with `deploy` to deploy your stack once before any |
| 40 | tests run: |
| 41 | |
| 42 | ```diff lang="typescript" |
| 43 | // test/integ.test.ts |
| 44 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 45 | import * as Test from "alchemy/Test/Bun"; |
| 46 | import { expect } from "bun:test"; |
| 47 | import * as Effect from "effect/Effect"; |
| 48 | import Stack from "../alchemy.run.ts"; |
| 49 | |
| 50 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 51 | providers: Cloudflare.providers(), |
| 52 | state: Cloudflare.state(), |
| 53 | }); |
| 54 | |
| 55 | +const stack = beforeAll(deploy(Stack)); |
| 56 | ``` |
| 57 | |
| 58 | `deploy(Stack)` returns an Effect that plans and applies the stack. |
| 59 | `beforeAll` runs it once, then returns a lazy accessor you can |
| 60 | `yield*` inside each test to get the stack outputs. |
| 61 | |
| 62 | ## Access the stack outputs |
| 63 | |
| 64 | Write your first test. Use `yield* stack` to get the outputs you |
| 65 | returned from your Stack in Part 2: |
| 66 | |
| 67 | ```diff lang="typescript" |
| 68 | // test/integ.test.ts |
| 69 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 70 | import * as Test from "alchemy/Test/Bun"; |
| 71 | import { expect } from "bun:test"; |
| 72 | import * as Effect from "effect/Effect"; |
| 73 | import Stack from "../alchemy.run.ts"; |
| 74 | |
| 75 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 76 | providers: Cloudflare.providers(), |
| 77 | state: Cloudflare.state(), |
| 78 | }); |
| 79 | |
| 80 | const stack = beforeAll(deploy(Stack)); |
| 81 | |
| 82 | +test( |
| 83 | + "worker returns a url", |
| 84 | + Effect.gen(function* () { |
| 85 | + const { url } = yield* stack; |
| 86 | + |
| 87 | + expect(url).toBeString(); |
| 88 | + }), |
| 89 | +); |
| 90 | ``` |
| 91 | |
| 92 | `test(name, effect)` wraps `bun:test` — you write an Effect generator |
| 93 | instead of an async function. |
| 94 | |
| 95 | ## Run the tests |
| 96 | |
| 97 | ```sh |
| 98 | bun test test/integ.test.ts |
| 99 | ``` |
| 100 | |
| 101 | The first run deploys the stack (or reuses the existing one if |
| 102 | already deployed). Subsequent runs are fast because Alchemy diffs |
| 103 | and skips unchanged resources. |
| 104 | |
| 105 | ## Add HTTP assertions |
| 106 | |
| 107 | The basic test just checks that a URL exists. Let's verify the Worker |
| 108 | actually handles requests: |
| 109 | |
| 110 | ```diff lang="typescript" |
| 111 | // test/integ.test.ts |
| 112 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 113 | import * as Test from "alchemy/Test/Bun"; |
| 114 | import { expect } from "bun:test"; |
| 115 | import * as Effect from "effect/Effect"; |
| 116 | +import * as HttpBody from "effect/http/HttpBody"; |
| 117 | +import * as HttpClient from "effect/http/HttpClient"; |
| 118 | import Stack from "../alchemy.run.ts"; |
| 119 | |
| 120 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 121 | providers: Cloudflare.providers(), |
| 122 | state: Cloudflare.state(), |
| 123 | }); |
| 124 | |
| 125 | const stack = beforeAll(deploy(Stack)); |
| 126 | |
| 127 | -test( |
| 128 | - "worker returns a url", |
| 129 | - Effect.gen(function* () { |
| 130 | - const { url } = yield* stack; |
| 131 | - |
| 132 | - expect(url).toBeString(); |
| 133 | - }), |
| 134 | -); |
| 135 | +test( |
| 136 | + "PUT and GET round-trip an object", |
| 137 | + Effect.gen(function* () { |
| 138 | + const { url } = yield* stack; |
| 139 | + |
| 140 | + const put = yield* HttpClient.put(`${url}/hello.txt`, { |
| 141 | + body: HttpBody.text("Hello, World!"), |
| 142 | + }); |
| 143 | + expect(put.status).toBe(201); |
| 144 | + |
| 145 | + const get = yield* HttpClient.get(`${url}/hello.txt`); |
| 146 | + expect(yield* get.text).toBe("Hello, World!"); |
| 147 | + }), |
| 148 | +); |
| 149 | + |
| 150 | +test( |
| 151 | + "GET missing key returns 404", |
| 152 | + Effect.gen(function* () { |
| 153 | + const { url } = yield* stack; |
| 154 | + const response = yield* HttpClient.get(`${url}/no-such-key`); |
| 155 | + expect(response.status).toBe(404); |
| 156 | + }), |
| 157 | +); |
| 158 | ``` |
| 159 | |
| 160 | `HttpClient` is provided automatically by the test harness — no |
| 161 | extra setup needed. |
| 162 | |
| 163 | ## Allow the Bucket to be destroyed with objects in it |
| 164 | |
| 165 | The PUT test writes `hello.txt` into the Bucket, so the Bucket is no |
| 166 | longer empty when the tests finish. R2 refuses to delete a Bucket |
| 167 | that still contains objects, and Alchemy keeps that protection by |
| 168 | default: destroying a non-empty Bucket fails with `BucketNotEmpty` and |
| 169 | leaves the Bucket and its objects in place. |
| 170 | |
| 171 | This Bucket only ever holds test data, so opt in to deleting its |
| 172 | objects on destroy with `forceDestroy: true`: |
| 173 | |
| 174 | ```diff lang="typescript" |
| 175 | // src/bucket.ts |
| 176 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 177 | |
| 178 | -export const Bucket = Cloudflare.R2.Bucket("Bucket"); |
| 179 | +export const Bucket = Cloudflare.R2.Bucket("Bucket", { |
| 180 | + forceDestroy: true, |
| 181 | +}); |
| 182 | ``` |
| 183 | |
| 184 | With `forceDestroy: true`, Alchemy empties the Bucket before deleting |
| 185 | it. Reserve it for Buckets whose contents are disposable (test |
| 186 | fixtures, caches, previews); leave it off for Buckets that hold data |
| 187 | you need to keep. |
| 188 | |
| 189 | ## Destroy after tests on CI |
| 190 | |
| 191 | Right now the stack stays deployed after tests finish. That's great |
| 192 | locally — you can re-run tests instantly against the already-deployed |
| 193 | stack. But on CI you want to clean up. |
| 194 | |
| 195 | Add `afterAll` with `destroy`, using `skipIf` to only tear down when |
| 196 | `CI` is set: |
| 197 | |
| 198 | ```diff lang="typescript" |
| 199 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 200 | import * as Test from "alchemy/Test/Bun"; |
| 201 | import { expect } from "bun:test"; |
| 202 | import * as Effect from "effect/Effect"; |
| 203 | import * as HttpBody from "effect/http/HttpBody"; |
| 204 | import * as HttpClient from "effect/http/HttpClient"; |
| 205 | import Stack from "../alchemy.run.ts"; |
| 206 | |
| 207 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 208 | providers: Cloudflare.providers(), |
| 209 | state: Cloudflare.state(), |
| 210 | }); |
| 211 | |
| 212 | const stack = beforeAll(deploy(Stack)); |
| 213 | |
| 214 | +afterAll.skipIf(!process.env.CI)(destroy(Stack)); |
| 215 | |
| 216 | test(/* .. */); |
| 217 | |
| 218 | test(/* .. */); |
| 219 | ``` |
| 220 | |
| 221 | - **Locally** — `CI` is not set, so `skipIf` skips the destroy. You |
| 222 | iterate fast against the live stack. |
| 223 | - **On CI** — set `CI=true` and the stack is torn down automatically |
| 224 | after tests complete. |
| 225 | |
| 226 | ## Recap |
| 227 | |
| 228 | You now have: |
| 229 | |
| 230 | - `Test.make({ providers, state })` to wire your provider Layer and |
| 231 | state store into the test runner once per file |
| 232 | - `beforeAll(deploy(Stack))` to deploy once before tests |
| 233 | - `yield* stack` to access outputs in each test |
| 234 | - HTTP assertions using Effect's `HttpClient` |
| 235 | - `forceDestroy: true` so destroy can delete a Bucket that tests wrote to |
| 236 | - `afterAll.skipIf(!process.env.CI)(destroy(Stack))` for automatic |
| 237 | cleanup on CI with fast iteration locally |
| 238 | |
| 239 | In [Part 4](/cloudflare/tutorial/part-4), you'll run your stack locally with |
| 240 | `alchemy dev` for instant feedback during development. |