Skip to content

Part 3: Testing

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

Alchemy ships test utilities for Bun that wrap bun:test with Effect support. Configure providers (and state) once at the top of the file with Test.make({...}) — the same AWS.providers() / AWS.state() you used in your Stack:

test/integ.test.ts
import * as AWS from "alchemy/AWS";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: AWS.providers(),
state: AWS.state(),
});

expect (and any other bun:test helpers like describe) come from bun:test directly — Test.make only provides the Effect-aware test runner pieces.

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

const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: AWS.providers(),
state: AWS.state(),
});
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:

const stack = beforeAll(deploy(Stack));
test(
"stack exposes a function 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 function actually handles requests:

test/integ.test.ts
import * as AWS from "alchemy/AWS";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import * as HttpBody from "effect/http/HttpBody";
import * as HttpClient from "effect/http/HttpClient";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: AWS.providers(),
state: AWS.state(),
});
const stack = beforeAll(deploy(Stack));
test(
"stack exposes a function 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(204);
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.

Allow the Bucket to be destroyed with objects in it

Section titled “Allow the Bucket to be destroyed with objects in it”

The PUT test writes hello.txt into the Bucket, so the Bucket is no longer empty when the tests finish. S3 refuses to delete a Bucket that still contains objects, and Alchemy keeps that protection by default: destroying a non-empty Bucket fails with BucketNotEmpty and leaves the Bucket and its objects in place.

This Bucket only ever holds test data, so opt in to deleting its objects on destroy with forceDestroy: true:

src/api.ts
Effect.gen(function* () {
const bucket = yield* S3.Bucket("Bucket");
const bucket = yield* S3.Bucket("Bucket", {
forceDestroy: true,
});
const putObject = yield* S3.PutObject(bucket);
const getObject = yield* S3.GetObject(bucket);

With forceDestroy: true, Alchemy empties the Bucket before deleting it. Reserve it for Buckets whose contents are disposable (test fixtures, caches, previews); leave it off for Buckets that hold data you need to keep.

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:

const stack = beforeAll(deploy(Stack));
afterAll.skipIf(!process.env.CI)(destroy(Stack));
test(/* .. */);
test(/* .. */);
  • Locally — CI 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:

  • Test.make({ providers, state }) to wire your provider Layer and state store into the test runner once per file
  • beforeAll(deploy(Stack)) to deploy once before tests
  • yield* stack to access outputs in each test
  • HTTP assertions using Effect’s HttpClient
  • forceDestroy: true so destroy can delete a Bucket that tests wrote to
  • afterAll.skipIf(!process.env.CI)(destroy(Stack)) for automatic cleanup on CI with fast iteration locally

In Part 4, you’ll use stages to give every developer, environment, and pull request its own isolated copy of the stack.