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 {
import beforeAll
beforeAll
,
import deploy
deploy
,
import expect
expect
,
import test
test
} from "alchemy/Test/Bun";
import * as
import Effect
Effect
from "effect/Effect";
import
import Stack
Stack
from "../alchemy.run.ts";

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

test/integ.test.ts
import {
import beforeAll
beforeAll
,
import deploy
deploy
,
import expect
expect
,
import test
test
} from "alchemy/Test/Bun";
import * as
import Effect
Effect
from "effect/Effect";
import
import Stack
Stack
from "../alchemy.run.ts";
const
const stack: any
stack
=
import beforeAll
beforeAll
(
import deploy
deploy
(
import Stack
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 {
import beforeAll
beforeAll
,
import deploy
deploy
,
import expect
expect
,
import test
test
} from "alchemy/Test/Bun";
import * as
import Effect
Effect
from "effect/Effect";
import
import Stack
Stack
from "../alchemy.run.ts";
const
const stack: any
stack
=
import beforeAll
beforeAll
(
import deploy
deploy
(
import Stack
Stack
));
import test
test
(
"worker returns a url",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
import expect
expect
(
const url: any
url
).
any
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 {
import beforeAll
beforeAll
,
import deploy
deploy
,
import expect
expect
,
import test
test
} from "alchemy/Test/Bun";
import * as
import Effect
Effect
from "effect/Effect";
import
import Stack
Stack
from "../alchemy.run.ts";
const
const stack: any
stack
=
import beforeAll
beforeAll
(
import deploy
deploy
(
import Stack
Stack
));
import * as
import HttpClient
HttpClient
from "effect/unstable/http/HttpClient";
import * as
import HttpBody
HttpBody
from "effect/unstable/http/HttpBody";
import test
test
(
"worker returns a url",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
import expect
expect
(
const url: any
url
).
any
toBeString
();
}),
);
import test
test
(
"PUT and GET round-trip an object",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
const
const put: any
put
= yield*
import HttpClient
HttpClient
.
any
put
(`${
const url: any
url
}/hello.txt`, {
body: any
body
:
import HttpBody
HttpBody
.
any
text
("Hello, World!"),
});
import expect
expect
(
const put: any
put
.
any
status
).
any
toBe
(201);
const
const get: any
get
= yield*
import HttpClient
HttpClient
.
any
get
(`${
const url: any
url
}/hello.txt`);
import expect
expect
(yield*
const get: any
get
.
any
text
).
any
toBe
("Hello, World!");
}),
);
import test
test
(
"GET missing key returns 404",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
const
const response: any
response
= yield*
import HttpClient
HttpClient
.
any
get
(`${
const url: any
url
}/no-such-key`);
import expect
expect
(
const response: any
response
.
any
status
).
any
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 {
import afterAll
afterAll
,
import beforeAll
beforeAll
,
import deploy
deploy
,
import destroy
destroy
,
import expect
expect
,
import test
test
,
} from "alchemy/Test/Bun";
import * as
import Effect
Effect
from "effect/Effect";
import * as
import HttpClient
HttpClient
from "effect/unstable/http/HttpClient";
import * as
import HttpBody
HttpBody
from "effect/unstable/http/HttpBody";
import
import Stack
Stack
from "../alchemy.run.ts";
const
const stack: any
stack
=
import beforeAll
beforeAll
(
import deploy
deploy
(
import Stack
Stack
));
import test
test
(
"PUT and GET round-trip an object",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
const
const put: any
put
= yield*
import HttpClient
HttpClient
.
any
put
(`${
const url: any
url
}/hello.txt`, {
body: any
body
:
import HttpBody
HttpBody
.
any
text
("Hello, World!"),
});
import expect
expect
(
const put: any
put
.
any
status
).
any
toBe
(201);
const
const get: any
get
= yield*
import HttpClient
HttpClient
.
any
get
(`${
const url: any
url
}/hello.txt`);
import expect
expect
(yield*
const get: any
get
.
any
text
).
any
toBe
("Hello, World!");
}),
);
import test
test
(
"GET missing key returns 404",
import Effect
Effect
.
any
gen
(function* () {
const {
const url: any
url
} = yield*
const stack: any
stack
;
const
const response: any
response
= yield*
import HttpClient
HttpClient
.
any
get
(`${
const url: any
url
}/no-such-key`);
import expect
expect
(
const response: any
response
.
any
status
).
any
toBe
(404);
}),
);
import afterAll
afterAll
.
any
skipIf
(!
var process: NodeJS.Process
process
.
NodeJS.Process.env: NodeJS.ProcessEnv

The process.env property returns an object containing the user environment. See environ(7).

An example of this object looks like:

{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}

It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

Terminal window
node -e 'process.env.foo = "bar"' && echo $foo

While the following will:

import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);

Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'

Use delete to delete a property from process.env.

import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined

On Windows operating systems, environment variables are case-insensitive.

import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1

Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

@sincev0.1.27

env
.
string | undefined
CI
)(
import destroy
destroy
(
import Stack
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.