Skip to content

Commit 0cad3f1

Browse files
btraversclaude
andcommitted
feat(testing)!: contract-aware fixtures and configurable environments
- item 27: createContractTest(contract, { workflowsPath, activities?, workerOptions? }) — new ./contract entry; vitest fixtures exposing { client, typedClient, worker } wired against the testcontainers server, reusing the ./extension connection fixtures - item 27: runActivity(definition, implementation, input, { env? }) — new vitest-free ./activity entry executing a single AsyncResult activity implementation inside MockActivityEnvironment, with heartbeat and cancellation support via a caller-provided environment - item 28: createTimeSkippingEnvironment forwards TimeSkippingTestWorkflowEnvironmentOptions; new createTimeSkippingTest fixture factory so suites can pin the test-server version - item 28: extension throws a descriptive error naming @temporal-contract/testing/global-setup when inject values are missing; workerConnection is now closed in teardown inside try/catch (the known NativeConnection shutdown race is swallowed) - item 28: createGlobalSetup({ postgresImage, temporalImage, temporalEnv, quiet }) factory; the default export becomes createGlobalSetup() with the previous defaults; add the ./package.json export - item 29: time-skipping TSDoc examples use .get() and the new TypedClient.create({ client }) + for(contract) construction; README covers ./contract, ./activity, ./time-skipping and fixes the Client import - deps: @temporal-contract/client|contract|worker and unthrown become peer dependencies (they appear in the new public types); the workspace siblings are resolved via tsconfig paths / vitest aliases locally because declaring them as dev deps would create a cycle in the package graph (client and worker already devDepend on this package) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 887b278 commit 0cad3f1

17 files changed

Lines changed: 1137 additions & 148 deletions

packages/testing/README.md

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pnpm add -D @temporal-contract/testing
1414

1515
### Global Setup
1616

17-
Configure Vitest to start a Temporal server before all tests:
17+
Configure Vitest to start a Temporal server (via testcontainers — requires Docker) before all tests:
1818

1919
```typescript
2020
// vitest.config.ts
@@ -28,18 +28,89 @@ export default defineConfig({
2828
});
2929
```
3030

31-
### Test Extension
31+
To pin container images, inject extra Temporal env, or silence the progress logs, reference your own module that default-exports `createGlobalSetup(options)`:
3232

33-
Use the `it` extension for automatic connection management:
33+
```typescript
34+
// temporal-global-setup.ts
35+
import { createGlobalSetup } from "@temporal-contract/testing/global-setup";
36+
37+
export default createGlobalSetup({
38+
temporalImage: "temporalio/auto-setup:1.28.0",
39+
quiet: true,
40+
});
41+
```
42+
43+
### Contract-Aware Fixtures
44+
45+
`createContractTest` wires the whole stack for one contract — a running worker, the connection-scoped `TypedClient` root, and the contract-bound `ContractClient`:
46+
47+
```typescript
48+
// order.spec.ts
49+
import { createContractTest } from "@temporal-contract/testing/contract";
50+
import { declareActivitiesHandler } from "@temporal-contract/worker/activity";
51+
import { workflowsPathFromURL } from "@temporal-contract/worker/worker";
52+
import { describe, expect } from "vitest";
53+
54+
import { orderContract } from "./order.contract.js";
55+
56+
const activities = declareActivitiesHandler({ contract: orderContract, activities: { ... } });
57+
58+
const it = createContractTest(orderContract, {
59+
workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"),
60+
activities, // omit for a workflow-only worker
61+
});
62+
63+
describe("order processing", () => {
64+
it("processes an order", async ({ client, typedClient, worker }) => {
65+
const result = await client.executeWorkflow("processOrder", {
66+
workflowId: `order-${Date.now()}`,
67+
args: { orderId: "ORD-1" },
68+
});
69+
expect(result.isOk()).toBe(true);
70+
});
71+
});
72+
```
73+
74+
### Unit-Testing a Single Activity
75+
76+
`runActivity` executes one `AsyncResult`-returning activity implementation inside `@temporalio/testing`'s `MockActivityEnvironment` — no worker, no server, no Docker. Pass your own environment to observe heartbeats or trigger cancellation:
77+
78+
```typescript
79+
import { runActivity } from "@temporal-contract/testing/activity";
80+
81+
const result = await runActivity(chargeCardDefinition, chargeCard, { amount: 100 });
82+
expect(result.isOk()).toBe(true);
83+
```
84+
85+
### Time-Skipping Environment (no Docker)
86+
87+
The `./time-skipping` entry runs suites against Temporal's in-process time-skipping test server (downloaded and cached by `@temporalio/testing` on first use) — hour-long timers resolve instantly:
88+
89+
```typescript
90+
import { it } from "@temporal-contract/testing/time-skipping";
91+
// or pin the server version:
92+
// const it = createTimeSkippingTest({ server: { executable: { type: "cached-download", version: "v1.3.0" } } });
93+
94+
it("processes the order", async ({ testEnv }) => {
95+
// testEnv.client, testEnv.nativeConnection, worker.runUntil(...)
96+
});
97+
```
98+
99+
`createTimeSkippingEnvironment(options?)` creates the environment directly for suites preferring explicit `beforeAll`/`afterAll` management.
100+
101+
### Connection Fixtures
102+
103+
Use the `it` extension for automatic connection management against the testcontainers server:
34104

35105
```typescript
36106
// my-workflow.spec.ts
37107
import { it } from "@temporal-contract/testing/extension";
108+
import { Client } from "@temporalio/client";
38109
import { expect } from "vitest";
39110

40111
it("should execute workflow", async ({ clientConnection, workerConnection }) => {
41112
// clientConnection: Connection from @temporalio/client (auto-connected, auto-closed)
42-
// workerConnection: NativeConnection from @temporalio/worker (auto-connected)
113+
// workerConnection: NativeConnection from @temporalio/worker (auto-connected, auto-closed)
43114

44115
const client = new Client({ connection: clientConnection });
45116
// ... use client and workerConnection in your test

packages/testing/package.json

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
"type": "module",
2828
"sideEffects": false,
2929
"exports": {
30+
"./activity": {
31+
"types": "./dist/activity.d.mts",
32+
"import": "./dist/activity.mjs"
33+
},
34+
"./contract": {
35+
"types": "./dist/contract.d.mts",
36+
"import": "./dist/contract.mjs"
37+
},
3038
"./extension": {
3139
"types": "./dist/extension.d.mts",
3240
"import": "./dist/extension.mjs"
@@ -35,16 +43,18 @@
3543
"types": "./dist/global-setup.d.mts",
3644
"import": "./dist/global-setup.mjs"
3745
},
46+
"./package.json": "./package.json",
3847
"./time-skipping": {
3948
"types": "./dist/time-skipping.d.mts",
4049
"import": "./dist/time-skipping.mjs"
4150
}
4251
},
4352
"scripts": {
44-
"build": "tsdown src/global-setup.ts src/extension.ts src/time-skipping.ts --format esm --dts --clean",
53+
"build": "tsdown src/global-setup.ts src/extension.ts src/time-skipping.ts src/contract.ts src/activity.ts --format esm --dts --clean",
4554
"build:docs": "typedoc",
46-
"test": "vitest run",
47-
"test:watch": "vitest",
55+
"test": "vitest run --project unit",
56+
"test:integration": "vitest run --project integration",
57+
"test:watch": "vitest --project unit",
4858
"typecheck": "tsc --noEmit"
4959
},
5060
"dependencies": {
@@ -56,18 +66,25 @@
5666
"@temporalio/client": "catalog:",
5767
"@temporalio/testing": "catalog:",
5868
"@temporalio/worker": "catalog:",
69+
"@temporalio/workflow": "catalog:",
5970
"@types/node": "catalog:",
6071
"@vitest/coverage-v8": "catalog:",
6172
"tsdown": "catalog:",
6273
"typedoc": "catalog:",
6374
"typedoc-plugin-markdown": "catalog:",
6475
"typescript": "catalog:",
65-
"vitest": "catalog:"
76+
"unthrown": "catalog:",
77+
"vitest": "catalog:",
78+
"zod": "catalog:"
6679
},
6780
"peerDependencies": {
81+
"@temporal-contract/client": "workspace:^",
82+
"@temporal-contract/contract": "workspace:^",
83+
"@temporal-contract/worker": "workspace:^",
6884
"@temporalio/client": "^1",
6985
"@temporalio/testing": "^1",
7086
"@temporalio/worker": "^1",
87+
"unthrown": "^5.0.0",
7188
"vitest": "^4"
7289
},
7390
"engines": {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Integration coverage for `createContractTest` against the
3+
* testcontainers-provided Temporal server (started by this package's own
4+
* `global-setup`): the fixtures wire a running worker, the connection-scoped
5+
* root, and the contract-bound client, and a workflow executes end-to-end
6+
* through them.
7+
*/
8+
import { extname } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
11+
import { declareActivitiesHandler } from "@temporal-contract/worker/activity";
12+
import { Ok } from "unthrown";
13+
import { describe, expect } from "vitest";
14+
15+
import { createContractTest } from "../contract.js";
16+
import { testContract } from "./test.contract.js";
17+
// The worker loads this module by path (`workflowsPath`); importing it here
18+
// too keeps its exports type-checked and visible to static analysis.
19+
import * as workflows from "./test.workflows.js";
20+
21+
const activities = declareActivitiesHandler({
22+
contract: testContract,
23+
activities: {
24+
greet: {
25+
decorate: ({ name }) => Ok({ decorated: name.toUpperCase() }).toAsync(),
26+
},
27+
},
28+
});
29+
30+
const it = createContractTest(testContract, {
31+
workflowsPath: fileURLToPath(
32+
new URL(`./test.workflows${extname(import.meta.url)}`, import.meta.url),
33+
),
34+
activities,
35+
});
36+
37+
describe("createContractTest", () => {
38+
it("executes a workflow end-to-end through the contract-bound client", async ({ client }) => {
39+
const result = await client.executeWorkflow("greet", {
40+
workflowId: `greet-${Date.now()}`,
41+
args: { name: "world" },
42+
});
43+
44+
expect(result.isOk()).toBe(true);
45+
if (result.isOk()) {
46+
expect(result.value).toEqual({ message: "Hello, WORLD!" });
47+
}
48+
});
49+
50+
it("exposes the running worker and the connection-scoped root", async ({
51+
worker,
52+
typedClient,
53+
client,
54+
}) => {
55+
expect(worker.getState()).toBe("RUNNING");
56+
// The root memoizes contract bindings — the fixture's client is the
57+
// same instance `for()` hands out.
58+
expect(typedClient.for(testContract)).toBe(client);
59+
// Sanity: the module registered via `workflowsPath` exports the
60+
// workflow the contract declares.
61+
expect(workflows.greet).toBeTypeOf("function");
62+
});
63+
64+
it("starts a workflow and reads its result through a typed handle", async ({ client }) => {
65+
const workflowId = `greet-handle-${Date.now()}`;
66+
const handleResult = await client.startWorkflow("greet", {
67+
workflowId,
68+
args: { name: "fixtures" },
69+
});
70+
71+
expect(handleResult.isOk()).toBe(true);
72+
if (!handleResult.isOk()) return;
73+
74+
expect(handleResult.value.workflowId).toBe(workflowId);
75+
const result = await handleResult.value.result();
76+
expect(result.isOk()).toBe(true);
77+
if (result.isOk()) {
78+
expect(result.value).toEqual({ message: "Hello, FIXTURES!" });
79+
}
80+
});
81+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineActivity, defineContract, defineWorkflow } from "@temporal-contract/contract";
2+
import { z } from "zod";
3+
4+
// Minimal inline contract for exercising the contract-aware fixtures:
5+
// one workflow with one activity.
6+
7+
const decorate = defineActivity({
8+
input: z.object({ name: z.string() }),
9+
output: z.object({ decorated: z.string() }),
10+
defaultOptions: { startToCloseTimeout: "10 seconds" },
11+
});
12+
13+
const greet = defineWorkflow({
14+
input: z.object({ name: z.string() }),
15+
output: z.object({ message: z.string() }),
16+
activities: { decorate },
17+
});
18+
19+
export const testContract = defineContract({
20+
taskQueue: "testing-contract-fixtures",
21+
workflows: { greet },
22+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Workflow implementation for the fixtures' integration test.
2+
//
3+
// Deliberately written with the raw `@temporalio/workflow` API (like
4+
// packages/client's integration workflows) rather than `declareWorkflow`:
5+
// Temporal's workflow bundler resolves imports through node_modules from
6+
// this file's location, and `@temporal-contract/worker` is a *peer* of this
7+
// package (a dev dep would put a cycle in the workspace package graph), so
8+
// it is not linked here. The typed surface under test — the contract-bound
9+
// client and the worker wiring — is unaffected.
10+
import { proxyActivities } from "@temporalio/workflow";
11+
12+
type Activities = {
13+
decorate(args: { name: string }): Promise<{ decorated: string }>;
14+
};
15+
16+
const activities = proxyActivities<Activities>({
17+
startToCloseTimeout: "10 seconds",
18+
});
19+
20+
export async function greet(args: { name: string }): Promise<{ message: string }> {
21+
const { decorated } = await activities.decorate({ name: args.name });
22+
return { message: `Hello, ${decorated}!` };
23+
}

0 commit comments

Comments
 (0)