Skip to content

Part 3: Testing

In Part 2 you deployed a Worker with R2 Bucket bindings. Now you’ll write integration tests that deploy the stack, hit the live Worker over HTTP, and verify it works.

Alchemy ships test utilities for Bun that wrap bun:test with Effect support. Create an empty test file:

test/integ.test.ts
import { beforeAll, deploy, expect, test } from "alchemy/Test/Bun";
import * as Effect from "effect/Effect";
import Stack from "../alchemy.run.ts";

Use beforeAll with deploy to deploy your stack once before any tests run:

test/integ.test.ts
import { beforeAll, deploy, expect, test } from "alchemy/Test/Bun";
import * as Effect from "effect/Effect";
import Stack from "../alchemy.run.ts";
const stack = beforeAll(deploy(Stack));

deploy(Stack) returns an Effect that plans and applies the stack. beforeAll runs it once, then returns a lazy accessor you can yield* inside each test to get the stack outputs.

Write your first test. Use yield* stack to get the outputs you returned from your Stack in Part 2:

test/integ.test.ts
import { beforeAll, deploy, expect, test } from "alchemy/Test/Bun";
import * as Effect from "effect/Effect";
import Stack from "../alchemy.run.ts";
const stack = beforeAll(deploy(Stack));
test(
"worker returns a url",
Effect.gen(function* () {
const { url } = yield* stack;
expect(url).toBeString();
}),
);

test(name, effect) wraps bun:test — you write an Effect generator instead of an async function.

Terminal window
bun test test/integ.test.ts

The first run deploys the stack (or reuses the existing one if already deployed). Subsequent runs are fast because Alchemy diffs and skips unchanged resources.

The basic test just checks that a URL exists. Let’s verify the Worker actually handles requests:

test/integ.test.ts
import { beforeAll, deploy, 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));
test(
"worker returns a url",
Effect.gen(function* () {
const { url } = yield* stack;
expect(url).toBeString();
}),
);
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);
}),
);

HttpClient is provided automatically by the test harness — no extra setup needed.

Right now the stack stays deployed after tests finish. That’s great locally — you can re-run tests instantly against the already-deployed stack. But on CI you want to clean up.

Add afterAll with destroy, using skipIf to only tear down when CI is set:

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));
  • LocallyCI is not set, so skipIf skips the destroy. You iterate fast against the live stack.
  • On CI — set CI=true and the stack is torn down automatically after tests complete.

You now have:

  • beforeAll(deploy(Stack)) to deploy once before tests
  • yield* stack to access outputs in each test
  • HTTP assertions using Effect’s HttpClient
  • afterAll.skipIf(!process.env.CI)(destroy(Stack)) for automatic cleanup on CI with fast iteration locally

In Part 4, you’ll run your stack locally with alchemy dev for instant feedback during development.