Skip to main content

Unit Testing

Eventual provides a built-in library, @eventual/testing, for mocking and testing applications locally.

TestEnvironment

The TestEnvironment is the core of Eventual's testing capabilities. It allows you to control how time progresses in a test environment, mock task responses or send mock events and signals, etc.

Create a new TestEnvironment

To create a new TestEnvironment, import the TestEnvironment class from @eventual/testing and then instantiate it and call initialize.

const env = new TestEnvironment({
entry: path.resolve(
url.fileURLToPath(new URL(".", import.meta.url)),
"./workflow.ts"
),
});

await env.initialize();

It's common to use a beforeAll test hook (or equivalent) to ensure the environment is created before any tests run.

let env: TestEnvironment;

// if there is pollution between tests, call reset()
beforeAll(async () => {
env = new TestEnvironment({
entry: path.resolve(
url.fileURLToPath(new URL(".", import.meta.url)),
"./workflow.ts"
),
});

await env.initialize();
});

The above example uses import.meta.url from ESM. If you're using CommonJS (CJS) or another legacy node module system, you can use __dirname instead:

new TestEnvironment({
entry: path.resolve(__dirname, "./workflow.ts"),
});

Testing Workflows

Start Workflow Execution

You can start an execution of a workflow using the startExecution method. It accepts two arguments: the workflow to start a mock execution of and the input argument.

For example, to start an execution of a workflow that accepts no input parameters, you can pass undefined as the input argument:

// import your workflow from the src
import { myWorkflow } from "../src/index.js";

// start an execution of the workflow
await env.startExecution(myWorkflow, undefined);

On the other hand, if the workflow requires an input parameter of a certain type, you must pass a value of that type as the input argument:

const myWorkflow = workflow("myWorkflow", async (input: string) => {
// ..
});

await env.startExecution(myWorkflow, "input string");

Get Workflow Status

The startExecution method returns an ExecutionHandle, which is a reference to a running workflow execution. You can use the getStatus method to retrieve the current status of the execution:

For example, to start a workflow, advance time and then assert the status is FAILED, you can run the following code:

const execution = await env.startExecution(myWorkflow, undefined);
await env.tick();

const status = await execution.getStatus();
expect(status).toMatchObject({
status: ExecutionStatus.FAILED,
});

Send Signal

The sendSignal method sends a signal to the ExecutionHandle.

For example, to start a workflow, send a signal, advance time and then assert the status is COMPLETE, you can run the following code:

const execution = await env.startExecution(myWorkflow, undefined);
await execution.sendSignal(mySignal, "value");
await env.tick();

const status = await execution.getStatus();
expect(status).toMatchObject({
status: ExecutionStatus.COMPLETE,
});

Controlling Time

The TestEnvironment class provides utilities for controlling time.

Start Time

When creating a new TestEnvironment, you can specify the start property to initialize the environment at a specific point in time. For example:

new TestEnvironment({
// start the time at the beginning of the year, 2023
start: new Date("2023-01-01T00:00Z"),
});

resetTime

The resetTime method will reset an environment's time back to the time it was initialized with. It is common to use afterEach to reset an environment's time before each test runs. This ensures that each test runs with a consistent view of time and does not affect one another.

afterAll(() => {
env.resetTime();
});

tick - advance time

The tick method can be used to advance time within the test environment. It takes a number of seconds as an argument, which represents the amount of time to advance. For example:

await env.tick(1); // advance time by 1 second
await env.tick(2); // advance time by 2 seconds

If no argument is provided, tick advances time by 1 second by default. This can be useful when you want to advance time by a small amount, but don't need to specify an exact amount.

await env.tick(); // advance time by 1 second

You can use tick to simulate the passage of time in your tests, which can be useful for testing time-based functionality such as timeouts.

// test a timeout of 5 seconds
await env.tick(5); // advance time by 5 seconds

tickUntil - advance time to a specific timestamp

The tickUntil method allows you to advance time in the test environment to a specific point in time. It takes a timestamp as an argument, which can be provided as an ISO8601 string or a Date object. The method will advance time one tick at a time until the test environment reaches the specified timestamp.

For example, to advance time to the beginning of the year 2023:

await env.tickUntil("2023-01-01T00:00Z");

You can also provide a Date object as the argument:

await env.tickUntil(new Date(epochMilliseconds));

You can use tickUntil to simulate the passage of time in your tests without having to compute tick intervals, which can be useful for testing time-based functionality such as scheduled tasks.

// test a scheduled task that runs every hour
await env.tickUntil("2023-01-01T01:00Z"); // advance time to 01:00

Mocking Tasks

While testing workflows, it is often necessary to mock the behavior of a task.

mockTask

The mockTask function on TestEnvironment allows you to create a mock of a task. This mock object can be used to control the result of a task from the perspective of a workflow.

const mockedTask = env.mockTask(myTask);

The mockTask provides the following utility functions that can be used to mock its behavior in tests:

ResolutionDescription
succeed and succeedOnceTask returns a result
fail and failOnceTask fails with an error
timeout and timeoutOnceTask fails with a Timeout error
invoke and invokeOnceTask will call your delegate function
invokeReal and invokeRealOnceTask will call the real underlying implementation
asyncResult and asyncResultOnceTask will return an async result token

succeed

Use the succeed method to set up a mocked task to always succeed with a specified value:

mockedTask.succeed("value");

succeedOnce

Use the succeedOnce method to set up a mocked task to succeed once with a specific value, and then behave differently on subsequent invocations.

mockedTask.succeedOnce("once").succeed("value");

For example, in the above code, the first time this mocked task is called, it will succeed with the value "once". All subsequent calls will then succeed with "value".

fail

Use the fail method to set up a mocked task to always fail with a specified error:

mockedTask.fail(new Error("oops"));

failOnce

Use the failOnce method to set up a mocked task to fail once with a specific value, and then behave differently on subsequent invocations.

mockedTask.failOnce(new Error("oops"));

timeout

Use the timeout method to set up a mocked task to always timeout:

mockedTask.timeout();

timeoutOnce

Use the timeoutOnce method to set up a mocked task to timeout once, and then behave differently on subsequent invocations.

mockedTask.timeoutOnce();

invoke

Use invoke to set up a mocked task to always mock a provided function.

For example, a useful pattern is to proxy task invocations to a Jest Mocked Function and then make assertions on the mock function:

const mockedFn = jest.fn();

mockTask.invoke(mockedFn);

await env.tick();

expect(mockedFn).toHaveBeenCalled();

invokeOnce

Use the invokeOnce method to set up a mocked task to invoke the provided function once, and then behave differently on subsequent invocations.

const mockedFn = jest.fn();

mockTask.invokeOnce(mockedFn);

invokeReal

Use invokeReal to set up a mocked task to always invoke the real, underlying function.

mockedTask.invokeReal();

The "real function" refers to the function implementation defined on the task being mocked:

const myTask("myTask", async () => {
// (this function)
})

invokeRealOnce

Use the invokeRealOnce method to set up a mocked task to invoke the real function once, and then behave differently on subsequent invocations.

mockedTask.invokeRealOnce();

asyncResult

Use the asyncResult method to set up a mocked task to always return an async token:

mockedTask.asyncResult();

It accepts an optional callback argument that will be called with the token. This callback can be used to pass to capture the token for use within the test.

let taskToken;

// mock the result and save the token
mockTask.asyncResult((token) => {
taskToken = token;
});

// kick off the workflow
await env.startExecution(longRunningWorkflow, undefined);

// and allow it time to progress
await env.tick();

// check the taskToken was received
if (!taskToken) {
fail("Expected task token to be set");
}

// mock the token being completed
await env.sendTaskSuccess({
taskToken,
result: {
value: "hello from the async mock",
},
});

asyncResultOnce

Use the asyncResultOnce method to set up a mocked task to return an async token, and then behave differently on subsequent invocations.

mockedTask.asyncResultOnce();

It accepts an optional callback argument that will be called with the token. This callback can be used to pass to capture the token for use within the test.

let taskToken;

// mock the result and save the token
mockTask.asyncResultOnce((token) => {
taskToken = token;
});

// kick off the workflow
await env.startExecution(longRunningWorkflow, undefined);

// and allow it time to progress
await env.tick();

// check the taskToken was received
if (!taskToken) {
fail("Expected task token to be set");
}

// mock the token being completed
await env.sendTaskSuccess({
taskToken,
result: {
value: "hello from the async mock",
},
});

Testing Tasks

Tasks are functions that are executed within the context of an Eventual workflow. They can be tested in the same way as regular functions, with the exception of tasks that use the asyncResult and heartbeat intrinsic functions. These tasks are currently not supported and can be tracked in this issue: https://github.com/functionless/eventual/issues/167.

Call a task from within a Test

To test a task, you can import it from your source code and call it with the desired input arguments, just like any other function. Then, you can make assertions about the output or the side effects of the task. For example:

import { myTask } from "../src/index.js";

const result = await myTask("input value");
expect(result).toEqual("expected output");

Mock a task's dependencies

To test the interactions of the myTask task with external dependencies, such as APIs or databases, you can use mocking libraries like Jest.

For example, given a task, myTask, that imports and calls a function, sendRequest:

import { sendRequest } from "./my-api";

export const myTask = task("myTask", async () => {
// call some
return await sendRequest();
});

First, create a mock for the sendRequest function that myTask calls using jest.mock or a similar method. Then, invoke the myTask task and use assertions to verify that it behaves as expected when interacting with the mocked function.

import { myTask } from "../src/index.js";

// Create a mock for the sendRequest function that myTask calls
jest.mock("../src/my-api", () => ({
sendRequest: jest.fn(() => Promise.resolve("mocked response")),
}));

// Invoke the myTask task
const result = await myTask("input value");

// Use an assertion to verify that the result of the task is what we expect
expect(result).toEqual("mocked response");

Testing Events

emits into an environment

To simulate an event being emitted to a Service, use the emit method. It accepts two arguments: a reference to the event to emit and its data. For example:

await env.emit(myEvent, {
prop: "value",
});

Note: calling emit will progress time by one until, identically to calling await env.tick().

Here is a more advanced example that tests an event handler that sends a signal to a workflow execution by its ID:

const myEvent = event<{ executionId: string }>("myEvent");

myEvent.onEvent(({ executionId }) => {
await sendSignal(executionId, "mySignal", "data");
});

const myWorkflow = workflow("myWorkflow", async () => {
await expectSignal("mySignal");
});

To test this complex flow:

// start the workflow execution
const execution = await env.startExecution(myWorkflow);

// emit an event into the test environment
await env.emit(myEvent, {
executionId: execution.executionId,
});

// and assert that is is COMPLETE - the event handler should have allowed it to complete
expect(await execution.getStatus()).toMatchObject({
status: ExecutionStatus.COMPLETE,
});

onEvent - listen to events in a TestEnvironment

The onEvent method can be used to subscribe a test handler to an event within a TestEnvironment so that you can capture events emitted by your application and make assertions.

For example, imagine you want to test that the below workflow emits to myEvent:

const myWorkflow = workflow("myWorkflow", async () => {
await myEvent.emit({ .. });
})

You can use the onEvent method to subscribe a mock handler to myEvent and then assert it was called after advancing time:

// start the workflow
env.startExecution(myWorkflow, undefined);

// create a mock function
const mockHandler = jest.fn();

// subscribe the mock to the myEvent
env.onEvent(myEvent, mockHandler);

// allow time to progress so that the workflow will progress
await env.tick();

// assert the mock was called
expect(mockHandler).toHaveBeenCalled();

resetTestSubscriptions - clear any test subscriptions

To remove any test event subscriptions created with the env.onEvent method, call env.resetTestSubscriptions():

env.resetTestSubscriptions();