Testing & Debugging Workflows
Comprehensive guide to testing and debugging workflows in Elsa Workflows, covering unit testing, integration testing, debugging techniques, test data management, CI/CD integration, and best practices.
Testing and debugging workflows is crucial for building reliable, production-ready workflow systems. This guide covers comprehensive strategies for testing workflows with xUnit and Elsa.Testing, integration testing patterns, debugging techniques, and best practices for workflow testing in Elsa V3.
Why Test Workflows?
Workflows often contain critical business logic that needs to be reliable and maintainable. Testing workflows provides:
Confidence: Ensure workflows behave correctly before deployment
Regression Prevention: Catch breaking changes early
Documentation: Tests serve as executable documentation
Refactoring Safety: Make changes without fear of breaking functionality
Quality Assurance: Validate business rules and edge cases
Unit Testing Workflows
Unit testing workflows involves testing individual workflows or activities in isolation. Elsa provides the Elsa.Testing package to make this process straightforward.
Setting Up Your Test Project
Create Test Project
Create a new xUnit test project and add the necessary packages:
dotnet new xunit -n "MyWorkflows.Tests"
cd MyWorkflows.Tests
dotnet add package Elsa
dotnet add package Elsa.Testing.Shared
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package xunit
dotnet add package xunit.runner.visualstudioConfigure Test Infrastructure
Create a base test class to set up the Elsa service container:
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace MyWorkflows.Tests;
public abstract class WorkflowTestBase : IAsyncLifetime
{
protected IServiceProvider Services { get; private set; } = default!;
public virtual async Task InitializeAsync()
{
var services = new ServiceCollection();
// Add Elsa services
services.AddElsa();
// Add custom activities or services
ConfigureServices(services);
// Build the service provider
Services = services.BuildServiceProvider();
// Populate registries (required for non-hosted scenarios)
await Services.PopulateRegistriesAsync();
}
protected virtual void ConfigureServices(IServiceCollection services)
{
// Override in derived classes to add custom services
}
public virtual Task DisposeAsync()
{
if (Services is IDisposable disposable)
disposable.Dispose();
return Task.CompletedTask;
}
protected async Task<WorkflowState> RunWorkflowAsync(Workflow workflow, IDictionary<string, object>? input = null, CancellationToken cancellationToken = default)
{
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(workflow, input, cancellationToken);
return result;
}
}Testing a Simple Workflow
Here's an example of testing a workflow that validates and processes user input:
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Contracts;
using Elsa.Workflows.Models;
using FluentAssertions;
using Xunit;
namespace MyWorkflows.Tests;
public class UserValidationWorkflowTests : WorkflowTestBase
{
[Fact]
public async Task ValidUser_ShouldCompleteSuccessfully()
{
// Arrange
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new SetVariable
{
Variable = new Variable<string>("Email"),
Value = new Input<string>("[email protected]")
},
new If
{
Condition = new Input<bool>(context =>
{
var email = context.GetVariable<string>("Email");
return !string.IsNullOrEmpty(email) && email.Contains("@");
}),
Then = new WriteLine
{
Text = new Input<string>("Valid email")
},
Else = new WriteLine
{
Text = new Input<string>("Invalid email")
}
}
}
}
};
// Act
var result = await RunWorkflowAsync(workflow);
// Assert
result.Status.Should().Be(WorkflowStatus.Finished);
result.SubStatus.Should().Be(WorkflowSubStatus.Finished);
}
[Theory]
[InlineData("")]
[InlineData("invalid-email")]
[InlineData("exampledotcom")]
public async Task InvalidEmail_ShouldTakeElseBranch(string email)
{
// Arrange
var executedBranch = "";
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new SetVariable
{
Variable = new Variable<string>("Email"),
Value = new Input<string>(email)
},
new If
{
Condition = new Input<bool>(context =>
{
var emailValue = context.GetVariable<string>("Email");
return !string.IsNullOrEmpty(emailValue) && emailValue.Contains("@");
}),
Then = new Inline(context => executedBranch = "Then"),
Else = new Inline(context => executedBranch = "Else")
}
}
}
};
// Act
await RunWorkflowAsync(workflow);
// Assert
executedBranch.Should().Be("Else");
}
}Testing Custom Activities
When testing custom activities, focus on testing the activity's logic in isolation:
using Elsa.Workflows;
using Elsa.Workflows.Models;
using FluentAssertions;
using Xunit;
namespace MyWorkflows.Tests;
public class CustomActivityTests : WorkflowTestBase
{
[Fact]
public async Task SendEmail_WithValidRecipient_ShouldSucceed()
{
// Arrange
var emailSent = false;
var sendEmailActivity = new SendEmailActivity
{
To = new Input<string>("[email protected]"),
Subject = new Input<string>("Test Subject"),
Body = new Input<string>("Test Body"),
OnEmailSent = () => emailSent = true
};
var workflow = new Workflow
{
Root = sendEmailActivity
};
// Act
var result = await RunWorkflowAsync(workflow);
// Assert
result.Status.Should().Be(WorkflowStatus.Finished);
emailSent.Should().BeTrue();
}
[Fact]
public async Task SendEmail_WithInvalidRecipient_ShouldFail()
{
// Arrange
var sendEmailActivity = new SendEmailActivity
{
To = new Input<string>("invalid-email"),
Subject = new Input<string>("Test Subject"),
Body = new Input<string>("Test Body")
};
var workflow = new Workflow
{
Root = sendEmailActivity
};
// Act
var result = await RunWorkflowAsync(workflow);
// Assert
result.Status.Should().Be(WorkflowStatus.Faulted);
}
}
// Example custom activity for testing
public class SendEmailActivity : CodeActivity
{
public Input<string> To { get; set; } = default!;
public Input<string> Subject { get; set; } = default!;
public Input<string> Body { get; set; } = default!;
public Action? OnEmailSent { get; set; }
protected override void Execute(ActivityExecutionContext context)
{
var to = To.Get(context);
if (string.IsNullOrEmpty(to) || !to.Contains("@"))
{
throw new InvalidOperationException("Invalid email address");
}
// Simulate sending email
OnEmailSent?.Invoke();
}
}Testing Workflow Inputs and Outputs
Test workflows with various input combinations and verify outputs:
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Contracts;
using Elsa.Workflows.Models;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace MyWorkflows.Tests;
public class WorkflowInputOutputTests : WorkflowTestBase
{
[Fact]
public async Task Workflow_WithInput_ShouldProduceExpectedOutput()
{
// Arrange
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new SetVariable
{
Variable = new Variable<int>("InputValue"),
Value = new Input<int>(context =>
context.GetInput<int>("value"))
},
new SetVariable
{
Variable = new Variable<int>("Result"),
Value = new Input<int>(context =>
context.GetVariable<int>("InputValue") * 2)
},
new SetOutput
{
OutputName = "Result",
OutputValue = new Input<object?>(context =>
context.GetVariable<int>("Result"))
}
}
}
};
var input = new Dictionary<string, object>
{
["value"] = 5
};
// Act
var result = await RunWorkflowAsync(workflow, input);
// Assert
result.Status.Should().Be(WorkflowStatus.Finished);
var output = result.Output;
output.Should().ContainKey("Result");
output["Result"].Should().Be(10);
}
}Using Elsa's Official Testing Helpers
Elsa provides official testing helper packages that simplify test setup and execution. These are the recommended approaches used in the elsa-core repository.
ActivityTestFixture for Unit Testing Activities
The ActivityTestFixture from Elsa.Testing.Shared package is the recommended way to unit test individual activities:
using Elsa.Testing.Shared;
using Xunit;
namespace MyWorkflows.Tests;
public class MyActivityUnitTests
{
[Fact]
public async Task MyActivity_Executes_Successfully()
{
// Arrange
var activity = new MyCustomActivity
{
InputProperty = new Input<string>("test value")
};
// Act - ActivityTestFixture handles all setup
var fixture = new ActivityTestFixture(activity);
var context = await fixture.ExecuteAsync();
// Assert - Check activity behavior in isolation
Assert.Equal(ActivityStatus.Completed, context.Status);
}
}WorkflowTestFixture for Integration Testing
The WorkflowTestFixture from Elsa.Testing.Shared.Integration provides a complete test infrastructure with proper service setup:
using Elsa.Testing.Shared.Integration;
using Xunit;
using Xunit.Abstractions;
namespace MyWorkflows.Tests.Integration;
public class MyActivityIntegrationTests
{
private readonly WorkflowTestFixture _fixture;
public MyActivityIntegrationTests(ITestOutputHelper testOutputHelper)
{
_fixture = new WorkflowTestFixture(testOutputHelper);
}
[Fact]
public async Task Activity_Completes_Successfully()
{
// Arrange
var activity = new MyActivity { Input = new("test") };
// Act - Runs activity in a complete workflow context
var result = await _fixture.RunActivityAsync(activity);
// Assert
Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
// Check specific activity status
var activityStatus = _fixture.GetActivityStatus(result, activity);
Assert.Equal(ActivityStatus.Completed, activityStatus);
}
}Creating Execution Contexts
WorkflowTestFixture provides methods to create execution contexts at different levels:
// Create a workflow execution context
var workflowContext = await _fixture.CreateWorkflowExecutionContextAsync(
variables: new[]
{
new Variable<int>("Counter", 0)
});
// Create an activity execution context
var activityContext = await _fixture.CreateActivityExecutionContextAsync(
activity: myActivity,
variables: new[] { new Variable<string>("MyVar", "value") }
);
// Create an expression execution context for testing expressions
var expressionContext = await _fixture.CreateExpressionExecutionContextAsync(new[]
{
new Variable<string>("MyVariable", "test value")
});
// Variables are accessible via dynamic accessors (e.g., getMyVariable(), setMyVariable())
var evaluator = _fixture.Services.GetRequiredService<IJavaScriptEvaluator>();
var script = @"
setMyVariable('updated value');
return getMyVariable();
";
var result = await evaluator.EvaluateAsync(script, typeof(string), expressionContext);Testing Async Workflows
For workflows that complete asynchronously (with timers, external triggers, etc.), use AsyncWorkflowRunner:
using Elsa.Workflows.ComponentTests.Helpers.Services;
using Xunit;
using Xunit.Abstractions;
public class AsyncWorkflowTests
{
private readonly ITestOutputHelper _testOutput;
public AsyncWorkflowTests(ITestOutputHelper testOutput)
{
_testOutput = testOutput;
}
[Fact]
public async Task AsyncWorkflow_Completes_Successfully()
{
// Arrange
var sp = new TestApplicationBuilder(_testOutput).Build();
var runner = sp.GetRequiredService<AsyncWorkflowRunner>();
// Act - Waits for workflow completion with timeout
var result = await runner.RunAndAwaitWorkflowCompletionAsync(
WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published)
);
// Assert
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
result.ActivityExecutionRecords.Should().HaveCount(expectedCount);
}
}AsyncWorkflowRunner tracks activity execution records and properly awaits workflow completion signals, making it ideal for deterministic testing of asynchronous workflow behavior.
Integration Testing
Integration tests verify that workflows work correctly with external dependencies like databases, message queues, and HTTP services.
Testing with TestContainers
TestContainers allows you to run real dependencies in Docker containers during tests:
Install TestContainers
dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.RabbitMqCreate Integration Test Base
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Elsa.EntityFrameworkCore.Extensions;
using Elsa.EntityFrameworkCore.Modules.Management;
using Elsa.EntityFrameworkCore.Modules.Runtime;
using Elsa.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using Xunit;
namespace MyWorkflows.Tests.Integration;
public abstract class IntegrationTestBase : IAsyncLifetime
{
private PostgreSqlContainer? _postgresContainer;
protected IServiceProvider Services { get; private set; } = default!;
protected string ConnectionString { get; private set; } = default!;
public virtual async Task InitializeAsync()
{
// Start PostgreSQL container
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:15")
.WithDatabase("elsa_test")
.WithUsername("elsa")
.WithPassword("elsa")
.Build();
await _postgresContainer.StartAsync();
ConnectionString = _postgresContainer.GetConnectionString();
// Configure services
var services = new ServiceCollection();
services.AddElsa(elsa =>
{
elsa.UseWorkflowManagement(management =>
{
management.UseEntityFrameworkCore(ef =>
ef.UsePostgreSql(ConnectionString));
});
elsa.UseWorkflowRuntime(runtime =>
{
runtime.UseEntityFrameworkCore(ef =>
ef.UsePostgreSql(ConnectionString));
});
});
ConfigureServices(services);
Services = services.BuildServiceProvider();
// Populate registries and run migrations
await Services.PopulateRegistriesAsync();
await Services.RunMigrationsAsync();
}
protected virtual void ConfigureServices(IServiceCollection services)
{
// Override in derived classes
}
public virtual async Task DisposeAsync()
{
if (Services is IDisposable disposable)
disposable.Dispose();
if (_postgresContainer != null)
await _postgresContainer.DisposeAsync();
}
}Write Integration Tests
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Contracts;
using Elsa.Workflows.Management;
using Elsa.Workflows.Models;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace MyWorkflows.Tests.Integration;
public class WorkflowPersistenceTests : IntegrationTestBase
{
[Fact]
public async Task SaveAndLoadWorkflow_ShouldPersistCorrectly()
{
// Arrange
var workflowDefinitionService = Services.GetRequiredService<IWorkflowDefinitionService>();
var workflow = new WorkflowDefinition
{
DefinitionId = "test-workflow",
Name = "Test Workflow",
Version = 1,
IsLatest = true,
IsPublished = true
};
// Act - Save
await workflowDefinitionService.SaveAsync(workflow, CancellationToken.None);
// Act - Load
var loadedWorkflow = await workflowDefinitionService
.FindByDefinitionIdAsync("test-workflow", CancellationToken.None);
// Assert
loadedWorkflow.Should().NotBeNull();
loadedWorkflow!.Name.Should().Be("Test Workflow");
loadedWorkflow.Version.Should().Be(1);
}
[Fact]
public async Task ExecutePersistedWorkflow_ShouldComplete()
{
// Arrange
var workflowDefinitionService = Services.GetRequiredService<IWorkflowDefinitionService>();
var workflowRunner = Services.GetRequiredService<IWorkflowRunner>();
var workflow = new Workflow
{
Identity = new WorkflowIdentity
{
DefinitionId = "simple-workflow",
Version = 1
},
Root = new WriteLine
{
Text = new Input<string>("Integration test workflow")
}
};
// Save workflow definition
var definition = await workflowDefinitionService.SaveAsync(workflow, CancellationToken.None);
// Act - Execute
var result = await workflowRunner.RunAsync(workflow);
// Assert
result.Status.Should().Be(WorkflowStatus.Finished);
}
}Testing HTTP Workflows
Use ASP.NET Core's test server for testing HTTP-triggered workflows:
using Elsa.Extensions;
using Elsa.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Xunit;
namespace MyWorkflows.Tests.Integration;
public class HttpWorkflowTests : IAsyncLifetime
{
private IHost? _host;
private HttpClient? _client;
public async Task InitializeAsync()
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webHost =>
{
webHost.UseTestServer();
webHost.ConfigureServices(services =>
{
services.AddElsa(elsa =>
{
elsa.UseHttp();
elsa.UseWorkflowRuntime();
});
});
webHost.Configure(app =>
{
app.UseRouting();
app.UseWorkflowsApi();
});
});
_host = await hostBuilder.StartAsync();
_client = _host.GetTestClient();
}
[Fact]
public async Task HttpEndpoint_WithValidRequest_ShouldReturnSuccess()
{
// Arrange
var request = new { name = "Test User" };
// Act
var response = await _client!.PostAsJsonAsync("/workflows/user-registration", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("success");
}
public async Task DisposeAsync()
{
_client?.Dispose();
if (_host != null)
await _host.StopAsync();
}
}Testing with In-Memory Databases
For faster tests, use Entity Framework Core's in-memory database:
using Elsa.EntityFrameworkCore.Extensions;
using Elsa.EntityFrameworkCore.Modules.Management;
using Elsa.EntityFrameworkCore.Modules.Runtime;
using Elsa.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace MyWorkflows.Tests.Integration;
public abstract class InMemoryTestBase : IAsyncLifetime
{
protected IServiceProvider Services { get; private set; } = default!;
public virtual async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddElsa(elsa =>
{
elsa.UseWorkflowManagement(management =>
{
management.UseEntityFrameworkCore(ef =>
ef.UseInMemory());
});
elsa.UseWorkflowRuntime(runtime =>
{
runtime.UseEntityFrameworkCore(ef =>
ef.UseInMemory());
});
});
ConfigureServices(services);
Services = services.BuildServiceProvider();
await Services.PopulateRegistriesAsync();
}
protected virtual void ConfigureServices(IServiceCollection services)
{
// Override in derived classes
}
public virtual Task DisposeAsync()
{
if (Services is IDisposable disposable)
disposable.Dispose();
return Task.CompletedTask;
}
}Debugging Workflow Execution
Debugging workflows requires understanding execution flow, state, and activity behavior.
Using the Execution Journal
The execution journal records every activity execution, providing a complete audit trail:
using Elsa.Workflows.Contracts;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
// After executing a workflow
var workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(workflow);
// Access the execution journal
var journal = result.WorkflowState.ExecutionLog;
foreach (var entry in journal)
{
Console.WriteLine($"Activity: {entry.ActivityId}");
Console.WriteLine($"Event: {entry.EventName}");
Console.WriteLine($"Timestamp: {entry.Timestamp}");
Console.WriteLine($"State: {entry.Payload}");
Console.WriteLine("---");
}Logging Workflow Execution
Configure structured logging to debug workflows:
using Elsa.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
var services = new ServiceCollection();
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("logs/elsa-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog(dispose: true);
});
services.AddElsa(elsa =>
{
// Enable workflow execution logging
elsa.UseWorkflowRuntime(runtime =>
{
runtime.EnableExecutionLogging = true;
});
});Using WriteLine Activity for Debugging
Insert WriteLine activities to trace execution flow:
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new WriteLine { Text = new Input<string>("DEBUG: Starting workflow") },
new SetVariable
{
Variable = new Variable<int>("Counter"),
Value = new Input<int>(0)
},
new WriteLine
{
Text = new Input<string>(context =>
$"DEBUG: Counter value = {context.GetVariable<int>("Counter")}")
},
new ForEach<int>
{
Items = new Input<ICollection<int>>([1, 2, 3, 4, 5]),
Body = new Sequence
{
Activities =
{
new WriteLine
{
Text = new Input<string>(context =>
$"DEBUG: Processing item {context.GetVariable<int>("CurrentValue")}")
},
new SetVariable
{
Variable = new Variable<int>("Counter"),
Value = new Input<int>(context =>
context.GetVariable<int>("Counter") + 1)
}
}
}
},
new WriteLine
{
Text = new Input<string>(context =>
$"DEBUG: Final counter = {context.GetVariable<int>("Counter")}")
},
new WriteLine { Text = new Input<string>("DEBUG: Workflow completed") }
}
}
};Debugging with Breakpoints
Create a custom breakpoint activity for debugging:
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using System.Diagnostics;
[Activity("Debugging", "Diagnostics", "Pauses workflow execution at this point for debugging")]
public class Breakpoint : CodeActivity
{
[Input(Description = "Message to display at breakpoint")]
public Input<string> Message { get; set; } = new("Breakpoint reached");
[Input(Description = "Enable this breakpoint")]
public Input<bool> Enabled { get; set; } = new(true);
protected override void Execute(ActivityExecutionContext context)
{
var enabled = Enabled.Get(context);
if (!enabled)
return;
var message = Message.Get(context);
// Display workflow state
Console.WriteLine($"=== BREAKPOINT ===");
Console.WriteLine($"Message: {message}");
Console.WriteLine($"Activity: {context.Activity.Id}");
Console.WriteLine($"Workflow: {context.WorkflowExecutionContext.Id}");
// Display variables
Console.WriteLine("Variables:");
var variables = context.ExpressionExecutionContext.Memory.Variables;
foreach (var variable in variables)
{
Console.WriteLine($" {variable.Key} = {variable.Value}");
}
// Launch debugger if attached
if (Debugger.IsAttached)
{
Debugger.Break();
}
else
{
Console.WriteLine("Press any key to continue...");
Console.ReadKey();
}
Console.WriteLine("=== CONTINUING ===");
}
}Inspecting Workflow State
Access and inspect workflow state during execution:
using Elsa.Workflows.Contracts;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
public static async Task InspectWorkflowState(IServiceProvider services, string workflowInstanceId)
{
var workflowStateStore = services.GetRequiredService<IWorkflowStateStore>();
// Load workflow state
var workflowState = await workflowStateStore.LoadAsync(workflowInstanceId);
if (workflowState == null)
{
Console.WriteLine("Workflow state not found");
return;
}
Console.WriteLine($"Workflow ID: {workflowState.Id}");
Console.WriteLine($"Status: {workflowState.Status}");
Console.WriteLine($"Sub Status: {workflowState.SubStatus}");
// Inspect variables
Console.WriteLine("\nVariables:");
foreach (var variable in workflowState.Properties)
{
Console.WriteLine($" {variable.Key} = {JsonSerializer.Serialize(variable.Value)}");
}
// Inspect bookmarks
Console.WriteLine("\nBookmarks:");
foreach (var bookmark in workflowState.Bookmarks)
{
Console.WriteLine($" Activity: {bookmark.ActivityId}");
Console.WriteLine($" Name: {bookmark.Name}");
Console.WriteLine($" Payload: {JsonSerializer.Serialize(bookmark.Payload)}");
}
// Inspect scheduled activities
Console.WriteLine("\nScheduled Activities:");
foreach (var scheduledActivity in workflowState.ScheduledActivities)
{
Console.WriteLine($" Activity: {scheduledActivity.ActivityId}");
Console.WriteLine($" Owner: {scheduledActivity.OwnerId}");
}
}Debug Workflow Failures
Handle and debug faulted workflows:
using Elsa.Workflows;
using Elsa.Workflows.Contracts;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
public static async Task DebugFailedWorkflow(IServiceProvider services)
{
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new WriteLine { Text = new Input<string>("Before fault") },
new Fault
{
Message = new Input<string>("Something went wrong!")
},
new WriteLine { Text = new Input<string>("After fault (won't execute)") }
}
}
};
try
{
var result = await workflowRunner.RunAsync(workflow);
if (result.Status == WorkflowStatus.Faulted)
{
Console.WriteLine("Workflow faulted!");
// Get fault information
var incidents = result.WorkflowState.Incidents;
foreach (var incident in incidents)
{
Console.WriteLine($"Incident: {incident.Message}");
Console.WriteLine($"Activity: {incident.ActivityId}");
Console.WriteLine($"Exception: {incident.Exception}");
}
// Examine execution log to see what happened
foreach (var logEntry in result.WorkflowState.ExecutionLog)
{
Console.WriteLine($"{logEntry.Timestamp}: {logEntry.EventName} - {logEntry.ActivityId}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Exception during workflow execution: {ex.Message}");
Console.WriteLine($"Stack trace: {ex.StackTrace}");
}
}Testing Faulted Workflows
When testing error scenarios, verify that workflows and activities fault correctly:
using Elsa.Testing.Shared.Integration;
using Xunit;
public class FaultHandlingTests
{
private readonly WorkflowTestFixture _fixture;
public FaultHandlingTests(ITestOutputHelper testOutputHelper)
{
_fixture = new WorkflowTestFixture(testOutputHelper);
}
[Fact]
public async Task Activity_That_Throws_Should_Fault()
{
// Arrange
var activity = new ActivityThatThrows();
// Act
var result = await _fixture.RunActivityAsync(activity);
// Assert - Check specific activity status, not just workflow status
var activityStatus = _fixture.GetActivityStatus(result, activity);
Assert.Equal(ActivityStatus.Faulted, activityStatus);
}
[Fact]
public async Task Workflow_With_Fault_Should_Have_Incidents()
{
// Arrange
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new WriteLine { Text = new Input<string>("Before fault") },
new Fault { Message = new Input<string>("Intentional failure") }
}
}
};
// Act
var result = await _fixture.RunWorkflowAsync(workflow);
// Assert
Assert.Equal(WorkflowStatus.Faulted, result.WorkflowState.Status);
Assert.NotEmpty(result.WorkflowState.Incidents);
var incident = result.WorkflowState.Incidents.First();
Assert.Contains("Intentional failure", incident.Message);
}
}When testing fault scenarios, prefer using _fixture.GetActivityStatus(result, activity) to check if a specific activity faulted, rather than only checking the workflow-level status. This provides more granular test assertions.
Test Data Management
Effective test data management ensures reliable and maintainable tests.
Test Data Builders
Use the builder pattern to create test data:
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
public class TestWorkflowBuilder
{
private readonly List<IActivity> _activities = new();
private string _definitionId = Guid.NewGuid().ToString();
private string _name = "Test Workflow";
public TestWorkflowBuilder WithDefinitionId(string definitionId)
{
_definitionId = definitionId;
return this;
}
public TestWorkflowBuilder WithName(string name)
{
_name = name;
return this;
}
public TestWorkflowBuilder AddActivity(IActivity activity)
{
_activities.Add(activity);
return this;
}
public TestWorkflowBuilder AddWriteLine(string text)
{
_activities.Add(new WriteLine { Text = new Input<string>(text) });
return this;
}
public TestWorkflowBuilder AddSetVariable(string variableName, object value)
{
_activities.Add(new SetVariable
{
Variable = new Variable<object>(variableName),
Value = new Input<object>(value)
});
return this;
}
public Workflow Build()
{
return new Workflow
{
Identity = new WorkflowIdentity
{
DefinitionId = _definitionId
},
Name = _name,
Root = new Sequence
{
Activities = _activities
}
};
}
}
// Usage
var workflow = new TestWorkflowBuilder()
.WithDefinitionId("test-workflow-1")
.WithName("My Test Workflow")
.AddWriteLine("Starting")
.AddSetVariable("Counter", 0)
.AddWriteLine("Complete")
.Build();Test Fixtures
Use xUnit class fixtures for shared test data:
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public class WorkflowTestFixture : IAsyncLifetime
{
public IServiceProvider Services { get; private set; } = default!;
// Shared test workflows
public Workflow SimpleWorkflow { get; private set; } = default!;
public Workflow ComplexWorkflow { get; private set; } = default!;
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddElsa();
Services = services.BuildServiceProvider();
await Services.PopulateRegistriesAsync();
// Initialize shared test workflows
SimpleWorkflow = CreateSimpleWorkflow();
ComplexWorkflow = CreateComplexWorkflow();
}
private Workflow CreateSimpleWorkflow()
{
return new TestWorkflowBuilder()
.WithDefinitionId("simple-workflow")
.AddWriteLine("Simple test")
.Build();
}
private Workflow CreateComplexWorkflow()
{
return new TestWorkflowBuilder()
.WithDefinitionId("complex-workflow")
.AddWriteLine("Start")
.AddSetVariable("Value", 100)
.AddWriteLine("End")
.Build();
}
public Task DisposeAsync()
{
if (Services is IDisposable disposable)
disposable.Dispose();
return Task.CompletedTask;
}
}
// Use in test classes
public class MyWorkflowTests : IClassFixture<WorkflowTestFixture>
{
private readonly WorkflowTestFixture _fixture;
public MyWorkflowTests(WorkflowTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task SimpleWorkflow_ShouldExecute()
{
var runner = _fixture.Services.GetRequiredService<IWorkflowRunner>();
var result = await runner.RunAsync(_fixture.SimpleWorkflow);
result.Status.Should().Be(WorkflowStatus.Finished);
}
}Parameterized Tests
Use Theory and InlineData for data-driven tests:
using Xunit;
using FluentAssertions;
public class CalculationWorkflowTests : WorkflowTestBase
{
[Theory]
[InlineData(5, 10, 15)]
[InlineData(0, 0, 0)]
[InlineData(-5, 5, 0)]
[InlineData(100, 200, 300)]
public async Task AddNumbers_WithVariousInputs_ShouldReturnCorrectSum(
int a, int b, int expected)
{
// Arrange
var workflow = CreateAdditionWorkflow();
var input = new Dictionary<string, object>
{
["a"] = a,
["b"] = b
};
// Act
var result = await RunWorkflowAsync(workflow, input);
// Assert
result.Output["sum"].Should().Be(expected);
}
private Workflow CreateAdditionWorkflow()
{
return new Workflow
{
Root = new Sequence
{
Activities =
{
new SetVariable
{
Variable = new Variable<int>("A"),
Value = new Input<int>(context => context.GetInput<int>("a"))
},
new SetVariable
{
Variable = new Variable<int>("B"),
Value = new Input<int>(context => context.GetInput<int>("b"))
},
new SetVariable
{
Variable = new Variable<int>("Sum"),
Value = new Input<int>(context =>
context.GetVariable<int>("A") + context.GetVariable<int>("B"))
},
new SetOutput
{
OutputName = "sum",
OutputValue = new Input<object?>(context =>
context.GetVariable<int>("Sum"))
}
}
}
};
}
[Theory]
[MemberData(nameof(GetComplexTestData))]
public async Task ComplexCalculation_WithTestData_ShouldSucceed(
ComplexInput input, ComplexOutput expectedOutput)
{
// Test implementation
}
public static IEnumerable<object[]> GetComplexTestData()
{
yield return new object[]
{
new ComplexInput { X = 1, Y = 2, Z = 3 },
new ComplexOutput { Result = 6, Status = "Success" }
};
yield return new object[]
{
new ComplexInput { X = 0, Y = 0, Z = 0 },
new ComplexOutput { Result = 0, Status = "Success" }
};
}
}
public record ComplexInput(int X, int Y, int Z);
public record ComplexOutput(int Result, string Status);CI/CD Integration
Integrate workflow tests into your CI/CD pipeline for automated testing.
GitHub Actions
name: Workflow Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: elsa_test
POSTGRES_USER: elsa
POSTGRES_PASSWORD: elsa
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run Unit Tests
run: dotnet test --no-build --configuration Release --filter "Category=Unit" --logger "trx;LogFileName=unit-tests.trx"
- name: Run Integration Tests
env:
ConnectionStrings__Elsa: "Host=localhost;Port=5432;Database=elsa_test;Username=elsa;Password=elsa"
run: dotnet test --no-build --configuration Release --filter "Category=Integration" --logger "trx;LogFileName=integration-tests.trx"
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: |
**/*.trxAzure DevOps
trigger:
branches:
include:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
dotnetSdkVersion: '8.x'
stages:
- stage: Test
displayName: 'Run Tests'
jobs:
- job: UnitTests
displayName: 'Unit Tests'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
version: $(dotnetSdkVersion)
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
- task: DotNetCoreCLI@2
displayName: 'Build solution'
inputs:
command: 'build'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Run unit tests'
inputs:
command: 'test'
arguments: '--configuration $(buildConfiguration) --no-build --filter "Category=Unit" --collect:"XPlat Code Coverage"'
publishTestResults: true
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage'
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/**/*coverage.cobertura.xml'
- job: IntegrationTests
displayName: 'Integration Tests'
dependsOn: UnitTests
services:
postgres:
image: postgres:15
ports:
- 5432:5432
env:
POSTGRES_DB: elsa_test
POSTGRES_USER: elsa
POSTGRES_PASSWORD: elsa
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
version: $(dotnetSdkVersion)
- task: DotNetCoreCLI@2
displayName: 'Run integration tests'
inputs:
command: 'test'
arguments: '--configuration $(buildConfiguration) --filter "Category=Integration"'
env:
ConnectionStrings__Elsa: 'Host=localhost;Port=5432;Database=elsa_test;Username=elsa;Password=elsa'Test Categories
Organize tests with categories for selective execution:
using Xunit;
public class WorkflowTests
{
[Fact]
[Trait("Category", "Unit")]
public void UnitTest_ShouldPass()
{
// Fast, isolated test
}
[Fact]
[Trait("Category", "Integration")]
public async Task IntegrationTest_ShouldPass()
{
// Slower test with dependencies
}
[Fact]
[Trait("Category", "E2E")]
public async Task EndToEndTest_ShouldPass()
{
// Full system test
}
}
// Run specific category
// dotnet test --filter "Category=Unit"
// dotnet test --filter "Category=Integration"Common Testing Pitfalls & Solutions
Pitfall 1: Not Populating Registries
Problem: Workflows fail with "Activity type not found" errors.
Solution: Always call PopulateRegistriesAsync() after building the service provider:
var services = new ServiceCollection();
services.AddElsa();
var serviceProvider = services.BuildServiceProvider();
// Required for non-hosted scenarios
await serviceProvider.PopulateRegistriesAsync();Pitfall 2: Shared State Between Tests
Problem: Tests fail intermittently due to shared state.
Solution: Use IAsyncLifetime to ensure clean state for each test:
public class MyTests : IAsyncLifetime
{
private IServiceProvider? _services;
public async Task InitializeAsync()
{
// Fresh service provider for each test
var services = new ServiceCollection();
services.AddElsa();
_services = services.BuildServiceProvider();
await _services.PopulateRegistriesAsync();
}
public Task DisposeAsync()
{
(_services as IDisposable)?.Dispose();
return Task.CompletedTask;
}
}Pitfall 3: Testing Async Workflows Synchronously
Problem: Workflows with delays or blocking activities don't complete in tests.
Solution: Use proper async/await and consider timeout strategies:
[Fact]
public async Task WorkflowWithDelay_ShouldEventuallyComplete()
{
var workflow = CreateWorkflowWithDelay();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var result = await RunWorkflowAsync(workflow, cancellationToken: cts.Token);
result.Status.Should().Be(WorkflowStatus.Finished);
}Pitfall 4: Not Testing Edge Cases
Problem: Workflows fail in production with unexpected input.
Solution: Test boundary conditions and invalid inputs:
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid-format")]
public async Task Workflow_WithInvalidInput_ShouldHandleGracefully(string? input)
{
var workflow = CreateValidationWorkflow();
var result = await RunWorkflowAsync(workflow, new Dictionary<string, object>
{
["input"] = input!
});
// Should handle gracefully, not crash
result.Status.Should().NotBe(WorkflowStatus.Faulted);
}Pitfall 5: Ignoring Disposal
Problem: Resource leaks and test failures due to undisposed resources.
Solution: Implement proper disposal patterns:
public class MyTests : IAsyncLifetime
{
private IServiceProvider? _services;
private PostgreSqlContainer? _container;
public async Task DisposeAsync()
{
(_services as IDisposable)?.Dispose();
if (_container != null)
await _container.DisposeAsync();
}
}Pitfall 6: Hardcoded Wait Times
Problem: Tests are flaky due to race conditions or unnecessarily slow.
Solution: Use polling or workflow completion events instead of fixed delays:
public async Task<WorkflowState> WaitForWorkflowCompletionAsync(
string workflowInstanceId,
TimeSpan timeout)
{
var deadline = DateTime.UtcNow.Add(timeout);
var workflowStateStore = Services.GetRequiredService<IWorkflowStateStore>();
while (DateTime.UtcNow < deadline)
{
var state = await workflowStateStore.LoadAsync(workflowInstanceId);
if (state?.Status == WorkflowStatus.Finished ||
state?.Status == WorkflowStatus.Faulted)
{
return state;
}
await Task.Delay(100);
}
throw new TimeoutException($"Workflow did not complete within {timeout}");
}Best Practices for Workflow Testing
1. Test Pyramid
Follow the testing pyramid principle:
Many Unit Tests: Fast, isolated tests for individual activities and simple workflows
Some Integration Tests: Test workflow persistence, external dependencies
Few End-to-End Tests: Full system tests including UI and APIs
/\
/E2E\ <- Few, slow, brittle
/------\
/ INT \ <- Some, moderate speed
/----------\
/ UNIT \ <- Many, fast, reliable
/--------------\2. Arrange-Act-Assert Pattern
Structure tests clearly:
[Fact]
public async Task WorkflowTest_ShouldFollowAAAPattern()
{
// Arrange - Set up test data and dependencies
var workflow = CreateTestWorkflow();
var input = new Dictionary<string, object> { ["key"] = "value" };
// Act - Execute the workflow
var result = await RunWorkflowAsync(workflow, input);
// Assert - Verify the outcome
result.Status.Should().Be(WorkflowStatus.Finished);
result.Output.Should().ContainKey("result");
}3. Use Descriptive Test Names
Test names should describe what is being tested and expected outcome:
// Good
[Fact]
public async Task UserRegistrationWorkflow_WithValidEmail_ShouldCreateUser()
[Fact]
public async Task PaymentProcessing_WhenInsufficientFunds_ShouldReturnError()
// Bad
[Fact]
public async Task Test1()
[Fact]
public async Task WorkflowTest()4. Test One Thing Per Test
Each test should verify a single behavior:
// Good - Tests one specific behavior
[Fact]
public async Task EmailValidation_WithInvalidEmail_ShouldFail()
{
var result = await ValidateEmailAsync("invalid");
result.IsValid.Should().BeFalse();
}
[Fact]
public async Task EmailValidation_WithValidEmail_ShouldSucceed()
{
var result = await ValidateEmailAsync("[email protected]");
result.IsValid.Should().BeTrue();
}
// Bad - Tests multiple behaviors
[Fact]
public async Task EmailValidation_ShouldWorkCorrectly()
{
var result1 = await ValidateEmailAsync("invalid");
result1.IsValid.Should().BeFalse();
var result2 = await ValidateEmailAsync("[email protected]");
result2.IsValid.Should().BeTrue();
}5. Mock External Dependencies
Isolate workflows from external systems in unit tests:
using Moq;
public class WorkflowWithDependenciesTests : WorkflowTestBase
{
protected override void ConfigureServices(IServiceCollection services)
{
// Mock external email service
var emailServiceMock = new Mock<IEmailService>();
emailServiceMock
.Setup(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
services.AddSingleton(emailServiceMock.Object);
}
[Fact]
public async Task Workflow_ShouldUseEmailService()
{
var workflow = CreateWorkflowWithEmailActivity();
var result = await RunWorkflowAsync(workflow);
result.Status.Should().Be(WorkflowStatus.Finished);
// Verify email service was called
}
}6. Use Test Helpers and Utilities
Create reusable test utilities:
public static class WorkflowTestHelpers
{
public static Workflow CreateSimpleSequence(params IActivity[] activities)
{
return new Workflow
{
Root = new Sequence { Activities = activities.ToList() }
};
}
public static async Task<T> GetWorkflowOutput<T>(
WorkflowState state,
string outputName)
{
return (T)state.Output[outputName];
}
public static void AssertWorkflowCompleted(WorkflowState state)
{
state.Status.Should().Be(WorkflowStatus.Finished);
state.SubStatus.Should().Be(WorkflowSubStatus.Finished);
}
}7. Test Error Handling
Explicitly test failure scenarios:
[Fact]
public async Task Workflow_WhenActivityThrowsException_ShouldFault()
{
var workflow = new Workflow
{
Root = new Sequence
{
Activities =
{
new ThrowExceptionActivity(),
new WriteLine { Text = new Input<string>("Should not execute") }
}
}
};
var result = await RunWorkflowAsync(workflow);
result.Status.Should().Be(WorkflowStatus.Faulted);
result.WorkflowState.Incidents.Should().NotBeEmpty();
}8. Keep Tests Fast
Optimize test execution time:
Use in-memory databases for unit tests
Parallelize test execution where possible
Mock slow dependencies
Use test containers only for integration tests
// Mark tests that can run in parallel
[Collection("Parallel")]
public class FastUnitTests
{
// Tests here can run in parallel
}
// Mark tests that need to run serially
[Collection("Serial")]
public class IntegrationTests
{
// Tests here run one at a time
}9. Maintain Test Data
Keep test data close to tests and version controlled:
Tests/
├── Data/
│ ├── Workflows/
│ │ ├── simple-workflow.json
│ │ └── complex-workflow.json
│ └── TestData/
│ ├── valid-users.json
│ └── invalid-inputs.json
├── Unit/
│ └── WorkflowTests.cs
└── Integration/
└── PersistenceTests.cs10. Document Complex Test Scenarios
Add comments for complex test logic:
[Fact]
public async Task ComplexBusinessRule_ShouldBeEnforced()
{
// This test verifies the business rule that states:
// "Orders over $1000 require manager approval, except for
// VIP customers who have made more than 10 purchases"
var workflow = CreateOrderApprovalWorkflow();
var vipCustomer = new Customer
{
IsVip = true,
PurchaseCount = 15
};
var result = await RunWorkflowAsync(workflow, new Dictionary<string, object>
{
["customer"] = vipCustomer,
["orderAmount"] = 1500
});
// VIP customer should bypass approval
result.Output["requiresApproval"].Should().Be(false);
}Summary
Testing and debugging workflows is essential for building reliable workflow-based applications. This guide covered:
Unit Testing: Testing workflows and activities in isolation with xUnit and Elsa.Testing, including ActivityTestFixture for activity unit tests
Integration Testing: Using WorkflowTestFixture, TestContainers, and in-memory databases for integration tests
Async Testing: Using AsyncWorkflowRunner for testing workflows with timers and external triggers
Debugging: Techniques including execution journals, logging, breakpoints, and state inspection
Test Data Management: Builders, fixtures, and parameterized tests
CI/CD Integration: Automating tests in GitHub Actions and Azure DevOps
Common Pitfalls: Solutions to frequent testing challenges
Best Practices: Proven patterns for maintainable and effective workflow tests
By following these practices and patterns, along with the official Elsa testing helpers (ActivityTestFixture, WorkflowTestFixture, AsyncWorkflowRunner), you'll build a robust test suite that gives you confidence in your workflows and enables rapid, safe iteration on your workflow-based applications.
Additional Resources
Elsa Core Repository Examples
Elsa Core Test Guidelines - Official testing guidelines from the elsa-core repository
Unit Test Examples - Unit test examples using ActivityTestFixture
Integration Test Examples - Integration tests with WorkflowTestFixture
Component Test Examples - Component tests with AsyncWorkflowRunner
Testing Frameworks & Tools
Related Guides
Last updated