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

1

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.visualstudio
2

Configure Test Infrastructure

Create a base test class to set up the Elsa service container:

WorkflowTestBase.cs
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:

UserValidationWorkflowTests.cs
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:

CustomActivityTests.cs
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:

WorkflowInputOutputTests.cs
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:

UsingActivityTestFixture.cs
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:

UsingWorkflowTestFixture.cs
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:

CreatingExecutionContexts.cs
// 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:

TestingAsyncWorkflows.cs
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:

1

Install TestContainers

dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.RabbitMq
2

Create Integration Test Base

IntegrationTestBase.cs
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();
    }
}
3

Write Integration Tests

WorkflowPersistenceTests.cs
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:

HttpWorkflowTests.cs
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:

InMemoryTestBase.cs
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:

ViewExecutionJournal.cs
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:

ConfigureLogging.cs
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:

DebugWorkflow.cs
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:

BreakpointActivity.cs
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:

InspectWorkflowState.cs
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:

DebugFailures.cs
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:

TestingFaultedWorkflows.cs
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:

WorkflowBuilder.cs
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:

WorkflowTestFixture.cs
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:

ParameterizedTests.cs
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

.github/workflows/test.yml
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: |
          **/*.trx

Azure DevOps

azure-pipelines.yml
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:

CategorizedTests.cs
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.cs

10. 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

Testing Frameworks & Tools

Last updated