From e936d636ee073f2b5ac75da08fd15f930d83c726 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 25 Nov 2025 19:06:52 +0100 Subject: [PATCH] Add unit and integration tests for `Container` activity, covering behavior such as variable scoping, child activity execution, and mixed variable types. (#7102) * Add unit and integration tests for `Container` activity, covering behavior such as variable scoping, child activity execution, and mixed variable types. * Refactor `RunWorkflowAndCaptureOutput` method in `ContainerTests` for better code organization * Extract `TestContainer` to `Elsa.Testing.Shared.Activities` for reuse across test projects. --- .../Activities/TestContainer.cs | 38 +++ .../Activities/Container/ContainerTests.cs | 111 +++++++++ .../Activities/Container/Workflows.cs | 154 +++++++++++++ .../Composition/ContainerTests.cs | 217 ++++++++++++++++++ 4 files changed, 520 insertions(+) create mode 100644 src/common/Elsa.Testing.Shared/Activities/TestContainer.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs create mode 100644 test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs diff --git a/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs b/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs new file mode 100644 index 000000000..b22ae3755 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs @@ -0,0 +1,38 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Testing.Shared.Activities; + +/// +/// Concrete implementation of Container for testing that schedules children sequentially. +/// +public class TestContainer : Container +{ + private const string CurrentIndexProperty = "CurrentIndex"; + + protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) + { + await HandleItemAsync(context); + } + + private async ValueTask HandleItemAsync(ActivityExecutionContext context) + { + var currentIndex = context.GetProperty(CurrentIndexProperty); + var childActivities = Activities.ToList(); + + if (currentIndex >= childActivities.Count) + { + await context.CompleteActivityAsync(); + return; + } + + var nextActivity = childActivities.ElementAt(currentIndex); + await context.ScheduleActivityAsync(nextActivity, OnChildCompleted); + context.UpdateProperty(CurrentIndexProperty, x => x + 1); + } + + private async ValueTask OnChildCompleted(ActivityCompletedContext context) + { + await HandleItemAsync(context.TargetContext); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs new file mode 100644 index 000000000..687958c11 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs @@ -0,0 +1,111 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Activities.Container; + +/// +/// Integration tests for the activity. +/// +public class ContainerTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + + [Fact(DisplayName = "Container executes child activities in order")] + public async Task Container_ExecutesChildActivitiesInOrder() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SimpleContainerWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Activity 1", "Activity 2", "Activity 3" }, lines); + } + + [Fact(DisplayName = "Container with variables makes them available to child activities")] + public async Task Container_WithVariables_MakesThemAvailableToChildren() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new ContainerWithVariablesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Counter: 0", lines[0]); + Assert.Contains("Counter: 1", lines[1]); + Assert.Contains("Counter: 2", lines[2]); + } + + [Fact(DisplayName = "Container with nested containers scopes variables correctly")] + public async Task Container_WithNestedContainers_ScopesVariablesCorrectly() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new NestedContainersWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Outer: 10", lines[0]); + Assert.Contains("Inner: 20", lines[1]); + Assert.Contains("Outer again: 10", lines[2]); + } + + [Fact(DisplayName = "Container with unnamed variables auto-names them")] + public async Task Container_WithUnnamedVariables_AutoNamesThem() + { + // Arrange & Act + var (result, _) = await RunWorkflowAndCaptureOutput(new ContainerWithUnnamedVariablesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + // The workflow should complete successfully even with unnamed variables + } + + [Fact(DisplayName = "Container with no activities completes successfully")] + public async Task Container_WithNoActivities_CompletesSuccessfully() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new EmptyContainerWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Empty(lines); + } + + [Fact(DisplayName = "Container with multiple variable types handles them correctly")] + public async Task Container_WithMultipleVariableTypes_HandlesThemCorrectly() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new ContainerWithMixedVariableTypesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Int: 42", lines[0]); + Assert.Contains("String: Hello", lines[1]); + Assert.Contains("Bool: True", lines[2]); + } + + [Theory(DisplayName = "Container handles different numbers of child activities")] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task Container_HandlesDifferentNumbersOfChildActivities(int activityCount) + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new DynamicContainerWorkflow(activityCount)); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(activityCount, lines.Count); + for (var i = 0; i < activityCount; i++) + { + Assert.Equal($"Activity {i + 1}", lines[i]); + } + } + + private async Task<(RunWorkflowResult Result, List Lines)> RunWorkflowAndCaptureOutput(IWorkflow workflow) + { + var result = await _fixture.RunWorkflowAsync(workflow); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + return (result, lines); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs new file mode 100644 index 000000000..d9215d94a --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs @@ -0,0 +1,154 @@ +using Elsa.Testing.Shared.Activities; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; + +namespace Elsa.Workflows.IntegrationTests.Activities.Container; + +/// +/// Simple workflow with a container that executes activities sequentially. +/// +public class SimpleContainerWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + } + }; + } +} + +/// +/// Workflow demonstrating container with variables. +/// +public class ContainerWithVariablesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var counter = new Variable("Counter", 0); + + workflow.Root = new TestContainer + { + Variables = { counter }, + Activities = + { + new WriteLine(context => $"Counter: {counter.Get(context)}"), + new SetVariable { Variable = counter, Value = new(1) }, + new WriteLine(context => $"Counter: {counter.Get(context)}"), + new SetVariable { Variable = counter, Value = new(2) }, + new WriteLine(context => $"Counter: {counter.Get(context)}") + } + }; + } +} + +/// +/// Workflow with nested containers to test variable scoping. +/// +public class NestedContainersWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var outerVar = new Variable("OuterVar", 10); + var innerVar = new Variable("InnerVar", 20); + + workflow.Root = new TestContainer + { + Variables = { outerVar }, + Activities = + { + new WriteLine(context => $"Outer: {outerVar.Get(context)}"), + new TestContainer + { + Variables = { innerVar }, + Activities = + { + new WriteLine(context => $"Inner: {innerVar.Get(context)}") + } + }, + new WriteLine(context => $"Outer again: {outerVar.Get(context)}") + } + }; + } +} + +/// +/// Workflow with unnamed variables to test auto-naming. +/// +public class ContainerWithUnnamedVariablesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer + { + Variables = + { + new Variable(), + new Variable(), + new Variable() + }, + Activities = + { + new WriteLine("Unnamed variables workflow") + } + }; + } +} + +/// +/// Workflow with an empty container (no activities). +/// +public class EmptyContainerWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer(); + } +} + +/// +/// Workflow with mixed variable types. +/// +public class ContainerWithMixedVariableTypesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var intVar = new Variable("IntVar", 42); + var stringVar = new Variable("StringVar", "Hello"); + var boolVar = new Variable("BoolVar", true); + + workflow.Root = new TestContainer + { + Variables = { intVar, stringVar, boolVar }, + Activities = + { + new WriteLine(context => $"Int: {intVar.Get(context)}"), + new WriteLine(context => $"String: {stringVar.Get(context)}"), + new WriteLine(context => $"Bool: {boolVar.Get(context)}") + } + }; + } +} + +/// +/// Workflow with a dynamic number of activities. +/// +public class DynamicContainerWorkflow(int activityCount) : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var container = new TestContainer(); + + for (var i = 1; i <= activityCount; i++) + { + container.Activities.Add(new WriteLine($"Activity {i}")); + } + + workflow.Root = container; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs new file mode 100644 index 000000000..f0a145e10 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs @@ -0,0 +1,217 @@ +using Elsa.Testing.Shared; +using Elsa.Testing.Shared.Activities; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Composition; + +/// +/// Unit tests for the activity focusing on ExecuteAsync behavior. +/// +public class ContainerTests +{ + [Theory(DisplayName = "Container schedules child activities")] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public async Task Container_SchedulesChildActivities(int activityCount) + { + // Arrange + var activities = CreateActivities(activityCount); + var container = CreateContainerWithActivities(activities); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert - First activity should be scheduled (sequential scheduling) + Assert.True(context.HasScheduledActivity(activities[0])); + } + + [Fact(DisplayName = "Container with no activities completes without scheduling")] + public async Task Container_WithNoActivities_CompletesWithoutScheduling() + { + // Arrange + var container = new TestContainer(); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(scheduledActivities); + } + + [Fact(DisplayName = "Container declares variables in memory")] + public async Task Container_DeclaresVariablesInMemory() + { + // Arrange + var variable1 = new Variable("Counter", 10); + var variable2 = new Variable("Name", "Test"); + var container = CreateContainerWithVariables(variable1, variable2); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + var memory = context.ExpressionExecutionContext.Memory; + Assert.True(memory.HasBlock(variable1.Id)); + Assert.True(memory.HasBlock(variable2.Id)); + } + + [Fact(DisplayName = "Container auto-names unnamed variables")] + public async Task Container_AutoNamesUnnamedVariables() + { + // Arrange + var var1 = new Variable(); + var var2 = new Variable(); + var var3 = new Variable(); + var container = CreateContainerWithVariables(var1, var2, var3); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("Variable1", var1.Name); + Assert.Equal("Variable2", var2.Name); + Assert.Equal("Variable3", var3.Name); + } + + [Fact(DisplayName = "Container preserves named variables")] + public async Task Container_PreservesNamedVariables() + { + // Arrange + var namedVar = new Variable("MyVariable", "value"); + var unnamedVar = new Variable(); + var container = CreateContainerWithVariables(namedVar, unnamedVar); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("MyVariable", namedVar.Name); + Assert.Equal("Variable1", unnamedVar.Name); + } + + [Theory(DisplayName = "Container handles multiple unnamed variables correctly")] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task Container_HandlesMultipleUnnamedVariables(int variableCount) + { + // Arrange + var variables = CreateUnnamedVariables(variableCount); + var container = CreateContainerWithVariables(variables.Cast().ToArray()); + + // Act + await ExecuteContainerAsync(container); + + // Assert + for (var i = 0; i < variableCount; i++) + { + Assert.Equal($"Variable{i + 1}", variables[i].Name); + } + } + + [Fact(DisplayName = "Container with mixed named and unnamed variables")] + public async Task Container_WithMixedNamedAndUnnamedVariables() + { + // Arrange + var namedVar1 = new Variable("FirstVar", 1); + var unnamedVar1 = new Variable(); + var namedVar2 = new Variable("SecondVar", "test"); + var unnamedVar2 = new Variable(); + var container = CreateContainerWithVariables(namedVar1, unnamedVar1, namedVar2, unnamedVar2); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("FirstVar", namedVar1.Name); + Assert.Equal("Variable1", unnamedVar1.Name); + Assert.Equal("SecondVar", namedVar2.Name); + Assert.Equal("Variable2", unnamedVar2.Name); + } + + [Fact(DisplayName = "Container schedules mixed activity types")] + public async Task Container_SchedulesMixedActivityTypes() + { + // Arrange + var writeLine = new WriteLine("Test output"); + var testVar = new Variable("testVar", 0, "testVar"); + var setVariable = new SetVariable(testVar, new Input(42)); + var mockActivity = Substitute.For(); + var container = CreateContainerWithActivities(writeLine, setVariable, mockActivity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.HasScheduledActivity(writeLine)); + } + + [Fact(DisplayName = "Container with single activity schedules it")] + public async Task Container_WithSingleActivity_SchedulesIt() + { + // Arrange + var activity = new WriteLine("Single activity"); + var container = CreateContainerWithActivities(activity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } + + [Fact(DisplayName = "Container with variables and activities works correctly")] + public async Task Container_WithVariablesAndActivities_WorksCorrectly() + { + // Arrange + var variable = new Variable("Counter", 0); + var activity = new WriteLine("Activity"); + var container = new TestContainer(); + container.Variables.Add(variable); + container.Activities.Add(activity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.ExpressionExecutionContext.Memory.HasBlock(variable.Id)); + Assert.True(context.HasScheduledActivity(activity)); + } + + private static Task ExecuteContainerAsync(TestContainer container) => + new ActivityTestFixture(container).ExecuteAsync(); + + private static IActivity[] CreateActivities(int count) => + Enumerable.Range(1, count) + .Select(i => new WriteLine($"Activity {i}")) + .Cast() + .ToArray(); + + private static List> CreateUnnamedVariables(int count) => + Enumerable.Range(0, count) + .Select(_ => new Variable()) + .ToList(); + + private static TestContainer CreateContainerWithActivities(params IActivity[] activities) + { + var container = new TestContainer(); + foreach (var activity in activities) + { + container.Activities.Add(activity); + } + return container; + } + + private static TestContainer CreateContainerWithVariables(params Variable[] variables) + { + var container = new TestContainer(); + foreach (var variable in variables) + { + container.Variables.Add(variable); + } + return container; + } +}