danielgerlag / workflow-core

Lightweight workflow engine for .NET Standard
MIT License
5.34k stars 1.19k forks source link

UnitTesting a workflow - step by step #1190

Open Ralf1108 opened 1 year ago

Ralf1108 commented 1 year ago

Is your feature request related to a problem? Please describe. When unit testing a workflow it is currently only possible to start the workflow and the workflow runs concurrently until the end or in an error, see Test helpers for Workflow Core

When testing a bigger workflow with many steps which also uses services vi DI the mocking and verifying of the service gets very cumbersome.

Describe the solution you'd like For testing purpose it would be nice to have an implicit wait step after each workflow step so a unittest can be structured like

  1. Start workflow() -> waits directly before first step
  2. Setup mocks for first step
  3. ProceedOneStep() -> executes one step and waits again
  4. Verify mocks/result data
  5. Setup mocks for next step
  6. ProceedOneStep() -> executes next step and waits again
  7. Verify mocks/result data
  8. -> continue until workflow is finished

In the test the expected workflow is known so the "ProceedOneStep()" calls will match the expected steps. If they doesn't match or the workflow changed then "ProceedOneStep()" will timeout and throw.

Describe alternatives you've considered I am currently investigating the built workflow steps and try to instrument them, so injecting a "WaitFor" step with a fixed event key after each step. For simple workflows with sequential flow it works almost but problematic are the more difficult ones.

It would be much simpler if the "IWorkflowBuilder" would provide interceptor events like "OnBeforeStepAdded" and "OnAfterStepAdded". Then in unittests this events could be used for instrumentation.

What do you think?

qrzychu commented 1 year ago

I guess you could roll out your own version of SyncWorkflowRunner, with a method AdvanceSingleStep to achieve this

Ralf1108 commented 1 year ago

Thx for the tip! I was successful using an own implementation of the IWorkflowExecutor. Now the workflow can be run stepwise... perfect for unittesting when setting mocks and verifications per step.

Here is my solution:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WorkflowCore.Interface;
using WorkflowCore.Models;
using WorkflowCore.Services;

namespace WorkflowCoreStepwiseWorkflow;

/// <summary>
/// Test stepwise workflow execution
/// </summary>
internal class Program3
{
    static async Task Main(string[] args)
    {
        File.Delete("mydb.db");

        var services = new ServiceCollection();
        services.AddLogging(x => x.AddConsole());
        services.AddTransient<LogStep>();
        services.AddWorkflow(x => { x.UseSqlite("Data Source=mydb.db", true); });

        services.AddSingleton<IWorkflowExecutor, StepwiseWorkflowExecutor>();

        var serviceProvider = services.BuildServiceProvider();
        var host = serviceProvider.GetRequiredService<IWorkflowHost>();
        host.RegisterWorkflow<TestWorkflow, TestWorkflowData>();

        host.OnStepError += Host_OnStepError;
        host.Start();

        var workflowId = await host.StartWorkflow("TestWorkflow", new TestWorkflowData());
        Console.WriteLine($"Started workflow: {workflowId}");

        var workflowExecutor = (StepwiseWorkflowExecutor)serviceProvider.GetRequiredService<IWorkflowExecutor>();
        var timeout = TimeSpan.FromSeconds(10);

        WaitForKey();
        workflowExecutor.ExecuteNextStep(timeout);

        WaitForKey();
        workflowExecutor.ExecuteNextStep(timeout);

        WaitForKey();
        workflowExecutor.ExecuteNextStep(timeout);

        WaitForKey();
        workflowExecutor.ExecuteNextStep(timeout);

        WaitForKey();
        workflowExecutor.ExecuteRemainingSteps();
    }

    private static void WaitForKey()
    {
        Console.WriteLine("Press key to continue");
        Console.ReadKey();
    }

    private static void Host_OnStepError(WorkflowInstance workflow, WorkflowStep step, Exception exception)
    {
        Console.WriteLine("Workflow Error: " + exception.Message);
    }

    class TestWorkflow : IWorkflow<TestWorkflowData>
    {
        public string Id => "TestWorkflow";
        public int Version => 1;

        public void Build(IWorkflowBuilder<TestWorkflowData> builder)
        {
            builder
                .StartWith<LogStep>()
                .Input(x => x.Number, x => 1)
                .Then<LogStep>()
                .Input(x => x.Number, x => 2)
                .Then<LogStep>()
                .Input(x => x.Number, x => 3)
                .Then<LogStep>()
                .Input(x => x.Number, x => 4);
        }
    }

    class LogStep : IStepBody
    {
        public int Number { get; set; }

        public async Task<ExecutionResult> RunAsync(IStepExecutionContext context)
        {
            Console.WriteLine($"LogStep: {Number}");
            return ExecutionResult.Next();
        }
    }

    class TestWorkflowData
    {
    }
}

/// <summary>
/// Issue: https://github.com/danielgerlag/workflow-core/issues/1190
/// Wraps default implementation: https://github.com/danielgerlag/workflow-core/blob/master/src/WorkflowCore/Services/WorkflowExecutor.cs
/// 
/// Register via: services.AddSingleton&lt;IWorkflowExecutor, StepwiseWorkflowExecutor&gt;();
/// </summary>
public class StepwiseWorkflowExecutor : IWorkflowExecutor
{
    private readonly WorkflowExecutor _wrapped;

    private readonly Semaphore _executeStep = new(0, int.MaxValue);
    private readonly Semaphore _executedStep = new(0, 1);

    public StepwiseWorkflowExecutor(
        IWorkflowRegistry registry,
        IServiceProvider serviceProvider,
        IScopeProvider scopeProvider,
        IDateTimeProvider datetimeProvider,
        IExecutionResultProcessor executionResultProcessor,
        ILifeCycleEventPublisher publisher,
        ICancellationProcessor cancellationProcessor,
        WorkflowOptions options,
        ILoggerFactory loggerFactory)
    {
        _wrapped = new WorkflowExecutor(registry,
            serviceProvider, scopeProvider, datetimeProvider,
            executionResultProcessor,
            publisher,
            cancellationProcessor,
            options,
            loggerFactory);
    }

    public async Task<WorkflowExecutorResult> Execute(
        WorkflowInstance workflow,
        CancellationToken cancellationToken = new())
    {
        while (!_executeStep.WaitOne(100))
            cancellationToken.ThrowIfCancellationRequested();

        var workflowExecutorResult = await _wrapped.Execute(workflow, cancellationToken);
        _executedStep.Release(1);
        return workflowExecutorResult;
    }

    public bool ExecuteNextStep(TimeSpan timeout, bool throwIfTimeout = true)
    {
        _executeStep.Release(1);
        var result = _executedStep.WaitOne(timeout);

        if (throwIfTimeout && !result)
            throw new InvalidOperationException("Timeout when waiting for next workflow step");

        return result;
    }

    /// <summary>
    /// WaitFor consists of 2 steps
    /// </summary>
    public bool ExecuteNextWaitFor(TimeSpan timeout, bool throwIfTimeout = true)
    {
        if (!ExecuteNextStep(timeout)) // WaitFor
            return false;

        return ExecuteNextStep(timeout); // Receive
    }

    /// <summary>
    /// Decide/Branch contains of 1 step
    /// </summary>
    public bool ExecuteNextDecideBranch(TimeSpan timeout, bool throwIfTimeout = true)
    {
        return ExecuteNextStep(timeout);
    }

    public void ExecuteRemainingSteps()
    {
        // let all remaining steps run to end
        _executeStep.Release(int.MaxValue / 2);
    }
}

Console output:

info: WorkflowCore.Services.WorkflowHost[0] Starting background tasks Started workflow: f2c41576-0fb2-4072-81cd-3f5a551ec7e1 Press key to continue LogStep: 1 Press key to continue LogStep: 2 Press key to continue LogStep: 3 Press key to continue LogStep: 4 Press key to continue

Ralf1108 commented 1 year ago

For reference: I created a base class for my unittests in case someone else needs something similar:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using WorkflowCore.Interface;
using WorkflowCore.Models;
using WorkflowCore.Testing;
using WorkflowCoreStepwiseWorkflow;
using Xunit;
using Xunit.Abstractions;

namespace WorkflowCoreStepwiseTestBaseClass;

public class WorkflowTestBase<TWorkflow, TData> : WorkflowTest<TWorkflow, TData>
    where TWorkflow : IWorkflow<TData>, new()
    where TData : class, new()
{
    private readonly ITestOutputHelper _testOutputHelper;

    protected TimeSpan TestTimeout { get; } = TimeSpan.FromSeconds(System.Diagnostics.Debugger.IsAttached ? 30 : 3);

    private StepwiseWorkflowExecutor _workflowExecutor = null!; // will be set in setup

    public WorkflowTestBase(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }

    /// <summary>
    /// We need to access the ServiceProvider
    /// Method copied from: https://github.com/danielgerlag/workflow-core/blob/master/src/WorkflowCore.Testing/WorkflowTest.cs
    /// </summary>
    protected override void Setup()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        ConfigureServices(services);

        var serviceProvider = services.BuildServiceProvider();

        PersistenceProvider = serviceProvider.GetRequiredService<IPersistenceProvider>();
        Host = serviceProvider.GetRequiredService<IWorkflowHost>();
        Host.RegisterWorkflow<TWorkflow, TData>();
        Host.OnStepError += Host_OnStepError;
        Host.Start();

        _workflowExecutor = (StepwiseWorkflowExecutor)serviceProvider.GetRequiredService<IWorkflowExecutor>();
    }

    protected override void ConfigureServices(IServiceCollection services)
    {
        base.ConfigureServices(services);

        services.AddLogging(x =>
                                x.AddConsole());

        services.AddSingleton<IWorkflowExecutor, StepwiseWorkflowExecutor>();

        services.AddTransient<LogStep>();
    }

    protected void TestStep(string stepName)
    {
        _testOutputHelper.WriteLine($"Testing step '{stepName}'...");
    }

    protected void TestWaitFor(string waitName)
    {
        _testOutputHelper.WriteLine($"Testing wait for '{waitName}'...");
    }

    protected void TestDecideBranch(string decideName)
    {
        _testOutputHelper.WriteLine($"Testing decide/branch '{decideName}'...");
    }

    protected bool ExecuteNextStep(TimeSpan timeout, bool throwIfTimeout = true)
    {
        return _workflowExecutor.ExecuteNextStep(timeout, throwIfTimeout);
    }

    protected bool ExecuteNextWaitFor(TimeSpan timeout, bool throwIfTimeout = true)
    {
        return _workflowExecutor.ExecuteNextWaitFor(timeout, throwIfTimeout);
    }

    protected bool ExecuteNextDecideBranch(TimeSpan timeout, bool throwIfTimeout = true)
    {
        return _workflowExecutor.ExecuteNextDecideBranch(timeout, throwIfTimeout);
    }
}

public class TestWorkflowTests : WorkflowTestBase<TestWorkflow, TestWorkflowData>
{
    private readonly Mock<IMonitor> _monitor;

    public TestWorkflowTests(ITestOutputHelper testOutputHelper) 
        : base(testOutputHelper)
    {
        _monitor = new Mock<IMonitor>();
    }

    protected override void ConfigureServices(IServiceCollection services)
    {
        base.ConfigureServices(services);

        services.AddTransient(_ => _monitor.Object);
    }

    [Fact]
    public async Task SimpleTest()
    {
        // Arrange
        Setup();

        // Act
        var data = new TestWorkflowData();
        var workflowId = await StartWorkflowAsync(data);

        TestStep(nameof(LogStep));
        ExecuteNextStep(TestTimeout);
        _monitor.Verify(x => x.Execute(1));

        TestStep(nameof(LogStep));
        ExecuteNextStep(TestTimeout);
        _monitor.Verify(x => x.Execute(2));

        TestStep(nameof(LogStep));
        ExecuteNextStep(TestTimeout);
        _monitor.Verify(x => x.Execute(3));

        TestStep(nameof(LogStep));
        ExecuteNextStep(TestTimeout);
        _monitor.Verify(x => x.Execute(4));
    }
}

public class TestWorkflow : IWorkflow<TestWorkflowData>
{
    public string Id => "TestWorkflow";
    public int Version => 1;

    public void Build(IWorkflowBuilder<TestWorkflowData> builder)
    {
        builder
            .StartWith<LogStep>()
            .Input(x => x.Number, x => 1)
            .Then<LogStep>()
            .Input(x => x.Number, x => 2)
            .Then<LogStep>()
            .Input(x => x.Number, x => 3)
            .Then<LogStep>()
            .Input(x => x.Number, x => 4);
    }
}

public class LogStep : IStepBody
{
    private readonly IMonitor _monitor;

    public int Number { get; set; }

    public LogStep(IMonitor monitor)
    {
        _monitor = monitor;
    }

    public async Task<ExecutionResult> RunAsync(IStepExecutionContext context)
    {
        Console.WriteLine($"LogStep: {Number}");
        _monitor.Execute(Number);
        return ExecutionResult.Next();
    }
}

public interface IMonitor
{
    void Execute(int number);
}

public class TestWorkflowData
{
}