Skip to content

Part 3: Testing

In Part 2 you deployed a Cloud Run service bound to a Bucket. Now you write tests that deploy the Stack, call the live service, and check it works.

Test.make takes the same providers and state as your Stack:

test/integ.test.ts
import * as Alchemy from "alchemy";
import * as GCP from "@alchemy.run/gcp";
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: GCP.providers(),
state: GCP.state(),
});

Deploy the Stack once, before any test runs:

const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: GCP.providers(),
state: GCP.state(),
});
const stack = beforeAll(deploy(Stack), { timeout: 600_000 });

beforeAll returns a handle. yield* it inside a test to get the Stack outputs. The first deploy builds an image, so give it a long timeout.

const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
test(
"stack exposes a service url",
Effect.gen(function* () {
const { url } = yield* stack;
expect(url).toStartWith("https://");
}),
);
Terminal window
bun test test/integ.test.ts

The first run deploys. Later runs reuse the deployment and only change what you changed.

Replace the first test with a round trip through the Bucket:

test/integ.test.ts
import * as Alchemy from "alchemy";
import * as GCP from "@alchemy.run/gcp";
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 * as Schedule from "effect/Schedule";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: GCP.providers(),
state: GCP.state(),
});
const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
test(
"stack exposes a service url",
Effect.gen(function* () {
const { url } = yield* stack;
expect(url).toStartWith("https://");
}),
);
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!"),
}).pipe(
Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }),
);
expect(put.status).toBe(204);
const get = yield* HttpClient.get(`${url}/hello.txt`);
expect(yield* get.text).toBe("Hello, World!");
}),
{ timeout: 120_000 },
);
test(
"GET a 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 comes with the test harness.

The PUT test leaves hello.txt in the Bucket. Cloud Storage won’t delete a Bucket that still has objects, so destroy would fail. This Bucket only holds test data, so let destroy empty it first:

src/api.ts
Effect.gen(function* () {
const bucket = yield* GCP.Storage.Bucket("Bucket", {});
const bucket = yield* GCP.Storage.Bucket("Bucket", {
forceDestroy: true,
});
const files = yield* GCP.Storage.ReadWriteBucket(bucket);

Leave forceDestroy off for Buckets that hold data you need to keep.

Locally, keep the Stack deployed between runs so tests start fast. In CI, tear it down:

const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
afterAll.skipIf(!process.env.CI)(destroy(Stack), { timeout: 600_000 });

With CI=true the Stack is destroyed after the tests. Without it, skipIf skips the destroy.

You now have:

  • Test.make({ providers, state }) wiring GCP into the test runner
  • beforeAll(deploy(Stack)) deploying once before the tests
  • HTTP assertions against the live Cloud Run URL
  • forceDestroy: true so destroy can delete a Bucket tests wrote to
  • afterAll.skipIf(!process.env.CI)(destroy(Stack)) cleaning up in CI

In Part 4, you give every developer, environment, and pull request its own copy of the Stack.