doc/qa/test-guidelines.md
This document is a practical test guideline. It tells you what to test, when to test it, and how to write deterministic, actionable tests using the repository's existing test helpers and patterns.
The philosophy of testing in Elsa can be summarized as:
Whenever a test fails, it should provide a clear direction towards the cause of the problem.
Tests should be fast, deterministic, and precise: they should pinpoint the failing subsystem (activity, invoker, persistence, scheduler, etc.) with minimal noise.
For contributors, tests are the first line of code review: they must document intended behaviour and prevent regressions.
IWorkflowRunner.RunAsync and PopulateRegistriesAsync() when using existing definitions.AppComponentTest to instantiate and IWorkflowInstanceStore queries for assertions.Each test layer has distinct goals and clear boundaries — see Which parts of Elsa to test for precise mapping of which aspects belong to which layer.
Before you write a test:
5-Minute Checklist:
dotnet testdotnet test --no-build -- repeat 10)Activities: First-class pluggable units. Each activity implements execution logic and interacts with the ActivityExecutionContext. More details in Activities.
Workflows: Graphs of activities. A workflow can run synchronously or schedule asynchronous work (bookmarks, timers). When you run a workflow in-process with IWorkflowRunner.RunAsync, the runner will return when synchronous work completes. Some activities set RunAsynchronously causing background scheduling — tests need to take care when asserting. More details in Workflow execution.
Tests are organized by module and test type:
test/unit/Elsa.Activities.UnitTests - Activity unit teststest/unit/Elsa.Expressions.UnitTests - Expression evaluation unit teststest/unit/Elsa.Workflows.Core.UnitTests - Core workflow unit tests (ActivityExecutionContext extensions, etc.)test/unit/Elsa.Workflows.Management.UnitTests - Workflow management unit teststest/integration/Elsa.Workflows.IntegrationTests - Workflow integration teststest/component/Elsa.Workflows.ComponentTests - End-to-end component testsShared test infrastructure:
src/common/Elsa.Testing.Shared - Shared test helpers (ActivityTestFixture, fake strategies, extensions)src/common/Elsa.Testing.Shared.Integration - Integration test helpers (RunActivityExtensions, PopulateRegistriesAsync)src/common/Elsa.Testing.Shared.Component - Component test helpers (event handlers, workflow events)This section maps Elsa aspects to the exact kinds of tests you should write, with examples and code patterns referencing repository conventions.
ActivityTestFixture to configure and execute the activity and obtain an ActivityExecutionContext for assertions.ConfigureServices() to add custom services, ConfigureContext() to set up context statecontext.GetActivityOutput()(extension method) for outputs, or context.Get() for variables.Example (does not set output):
[Fact]
public async Task Should_Set_Variable_Integer()
{
// Arrange
const int expected = 42;
var variable = new Variable<int>("myVar", 0, "myVar");
var setVariable = new SetVariable<int>(variable, new Input<int>(expected));
// Act
var fixture = new ActivityTestFixture(setVariable);
var context = await fixture.ExecuteAsync();
// Assert
var result = context.Get(variable);
Assert.Equal(expected, result);
}
Example (sets output using fluent configuration):
[Fact]
public async Task Should_Send_Get_Request_And_Handle_Success_Response()
{
// Arrange
var expectedUrl = new Uri("https://example.com");
var sendHttpRequest = new SendHttpRequest
{
Url = new Input<Uri?>(expectedUrl),
Method = new Input<string>("GET")
};
// Act
var fixture = new ActivityTestFixture(sendHttpRequest)
.WithHttpServices(responseHandler); // HTTP-specific extension
var context = await fixture.ExecuteAsync();
// Assert
var statusCodeOutput = context.GetActivityOutput(_ => sendHttpRequest.StatusCode);
Assert.Equal(200, statusCodeOutput);
}
Example (checking scheduled activities):
[Fact]
public async Task Should_Schedule_Child_Activity()
{
// Arrange
var childActivity = new WriteLine("Hello");
var parentActivity = new Sequence { Activities = [childActivity] };
// Act
var fixture = new ActivityTestFixture(parentActivity);
var context = await fixture.ExecuteAsync();
// Assert
Assert.True(context.HasScheduledActivity(childActivity));
}
Example (checking activity outcomes):
[Fact]
public async Task Should_Return_Multiple_Outcomes()
{
// Arrange
var flowFork = new FlowFork
{
Branches = new(new[] { "Branch1", "Branch2", "Branch3" })
};
// Act
var context = await new ActivityTestFixture(flowFork).ExecuteAsync();
// Assert - Check all outcomes
var outcomes = context.GetOutcomes().ToList();
Assert.Equal(3, outcomes.Count);
Assert.Contains("Branch1", outcomes);
Assert.Contains("Branch2", outcomes);
Assert.Contains("Branch3", outcomes);
}
[Fact]
public async Task Should_Return_Default_Outcome()
{
// Arrange
var flowSwitch = new FlowSwitch();
// Act
var context = await new ActivityTestFixture(flowSwitch).ExecuteAsync();
// Assert - Check single outcome
Assert.True(context.HasOutcome("Default"));
}
IWorkflowRunner.RunAsync. Assert outputs/variables and that the activity integrates correctly with preceding/following activities.WorkflowTestFixture for simplified integration testing with automatic service provider setup and output capture.Pattern note: RunAsync returns a RunWorkflowResult (or equivalent) containing the WorkflowInstance and output variables when run to completion.
Use returned state for deterministic assertions where possible.
Example (using WorkflowTestFixture - basic):
public class RunJavaScriptTests
{
private readonly WorkflowTestFixture _fixture;
public RunJavaScriptTests(ITestOutputHelper testOutputHelper)
{
_fixture = new WorkflowTestFixture(testOutputHelper);
}
[Fact(DisplayName = "RunJavaScript should execute and return output")]
public async Task Should_Execute_And_Return_Output()
{
// Arrange
var script = "return 1 + 1;";
var activity = new RunJavaScript { Script = new(script), Result = new() };
// Act
var result = await _fixture.RunActivityAsync(activity);
// Assert - activity produced expected output
var output = result.GetActivityOutput<object>(activity);
Assert.Equal(2, output);
}
[Fact(DisplayName = "RunJavaScript should set outcomes")]
public async Task Should_Set_Outcomes()
{
// Arrange
var script = "setOutcomes(['Branch1', 'Branch2']);";
var activity = new RunJavaScript { Script = new(script) };
// Act
var result = await _fixture.RunActivityAsync(activity);
// Assert - activity produced expected outcomes
var outcomes = _fixture.GetOutcomes(result, activity);
Assert.Contains("Branch1", outcomes);
Assert.Contains("Branch2", outcomes);
}
[Fact(DisplayName = "RunJavaScript should fault on invalid syntax")]
public async Task Should_Fault_On_Invalid_Syntax()
{
// Arrange
var script = "this is not valid javascript";
var activity = new RunJavaScript { Script = new(script) };
// Act
var result = await _fixture.RunActivityAsync(activity);
// Assert - activity should be in faulted state
var status = _fixture.GetActivityStatus(result, activity);
Assert.Equal(ActivityStatus.Faulted, status);
}
}
Example (using manual setup with IWorkflowRunner):
[Fact]
public async Task Should_Execute_Workflow()
{
// Arrange
var services = new TestApplicationBuilder(testOutputHelper).Build();
await services.PopulateRegistriesAsync();
var runner = services.GetRequiredService<IWorkflowRunner>();
var workflow = Workflow.FromActivity(new WriteLine("Hello"));
// Act
var result = await runner.RunAsync(workflow);
// Assert
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
}
IWorkflowRunner.RunAsync with small workflows to test variables propagation, branch logic (If/ForEach/Parallel), expression evaluation, and RunAsynchronously flags.Call the workflow runner to execute a workflow object or a loaded definition. Prefer this when asserting logical flow and outputs.
var runner = serviceProvider.GetRequiredService<IWorkflowRunner>();
await serviceProvider.PopulateRegistriesAsync();
var runResult = await runner.RunAsync(workflow);
Assert.Equal(WorkflowStatus.Finished, runResult.WorkflowInstance!.Status);
IWorkflowInstanceStore after the creation point. Simulate host restart by disposing and rebuilding the service provider (keeping the same persistence store) and resume the bookmark to assert resumption completes.Code pattern to resume a bookmark (integration/component):
// assume instanceId found via RunAsync or correlation id
await workflowTriggerService.ResumeAsync(instanceId, activityId, input, CancellationToken.None);
var resumed = await runner.RunAsync(workflowInstance);
Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status);
| Helper | Purpose | Use When |
|---|---|---|
TestApplicationBuilder | Build test service provider | All tests as entry point, |
| except activities unit tests (see below) | ||
ActivityTestFixture | Configure and run single activity in isolation | Unit testing activities |
ActivityTestFixture.ConfigureServices | Fluent API to add services to fixture | Activity unit tests requiring custom services |
ActivityTestFixture.ConfigureContext | Fluent API to configure execution context before execution | Setting up test-specific context state |
ActivityTestFixture.BuildAsync | Build context without executing activity | When you need context setup without execution |
ActivityTestFixture.ExecuteAsync | Execute activity and return context | Standard activity unit test execution |
WorkflowTestFixture | Configure and run workflows/activities in integration tests | Integration testing workflows and activities |
WorkflowTestFixture.ConfigureElsa | Fluent API to configure Elsa features | Integration tests requiring custom Elsa configuration |
WorkflowTestFixture.ConfigureServices | Fluent API to add services to fixture | Integration tests requiring custom services |
WorkflowTestFixture.RunActivityAsync | Run single activity as workflow | Integration tests for single activities |
WorkflowTestFixture.RunWorkflowAsync | Run workflow or workflow by definition ID | Integration tests for workflows |
WorkflowTestFixture.CapturingTextWriter | Capture WriteLine output | Asserting text output in integration tests |
WorkflowTestFixture.GetOutcomes | Get all outcomes from specific activity | Asserting activity outcomes in integration tests |
WorkflowTestFixture.HasOutcome | Check if activity produced specific outcome | Asserting single outcome in integration tests |
WorkflowTestFixture.GetActivityStatus | Get execution status of specific activity | Asserting activity status (Faulted, Completed, etc.) |
WorkflowTestFixture.CreateWorkflowExecutionContextAsync | Create workflow execution context without running workflow | Testing workflow-level concerns or base context setup |
WorkflowTestFixture.CreateActivityExecutionContextAsync | Create activity execution context (2 overloads) | Testing activity scheduling/execution or when needing activity context |
WorkflowTestFixture.CreateExpressionExecutionContextAsync | Create expression execution context (2 overloads) | Testing expression evaluators (JavaScript, Liquid, C#) with variables |
context.GetActivityOutput | Get output from activity using expression selector | Asserting activity outputs in unit tests |
context.HasScheduledActivity | Check if activity is scheduled | Verifying scheduling behavior in unit tests |
context.GetOutcomes | Get all outcomes from activity execution | Asserting multiple outcomes in unit tests |
context.HasOutcome | Check if activity has specific outcome | Asserting single outcome in unit tests |
IWorkflowRunner.RunAsync | Execute workflow in-process | Integration / Component tests |
RunActivityExtensions.RunActivityAsync | Run single activity as workflow | Integration tests for single activities |
PopulateRegistriesAsync | Register types for JSON deserialization | Loading JSON workflows. |
| Integration tests only | ||
IWorkflowInstanceStore | Query persisted instances | Component tests (persistence) |
AsyncWorkflowRunner.RunAndAwaitWorkflowCompletionAsync | Drive workflow to completion with activity tracking | Complex async scenarios. |
| Component tests only | ||
FakeActivityExecutionContextSchedulerStrategy | Fake scheduler for unit tests | Automatically configured in ActivityTestFixture |
FakeWorkflowExecutionContextSchedulerStrategy | Fake workflow scheduler for unit tests | Automatically configured in ActivityTestFixture |
When testing activities in isolation, Elsa provides fake scheduler strategy implementations that bypass the production scheduling logic:
FakeActivityExecutionContextSchedulerStrategy (source) - Schedules activities immediately within the activity execution context for deterministic testingFakeWorkflowExecutionContextSchedulerStrategy (source) - Schedules activities immediately at the workflow level for deterministic testingThese strategies are automatically configured when using ActivityTestFixture and ensure that scheduled activities execute synchronously in tests, making assertions deterministic.
Note: You do not need to manually configure these strategies - ActivityTestFixture handles this for you.
IWorkflowRunner.RunAsync and a small workflow.IWorkflowRunner.RunAsync and a small workflow. If persistence semantics change, add component tests.IWorkflowInstanceStore.Rule of thumb:
When in doubt, add the minimal unit tests plus one integration test that reproduces the scenario.
ActivityTestFixture.ExecuteAsync. Always inspect on the returned context — it is deterministic for synchronous workflows.RunAsync or attach a CorrelationId test variable and query IWorkflowInstanceStore.FindByCorrelationIdAsync(...). Avoid using "latest" queries.When using WorkflowTestFixture for integration tests, use these helper methods to assert on activity behavior:
Use result.GetActivityOutput<T>(activity) to retrieve the output value from an activity:
var result = await _fixture.RunActivityAsync(activity);
var output = result.GetActivityOutput<object>(activity);
Assert.Equal(expectedValue, output);
Use _fixture.GetOutcomes(result, activity) to retrieve all outcomes produced by an activity:
var result = await _fixture.RunActivityAsync(activity);
var outcomes = _fixture.GetOutcomes(result, activity);
Assert.Contains("ExpectedOutcome", outcomes);
Or use _fixture.HasOutcome(result, activity, outcome) to check for a specific outcome:
var result = await _fixture.RunActivityAsync(activity);
Assert.True(_fixture.HasOutcome(result, activity, "Success"));
Use _fixture.GetActivityStatus(result, activity) to check the execution status of an activity:
var result = await _fixture.RunActivityAsync(activity);
var status = _fixture.GetActivityStatus(result, activity);
Assert.Equal(ActivityStatus.Faulted, status);
Available activity statuses:
ActivityStatus.Pending - Activity is pending executionActivityStatus.Running - Activity is currently runningActivityStatus.Completed - Activity completed successfullyActivityStatus.Canceled - Activity was canceledActivityStatus.Faulted - Activity encountered an errorWorkflowTestFixture provides layered methods for creating execution contexts at different levels, giving you fine-grained control over test setup:
Use CreateWorkflowExecutionContextAsync to create a minimal workflow execution context without running the workflow:
var context = await _fixture.CreateWorkflowExecutionContextAsync(variables: new[]
{
new Variable<int>("Counter", 0)
});
Use CreateActivityExecutionContextAsync to create an activity execution context. Two overloads available:
Without existing workflow context (creates one automatically):
var activityContext = await _fixture.CreateActivityExecutionContextAsync(
activity: myActivity,
variables: new[] { new Variable<string>("MyVar", "value") }
);
With existing workflow context:
var workflowContext = await _fixture.CreateWorkflowExecutionContextAsync();
var activityContext = await _fixture.CreateActivityExecutionContextAsync(
workflowContext,
activity: myActivity
);
Use CreateExpressionExecutionContextAsync to create a context for testing expression evaluation (e.g., JavaScript, Liquid). Variables are properly registered and accessible via dynamic accessors. Two overloads available:
Without existing activity context (creates one automatically):
var expressionContext = await _fixture.CreateExpressionExecutionContextAsync(new[]
{
new Variable<string>("MyVariable", "test value")
});
// Variables are accessible via dynamic accessors in expressions
// e.g., getMyVariable() and setMyVariable(value) in JavaScript
With existing activity context:
var activityContext = await _fixture.CreateActivityExecutionContextAsync();
var expressionContext = await _fixture.CreateExpressionExecutionContextAsync(
activityContext,
variables: new[] { new Variable<int>("Count", 42) }
);
Example: Testing JavaScript Expression Evaluation
[Fact]
public async Task Dynamic_Variable_Accessors_Should_Work()
{
// Arrange
var script = @"
setMyVariable('updated value');
return getMyVariable();
";
var context = await _fixture.CreateExpressionExecutionContextAsync(new[]
{
new Variable<string>("MyVariable", "initial value")
});
// Act
var evaluator = _fixture.Services.GetRequiredService<IJavaScriptEvaluator>();
var result = await evaluator.EvaluateAsync(script, typeof(string), context);
// Assert
Assert.Equal("updated value", result);
}
When to use each method:
CreateWorkflowExecutionContextAsync when testing workflow-level concerns or when you need a base context for further customizationCreateActivityExecutionContextAsync when testing activity scheduling, execution, or when you need access to activity-specific contextCreateExpressionExecutionContextAsync when testing expression evaluators (JavaScript, Liquid, C#) or when you need variables to be accessible in expressions_fixture.GetActivityStatus(result, activity) to assert that a specific activity faulted, rather than checking workflow-level status.RunAsync — assert WorkflowInstance.Status == Faulted on the returned state or via IWorkflowInstanceStore.[Fact]
public async Task MyActivity_Test()
{
var activity = new ActivityToTest();
// Act
var fixture = new ActivityTestFixture(activity);
var context = await fixture.ExecuteAsync();
// assert behavior of activity in isolation
}
WorkflowTestFixtureRecommended approach for activity integration tests:
public class MyActivityTests
{
private readonly WorkflowTestFixture _fixture;
public MyActivityTests(ITestOutputHelper testOutputHelper)
{
_fixture = new WorkflowTestFixture(testOutputHelper);
}
[Fact]
public async Task Activity_Completes_Successfully()
{
// Arrange
var activity = new MyActivity { Input = new("test") };
// Act
var result = await _fixture.RunActivityAsync(activity);
// Assert
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
}
}
IWorkflowRunner.RunAsyncAlternative approach with manual setup (use when you need more control):
[Fact]
public async Task Workflow_With_MyActivity_Completes()
{
var sp = new TestApplicationBuilder(testOutput).Build();
await sp.PopulateRegistriesAsync();
var runner = sp.GetRequiredService<IWorkflowRunner>();
var workflow = new MyWorkflowDefinition();
var result = await runner.RunAsync(workflow);
Assert.Equal(WorkflowStatus.Finished, result.WorkflowInstance!.Status);
}
[Fact]
public async Task Workflow_Persists_Instance_And_Journal()
{
var sp = new TestApplicationBuilder(testOutput)
.Build();
var runner = sp.GetRequiredService<AsyncWorkflowRunner>();
var result = await runner.RunAndAwaitWorkflowCompletionAsync(
WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published)
);
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
}
For testing workflows that complete asynchronously (e.g., with timers, external triggers), use AsyncWorkflowRunner:
[Fact]
public async Task Workflow_Completes_Asynchronously()
{
var sp = new TestApplicationBuilder(testOutput).Build();
var runner = sp.GetRequiredService<AsyncWorkflowRunner>();
var result = await runner.RunAndAwaitWorkflowCompletionAsync(
WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published)
);
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
result.ActivityExecutionRecords.Should().HaveCount(expectedCount);
}
AsyncWorkflowRunner tracks activity execution records and awaits workflow completion signals, making it ideal for testing asynchronous workflow behavior deterministically.
Q: How do I import workflow definitions in tests and where do I put them?
A: For JSON-defined workflows use the repo's test integration helpers (PopulateRegistriesAsync() or the test registration helpers in test/common).
See integration test examples in the test tree.
Leave the definitions next to the tests that use them.
Q: Which helper should I use to run a workflow?
A: Prefer IWorkflowRunner.RunAsync for in-process deterministic runs. For activities use ActivityTestFixture.
Q: How do I check persisted journal entries?
A: Query IWorkflowInstanceStore and inspect the persisted journal on the instance. Use deterministic instance id or correlation id to locate the exact instance.
Q: Do I need a new helper to wait for workflow completion?
A: No. The repo provides RunAsync for integration tests and ActivityTestFixture for activity unit tests, as well as integration helpers that cover all necessary scenarios. For async workflows in component tests, use AsyncWorkflowRunner.
Search the test/ tree for examples that follow the above patterns:
test/unit/Elsa.Activities.UnitTests (look for ActivityTestFixture usage)test/integration/Elsa.*.IntegrationTests (look for PopulateRegistriesAsync() and IWorkflowRunner.RunAsync usage)test/component/Elsa.Workflows.ComponentTests (look for AsyncWorkflowRunner and IWorkflowInstanceStore assertions)