Testing
This guide shows how to write integration tests that deploy your stack, make HTTP requests against your live Worker, and tear everything down when finished.
-
Define the Worker under test
Create a Worker with an R2 bucket binding and two routes —
PUTto store an object andGETto retrieve it.src/Bucket.ts import * as Cloudflare from "alchemy/Cloudflare";export const Bucket = Cloudflare.R2Bucket("Bucket");src/Api.ts import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./Bucket.ts";export default class Api extends Cloudflare.Worker<Api>()("Api",{ main: import.meta.path },Effect.gen(function* () {const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);return {fetch: Effect.gen(function* () {const request = yield* HttpServerRequest;const key = request.url.slice(1); // strip leading "/"if (request.method === "PUT") {yield* bucket.put(key, yield* request.text);return HttpServerResponse.empty({ status: 201 });}const obj = yield* bucket.get(key);if (!obj) return HttpServerResponse.empty({ status: 404 });return HttpServerResponse.text(yield* obj.text());}),};}).pipe(Effect.provide(Layer.mergeAll(Cloudflare.R2BucketBindingLive))),) {} -
Create the Stack
Wire the Worker into your
alchemy.run.tsand expose its URL as a stack output.alchemy.run.ts import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import Api from "./src/Api.ts";export default Alchemy.Stack("MyApp",{ providers: Cloudflare.providers() },Effect.gen(function* () {const api = yield* Api;return { url: api.url };}),); -
Set up deploy and destroy lifecycle
Import the test utilities from
alchemy/Test/Bun. These wrapbun:testhooks so your Effects run automatically with all required platform layers, state management, and config.import {afterAll,beforeAll,deploy,destroy,expect,test,} from "alchemy/Test/Bun";import Stack from "../alchemy.run.ts";const stack = beforeAll(deploy(Stack));afterAll.skipIf(!process.env.CI)(destroy(Stack));Three things are happening here:
deploy(Stack)returns an Effect that plans and applies the stack, resolving to its outputs.beforeAll(effect)runs that Effect once before all tests, stores the result, and returnsEffect.sync(() => result)— a lazy accessor youyield*inside each test to get the stack outputs.afterAll.skipIf(!process.env.CI)skips the destroy when running locally so you can iterate quickly against an already-deployed stack. On CI, the destroy always runs.
-
Write tests with HttpClient
Each
test(name, effect)wrapsbun.testand provides an Effect context withHttpClientavailable. UseHttpClient.getandHttpClient.postdirectly — no need to resolve a service first.test/integ.test.ts import {afterAll,beforeAll,deploy,destroy,expect,test,} from "alchemy/Test/Bun";import * as Effect from "effect/Effect";import * as HttpClient from "effect/unstable/http/HttpClient";import * as HttpBody from "effect/unstable/http/HttpBody";import Stack from "../alchemy.run.ts";const stack = beforeAll(deploy(Stack));afterAll.skipIf(!process.env.CI)(destroy(Stack));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!");}),);test("GET missing key returns 404",Effect.gen(function* () {const { url } = yield* stack;const response = yield* HttpClient.get(`${url}/no-such-key`);expect(response.status).toBe(404);}),); -
Run the tests
Terminal window bun test test/integ.test.tsOn CI, set
CI=truesoafterAlltears down the stack. Locally, the destroy is skipped so you can re-run tests against the already-deployed stack for fast iteration.