Testing TypeScript Workflows
Our samples repo contains examples of testing with Jest and Mocha
The TypeScript SDK comes with an optional test framework (npm @temporalio/testing
).
Upon installation, it will automatically download a test server with time skipping support (more on that later).
The test framework provides utilities for testing both Activities and Workflows.
Testing Activities
Activities can be tested with MockActivityEnvironment
The constructor accepts an optional partial Activity Info
object in case any info fields are needed for the test.
Running an activity in Context
MockActivityEnvironment.run()
runs a function in an Activity Context.
- TypeScript
- JavaScript
import { MockActivityEnvironment } from '@temporalio/testing';
import { Context } from '@temporalio/activity';
const env = new MockActivityEnvironment({ attempt: 2 });
const result = await env.run(
async (x) => x + Context.current().info.attempt,
2
);
assert.equal(result, 4);
import { MockActivityEnvironment } from '@temporalio/testing';
import { Context } from '@temporalio/activity';
const env = new MockActivityEnvironment({ attempt: 2 });
const result = await env.run(async (x) => x + Context.current().info.attempt, 2);
assert.equal(result, 4);
Heartbeats and cancellation
MockActivityEnvironment
is an EventEmitter
that emits a heartbeat
event which you can use to listen for heartbeats emitted by the Activity.
NOTE: When run by a
Worker
, heartbeats are throttled to avoid overloading the server.MockActivityEnvironment
on the other hand does not apply any throttling.
It also exposes a cancel
method which cancels the Activity Context.
- TypeScript
- JavaScript
import { MockActivityEnvironment } from '@temporalio/testing';
import { CancelledFailure, Context } from '@temporalio/activity';
const env = new MockActivityEnvironment();
env.on('heartbeat', (d: unknown) => {
if (d === 6) {
env.cancel('test');
}
});
await assert.rejects(
() =>
env.run(async () => {
Context.current().heartbeat(6);
await Context.current().sleep(100); // <- sleep is cancellation aware
}),
(err) => {
assert.ok(err instanceof CancelledFailure);
}
);
import { MockActivityEnvironment } from '@temporalio/testing';
import { CancelledFailure, Context } from '@temporalio/activity';
const env = new MockActivityEnvironment();
env.on('heartbeat', (d) => {
if (d === 6) {
env.cancel('test');
}
});
await assert.rejects(() => env.run(async () => {
Context.current().heartbeat(6);
await Context.current().sleep(100); // <- sleep is cancellation aware
}), (err) => {
assert.ok(err instanceof CancelledFailure);
});
Testing Workflows
Workflows can be tested with TestWorkflowEnvironment
.
A typical test suite would set up a single instance of the test environment to be reused in all tests (e.g. in a Mocha before()
hook or a Jest beforeAll
hook).
When creating an environment, TestWorkflowEnvironment.create
will automatically start a test server that you can access with workflowClient
and nativeConnection
.
Example setup
beforeAll
and afterAll
are injected by jest
. To use mocha instead, change them to before
and after
, and import:
- TypeScript
- JavaScript
import { before, after } from 'mocha';
// Not required in JavaScript
- TypeScript
- JavaScript
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
import { v4 as uuid4 } from 'uuid';
import { httpWorkflow } from './workflows';
import type * as Activities from './activities'; // Uses types to ensure our mock signatures match
let testEnv: TestWorkflowEnvironment;
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.create();
});
afterAll(async () => {
await testEnv?.teardown();
});
import { TestWorkflowEnvironment } from '@temporalio/testing';
let testEnv;
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.create();
});
afterAll(async () => {
await testEnv?.teardown();
});
Mocking Activities
Since the TestWorkflowEnvironment
is meant for testing Workflows, you'd typically want to mock your Activities in tests to avoid generating side effects.
- TypeScript
- JavaScript
test('httpWorkflow with mock activity', async () => {
const { workflowClient, nativeConnection } = testEnv;
// Implement only the relevant activities for this workflow
const mockActivities: Partial<typeof Activities> = {
makeHTTPRequest: async () => '99',
};
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('./workflows'),
activities: mockActivities,
});
const result = await worker.runUntil(
await workflowClient.execute(httpWorkflow, {
workflowId: uuid4(),
taskQueue: 'test',
})
);
assert.strictEqual(result, 'The answer is 99');
});
test('httpWorkflow with mock activity', async () => {
const { workflowClient, nativeConnection } = testEnv;
// Implement only the relevant activities for this workflow
const mockActivities = {
makeHTTPRequest: async () => '99',
};
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('./workflows'),
activities: mockActivities,
});
const result = await worker.runUntil(await workflowClient.execute(httpWorkflow, {
workflowId: uuid4(),
taskQueue: 'test',
}));
assert.strictEqual(result, 'The answer is 99');
});
Time skipping in Workflows
The built-in test server automatically "skips" (fast forwards) time when no Activities are executing.
The test server starts in "normal" time, using the TestWorkflowEnvironment.workflowClient
execute
or result
methods switch the test server to "skipped" time mode until the Workflow completes.
If a Workflow sleeps for days, running it in the test environment will cause it to complete almost immediately.
workflows.ts
- TypeScript
- JavaScript
import { sleep } from '@temporalio/workflow';
export async function sleeperWorkflow() {
await sleep('1 day');
}
import { sleep } from '@temporalio/workflow';
export async function sleeperWorkflow() {
await sleep('1 day');
}
test.ts
- TypeScript
- JavaScript
test('sleep completes almost immediately', async () => {
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('../workflows'),
});
// Does not wait an entire day
await worker.runUntil(
testEnv.workflowClient.execute(sleeperWorkflow, {
workflowId: uuid(),
taskQueue: 'test',
})
);
});
test('sleep completes almost immediately', async () => {
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('../workflows'),
});
// Does not wait an entire day
await worker.runUntil(testEnv.workflowClient.execute(sleeperWorkflow, {
workflowId: uuid(),
taskQueue: 'test',
}));
});
Time skipping in Tests
You can also call testEnv.sleep()
from your test code to advance the test server's time.
This is useful for testing intermediate state, or for testing infinite Workflows.
However, to advance time using testEnv.sleep()
, you need to start the Workflow using start()
, not execute()
.
workflow.ts
- TypeScript
- JavaScript
import { sleep } from '@temporalio/workflow';
import { defineQuery, setHandler } from '@temporalio/workflow';
export const daysQuery = defineQuery('days');
export async function sleeperWorkflow() {
let numDays = 0;
setHandler(daysQuery, () => numDays);
for (let i = 0; i < 100; ++i) {
await sleep('1 day');
++numDays;
}
}
import { sleep } from '@temporalio/workflow';
import { defineQuery, setHandler } from '@temporalio/workflow';
export const daysQuery = defineQuery('days');
export async function sleeperWorkflow() {
let numDays = 0;
setHandler(daysQuery, () => numDays);
for (let i = 0; i < 100; ++i) {
await sleep('1 day');
++numDays;
}
}
test.ts
- TypeScript
- JavaScript
test('advancing time using `testEnv.sleep()`', async () => {
const client = testEnv.workflowClient;
// Important: `start()` starts the test server in "normal" mode,
// not skipped time mode. If you don't advance time using `testEnv.sleep()`,
// then `sleeperWorkflow()` will run for days.
handle = await client.start(sleeperWorkflow, {
taskQueue,
workflowId: uuidv4(),
});
let numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 0);
// Advance the test server's time by 25 hours and assert that
// `sleeperWorkflow()` correctly incremented `numDays`.
await testEnv.sleep('25 hours');
numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 1);
// Advance the test server's time by an additional 25 hours and
// assert that `sleeperWorkflow()` incremented `numDays` a second time.
await testEnv.sleep('25 hours');
numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 2);
});
test('advancing time using `testEnv.sleep()`', async () => {
const client = testEnv.workflowClient;
// Important: `start()` starts the test server in "normal" mode,
// not skipped time mode. If you don't advance time using `testEnv.sleep()`,
// then `sleeperWorkflow()` will run for days.
handle = await client.start(sleeperWorkflow, {
taskQueue,
workflowId: uuidv4(),
});
let numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 0);
// Advance the test server's time by 25 hours and assert that
// `sleeperWorkflow()` correctly incremented `numDays`.
await testEnv.sleep('25 hours');
numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 1);
// Advance the test server's time by an additional 25 hours and
// assert that `sleeperWorkflow()` incremented `numDays` a second time.
await testEnv.sleep('25 hours');
numDays = await handle.query(daysQuery);
assert.strictEqual(numDays, 2);
});
Time skipping in Activities
When an Activity is executing time switches back to "normal",
TestWorkflowEnvironment.sleep
can be used outside of Workflow code to skip time.
Workflow implementation
timer-examples/src/workflows.ts
- TypeScript
- JavaScript
export async function processOrderWorkflow({
orderProcessingMS,
sendDelayedEmailTimeoutMS,
}: ProcessOrderOptions): Promise<string> {
let processing = true;
// Dynamically define the timeout based on given input
const { processOrder } = proxyActivities<ReturnType<typeof createActivities>>({
startToCloseTimeout: orderProcessingMS,
});
const processOrderPromise = processOrder().then(() => {
processing = false;
});
await Promise.race([processOrderPromise, sleep(sendDelayedEmailTimeoutMS)]);
if (processing) {
await sendNotificationEmail();
await processOrderPromise;
}
return 'Order completed!';
}
export async function processOrderWorkflow({ orderProcessingMS, sendDelayedEmailTimeoutMS, }) {
let processing = true;
// Dynamically define the timeout based on given input
const { processOrder } = proxyActivities({
startToCloseTimeout: orderProcessingMS,
});
const processOrderPromise = processOrder().then(() => {
processing = false;
});
await Promise.race([processOrderPromise, sleep(sendDelayedEmailTimeoutMS)]);
if (processing) {
await sendNotificationEmail();
await processOrderPromise;
}
return 'Order completed!';
}
- TypeScript
- JavaScript
test('countdownWorkflow sends reminder email if processing does not complete in time', async () => {
// NOTE: this tests doesn't actually take days to complete, the test environment starts a test
// server that automatically skips time when there are no running activities.
let emailSent = false;
// createActivities defintion omitted for brevity
const activities: ReturnType<typeof createActivities> = {
async processOrder() {
// Test server switches to "normal" time while an activity is executing.
// Call `sleep` to skip time by "2 days".
await testEnv.sleep('2 days');
},
async sendNotificationEmail() {
emailSent = true;
},
};
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('../workflows'),
activities,
});
await worker.runUntil(
testEnv.workflowClient.execute(processOrderWorkflow, {
workflowId: uuid(),
taskQueue: 'test',
args: [
{
orderProcessingMS: ms('3 days'),
sendDelayedEmailTimeoutMS: ms('1 day'),
},
],
})
);
assert.strictEqual(emailSent, true);
});
test('countdownWorkflow sends reminder email if processing does not complete in time', async () => {
// NOTE: this tests doesn't actually take days to complete, the test environment starts a test
// server that automatically skips time when there are no running activities.
let emailSent = false;
// createActivities defintion omitted for brevity
const activities = {
async processOrder() {
// Test server switches to "normal" time while an activity is executing.
// Call `sleep` to skip time by "2 days".
await testEnv.sleep('2 days');
},
async sendNotificationEmail() {
emailSent = true;
},
};
const worker = await Worker.create({
connection: testEnv.nativeConnection,
taskQueue: 'test',
workflowsPath: require.resolve('../workflows'),
activities,
});
await worker.runUntil(testEnv.workflowClient.execute(processOrderWorkflow, {
workflowId: uuid(),
taskQueue: 'test',
args: [
{
orderProcessingMS: ms('3 days'),
sendDelayedEmailTimeoutMS: ms('1 day'),
},
],
}));
assert.strictEqual(emailSent, true);
});
Test arbitrary functions in Workflow context
In case you need to test a function in your Workflow code that's not exported in workflowsPath
, export it in a different path and register it with the Worker.
workflows/file-with-workflow-function-to-test.ts
- TypeScript
- JavaScript
import * as wf from '@temporalio/workflow';
import { someWorkflowToRunAsChild } from './some-workflow';
export { someWorkflowToRunAsChild }; // Must be re-exported here for Worker registration
export async function functionToTest() {
await wf.executeChild(someWorkflowToRunAsChild);
// Other test code
}
import * as wf from '@temporalio/workflow';
import { someWorkflowToRunAsChild } from './some-workflow';
export { someWorkflowToRunAsChild }; // Must be re-exported here for Worker registration
export async function functionToTest() {
await wf.executeChild(someWorkflowToRunAsChild);
// Other test code
}
test.ts
- TypeScript
- JavaScript
const worker = await Worker.create({
...someOtherOptions,
connection: testEnv.nativeConnection,
workflowsPath: require.resolve(
'./workflows/file-with-workflow-function-to-test'
),
});
await worker.runUntil(
testEnv.workflowClient.execute(functionToTest, workflowOptions)
);
const worker = await Worker.create({
...someOtherOptions,
connection: testEnv.nativeConnection,
workflowsPath: require.resolve('./workflows/file-with-workflow-function-to-test'),
});
await worker.runUntil(testEnv.workflowClient.execute(functionToTest, workflowOptions));
Asserting from Workflow code
In some cases it's useful to assert directly in Workflow context.
The Workflow context is injected with the Node.js assert
module and can be imported with import assert from 'assert'
.
By default, failed assert
statements throw AssertionError
s which cause Workflow Tasks to fail and be indefinitely retried.
To prevent this, use workflowInterceptorModules
from @temporalio/testing
. These interceptors catch AssertionError
s and turn them into ApplicationFailure
s that fail the entire Workflow Execution (not just the Workflow Task).
workflows/file-with-workflow-function-to-test.ts
- TypeScript
- JavaScript
import assert from 'assert';
export async function functionToTest() {
assert.ok(false);
}
import assert from 'assert';
export async function functionToTest() {
assert.ok(false);
}
test.ts
- TypeScript
- JavaScript
import {
TestWorkflowEnvironment,
workflowInterceptorModules,
} from '@temporalio/testing';
const worker = await Worker.create({
...someOtherOptions,
connection: testEnv.nativeConnection,
interceptors: {
workflowModules: workflowInterceptorModules,
},
workflowsPath: require.resolve(
'./workflows/file-with-workflow-function-to-test'
),
});
await worker.runUntil(
testEnv.workflowClient.execute(functionToTest, workflowOptions) // Throws WorkflowFailedError
);
import { workflowInterceptorModules, } from '@temporalio/testing';
const worker = await Worker.create({
...someOtherOptions,
connection: testEnv.nativeConnection,
interceptors: {
workflowModules: workflowInterceptorModules,
},
workflowsPath: require.resolve('./workflows/file-with-workflow-function-to-test'),
});
await worker.runUntil(testEnv.workflowClient.execute(functionToTest, workflowOptions) // Throws WorkflowFailedError
);
Testing with Jest
There are a couple of caveats for testing with Jest:
- The Temporal TypeScript SDK only supports Jest
>= 27.0.0
. - Make sure you run Jest with
testEnvironment: 'node'
.testEnvironment: 'jsdom'
is not supported.