diff --git a/Directory.Packages.props b/Directory.Packages.props
index 232f8c561..2c181897c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,174 +9,175 @@
9.9.0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Elsa.sln b/Elsa.sln
index f92369873..ba08d1d2c 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -311,6 +311,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "qa", "qa", "{0478E6EA-DCB2-
doc\qa\test-guidelines.md = doc\qa\test-guidelines.md
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Management.UnitTests", "test\unit\Elsa.Workflows.Management.UnitTests\Elsa.Workflows.Management.UnitTests.csproj", "{20C75446-CAE0-48D1-85D9-459541C4D8B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests", "test\unit\Elsa.Expressions.UnitTests\Elsa.Expressions.UnitTests.csproj", "{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -619,6 +623,14 @@ Global
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -714,6 +726,8 @@ Global
{DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92}
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA} = {18453B51-25EB-4317-A4B3-B10518252E92}
{0478E6EA-DCB2-4667-ADC2-37C62C9C2574} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
+ {20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92}
+ {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md
index 6fbafb73d..de0b29cc1 100644
--- a/doc/qa/test-guidelines.md
+++ b/doc/qa/test-guidelines.md
@@ -45,7 +45,7 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o
## Quick Start for Contributors
-**Before you writ e a test:**
+**Before you write a test:**
1. ✅ Understand what you're testing (see [**Which parts of Elsa to test**](#which-parts-of-elsa-to-test-and-which-test-types-to-use))
2. ✅ Choose the right test layer (unit vs integration vs component)
3. ✅ Use existing helpers (don't reinvent - see [**Test Helpers Reference**](#test-helpers-reference-quick-lookup))
@@ -68,6 +68,24 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o
---
+## Test Project Organization
+
+Tests are organized by module and test type:
+
+- **`test/unit/Elsa.Activities.UnitTests`** - Activity unit tests
+- **`test/unit/Elsa.Expressions.UnitTests`** - Expression evaluation unit tests
+- **`test/unit/Elsa.Workflows.Core.UnitTests`** - Core workflow unit tests (ActivityExecutionContext extensions, etc.)
+- **`test/unit/Elsa.Workflows.Management.UnitTests`** - Workflow management unit tests
+- **`test/integration/Elsa.Workflows.IntegrationTests`** - Workflow integration tests
+- **`test/component/Elsa.Workflows.ComponentTests`** - End-to-end component tests
+
+**Shared test infrastructure:**
+- **`src/common/Elsa.Testing.Shared`** - Shared test helpers (`ActivityTestFixture`, fake strategies, extensions)
+- **`src/common/Elsa.Testing.Shared.Integration`** - Integration test helpers (`RunActivityExtensions`, `PopulateRegistriesAsync`)
+- **`src/common/Elsa.Testing.Shared.Component`** - Component test helpers (event handlers, workflow events)
+
+---
+
## Which parts of Elsa to test, and which test types to use
This section maps Elsa aspects to the exact kinds of tests you should write, with examples and code patterns referencing repository conventions.
@@ -76,9 +94,11 @@ This section maps Elsa aspects to the exact kinds of tests you should write, wit
#### **Unit tests:**
- Test the activity class logic only (no persistence, no scheduler). Cover configuration permutations and boundary inputs.
-- Use [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs), `ExecuteActivityAsync` method to run the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions.
+- Use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) to configure and execute the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions.
+- Use fluent methods: `ConfigureServices()` to add custom services, `ConfigureContext()` to set up context state
+- Assert using `context.GetActivityOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)) for outputs, or `context.Get()` for variables.
-**Example:**
+**Example (does not set output):**
```csharp
[Fact]
public async Task Should_Set_Variable_Integer()
@@ -89,7 +109,8 @@ public async Task Should_Set_Variable_Integer()
var setVariable = new SetVariable(variable, new Input(expected));
// Act
- var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable);
+ var fixture = new ActivityTestFixture(setVariable);
+ var context = await fixture.ExecuteAsync();
// Assert
var result = context.Get(variable);
@@ -97,13 +118,56 @@ public async Task Should_Set_Variable_Integer()
}
```
+**Example (sets output using fluent configuration):**
+```csharp
+[Fact]
+public async Task Should_Send_Get_Request_And_Handle_Success_Response()
+{
+ // Arrange
+ var expectedUrl = new Uri("https://example.com");
+ var sendHttpRequest = new SendHttpRequest
+ {
+ Url = new Input(expectedUrl),
+ Method = new Input("GET")
+ };
+
+ // Act
+ var fixture = new ActivityTestFixture(sendHttpRequest)
+ .WithHttpServices(responseHandler); // HTTP-specific extension
+ var context = await fixture.ExecuteAsync();
+
+ // Assert
+ var statusCodeOutput = context.GetActivityOutput(_ => sendHttpRequest.StatusCode);
+ Assert.Equal(200, statusCodeOutput);
+}
+```
+
+**Example (checking scheduled activities):**
+```csharp
+[Fact]
+public async Task Should_Schedule_Child_Activity()
+{
+ // Arrange
+ var childActivity = new WriteLine("Hello");
+ var parentActivity = new Sequence { Activities = [childActivity] };
+
+ // Act
+ var fixture = new ActivityTestFixture(parentActivity);
+ var context = await fixture.ExecuteAsync();
+
+ // Assert
+ Assert.True(context.HasScheduledActivity(childActivity));
+}
+```
+
#### **Integration tests:**
- Place the activity inside a minimal workflow definition and run via [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs). Assert outputs/variables and that the activity integrates correctly with preceding/following activities.
- If activity creates bookmarks or relies on scheduler semantics, integration tests should resume bookmarks via the engine APIs to validate resumption.
-Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion.
+Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion.
Use returned state for deterministic assertions where possible.
+---
### Workflow execution (invoker, middleware, bookmarks)
@@ -140,14 +204,38 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status);
## Test Helpers Reference (Quick Lookup)
-| Helper | Purpose | Use When |
-|-------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------|
-| `TestApplicationBuilder` | Build test service provider | All tests as entry point, except activities unit tests (see below) |
-| `ActivityTestHelper.ExecuteActivityAsync` | Run single activity, with isolated context | Unit testing activities |
-| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests |
-| `PopulateRegistriesAsync` | Register types for JSON deserialization. | Loading JSON workflows. Integration tests only |
-| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) |
-| `RunWorkflowUntilEndAsync` | Drive workflow to completion. | Complex resumption scenarios. Integration tests only |
+| Helper | Purpose | Use When |
+|-------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------|
+| `TestApplicationBuilder` | Build test service provider | All tests as entry point, except activities unit tests (see below) |
+| `ActivityTestFixture` | Configure and run single activity in isolation | Unit testing activities |
+| `ActivityTestFixture.ConfigureServices` | Fluent API to add services to fixture | Activity unit tests requiring custom services |
+| `ActivityTestFixture.ConfigureContext` | Fluent API to configure execution context before execution | Setting up test-specific context state |
+| `ActivityTestFixture.BuildAsync` | Build context without executing activity | When you need context setup without execution |
+| `ActivityTestFixture.ExecuteAsync` | Execute activity and return context | Standard activity unit test execution |
+| `context.GetActivityOutput` | Get output from activity using expression selector | Asserting activity outputs in unit tests |
+| `context.HasScheduledActivity` | Check if activity is scheduled | Verifying scheduling behavior in unit tests |
+| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests |
+| `RunActivityExtensions.RunActivityAsync` | Run single activity as workflow | Integration tests for single activities |
+| `PopulateRegistriesAsync` | Register types for JSON deserialization | Loading JSON workflows. Integration tests only |
+| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) |
+| `AsyncWorkflowRunner.RunAndAwaitWorkflowCompletionAsync` | Drive workflow to completion with activity tracking | Complex async scenarios. Component tests only |
+| `FakeActivityExecutionContextSchedulerStrategy` | Fake scheduler for unit tests | Automatically configured in `ActivityTestFixture` |
+| `FakeWorkflowExecutionContextSchedulerStrategy` | Fake workflow scheduler for unit tests | Automatically configured in `ActivityTestFixture` |
+
+---
+
+## Scheduler Strategies for Testing
+
+When testing activities in isolation, Elsa provides fake scheduler strategy implementations that bypass the production scheduling logic:
+
+### Available Strategies
+
+- **`FakeActivityExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately within the activity execution context for deterministic testing
+- **`FakeWorkflowExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately at the workflow level for deterministic testing
+
+These strategies are automatically configured when using [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) and ensure that scheduled activities execute synchronously in tests, making assertions deterministic.
+
+**Note:** You do not need to manually configure these strategies - `ActivityTestFixture` handles this for you.
---
@@ -171,7 +259,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu
## Deterministic patterns to avoid flaky tests
-1. **For activity unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows.
+1. **For activity unit tests, prefer returned state from [`ActivityTestFixture.ExecuteAsync`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows.
2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution.
3. **For integration tests, locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries.
@@ -183,7 +271,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu
---
-## Practical test recipes & snippets (copy/paste-ready)
+## Practical test recipes & snippets
### Unit test (activity) — pattern
@@ -194,7 +282,8 @@ public async Task MyActivity_Test()
var activity = new ActivityToTest();
// Act
- var context = await ActivityTestHelper.ExecuteActivityAsync(activity);
+ var fixture = new ActivityTestFixture(activity);
+ var context = await fixture.ExecuteAsync();
// assert behavior of activity in isolation
}
@@ -228,10 +317,35 @@ public async Task Workflow_Persists_Instance_And_Journal()
.Build();
var runner = sp.GetRequiredService();
-var result = await runner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published));
+ var result = await runner.RunAndAwaitWorkflowCompletionAsync(
+ WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published)
+ );
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
}
```
+
+### Component test with async workflow execution
+
+For testing workflows that complete asynchronously (e.g., with timers, external triggers), use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs):
+
+```csharp
+[Fact]
+public async Task Workflow_Completes_Asynchronously()
+{
+ var sp = new TestApplicationBuilder(testOutput).Build();
+ var runner = sp.GetRequiredService();
+
+ var result = await runner.RunAndAwaitWorkflowCompletionAsync(
+ WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published)
+ );
+
+ result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
+ result.ActivityExecutionRecords.Should().HaveCount(expectedCount);
+}
+```
+
+`AsyncWorkflowRunner` tracks activity execution records and awaits workflow completion signals, making it ideal for testing asynchronous workflow behavior deterministically.
+
---
## FAQ (quick pointers)
@@ -244,7 +358,7 @@ Leave the definitions next to the tests that use them.
**Q: Which helper should I use to run a workflow?**
-A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use `ExecuteActivityAsync` via [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).
+A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).
**Q: How do I check persisted journal entries?**
@@ -252,7 +366,7 @@ A: Query [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/
**Q: Do I need a new helper to wait for workflow completion?**
-A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios.
+A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. For async workflows in component tests, use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs).
---
@@ -260,9 +374,9 @@ A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Cont
Search the `test/` tree for examples that follow the above patterns:
-- Unit test activity examples: `test/unit/Elsa.Activities.UnitTests` (look for [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) usage).
-- Integration workflow examples: `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage).
-- Component scenarios exercising persistence: `test/component/Elsa.Workflows.ComponentTests (look for [`AppComponentTest`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs) scaffolds and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions).
+- **Unit test activity examples:** `test/unit/Elsa.Activities.UnitTests` (look for [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) usage)
+- **Integration workflow examples:** `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage)
+- **Component scenarios exercising persistence:** `test/component/Elsa.Workflows.ComponentTests` (look for [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs) and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions)
diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
new file mode 100644
index 000000000..fc5f5bff3
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
@@ -0,0 +1,20 @@
+using Elsa.Workflows;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// Provides extension methods for to enhance its functionality.
+///
+public static class ActivityExecutionContextExtensions
+{
+ ///
+ /// Determines whether the specified activity is scheduled in the workflow execution context.
+ ///
+ /// The activity execution context instance used for checking the scheduled activity.
+ /// The activity to check for scheduling.
+ /// True if the specified activity is scheduled; otherwise, false.
+ public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity)
+ {
+ return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
+ }
+}
\ No newline at end of file
diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
similarity index 52%
rename from test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs
rename to src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
index b8f623ed6..8412d2189 100644
--- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs
+++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
@@ -4,89 +4,102 @@ using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Workflows;
+using Elsa.Workflows.Activities;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Management.Providers;
using Elsa.Workflows.Management.Services;
+using Elsa.Workflows.Memory;
using Elsa.Workflows.PortResolvers;
+using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
-namespace Elsa.Activities.UnitTests.Helpers;
+namespace Elsa.Testing.Shared;
///
-/// Helper class for unit testing activities in isolation.
-/// Provides methods to execute activities with minimal setup, abstracting away the complexity
-/// of creating WorkflowExecutionContext and ActivityExecutionContext.
+/// A test fixture for unit testing activities in isolation.
+/// Provides a fluent API to configure services, variables, and execution context.
///
-public static class ActivityTestHelper
+public class ActivityTestFixture
{
+ private Action? _configureContextAction;
+
///
- /// Executes an activity in isolation for unit testing purposes and returns the execution context.
- /// This method handles all the complexity of setting up the execution context,
- /// evaluating inputs, and executing the activity.
+ /// Initializes a new instance of the class.
///
- /// The activity to execute
- /// An optional action to configure the service collection for dependency injection during activity execution.
- /// The ActivityExecutionContext used for execution
- public static async Task ExecuteActivityAsync(IActivity activity, Action? configureServices = null)
+ /// The activity to test
+ public ActivityTestFixture(IActivity activity)
{
- var context = await CreateMinimalActivityExecutionContext(activity, configureServices);
+ Activity = activity;
+ Services = new ServiceCollection();
+ AddCoreWorkflowServices(Services);
+ }
+
+ ///
+ /// Represents the activity being tested within the context of the activity test fixture.
+ /// Provides access to the activity for configuration, execution, and validation purposes.
+ ///
+ public IActivity Activity { get; }
+
+ ///
+ /// Gets the service collection for registering additional services.
+ /// Use this to add services required by the activity under test.
+ ///
+ [UsedImplicitly]
+ public IServiceCollection Services { get; private set; }
+
+ ///
+ /// Configures the service collection using a fluent action.
+ ///
+ /// Action to configure the service collection
+ /// The fixture instance for method chaining
+ public ActivityTestFixture ConfigureServices(Action configure)
+ {
+ configure(Services);
+ return this;
+ }
+
+ ///
+ /// Configures the activity execution context before execution.
+ /// Multiple calls to this method will chain the configuration actions together.
+ ///
+ /// Action to configure the activity execution context
+ /// The fixture instance for method chaining
+ [UsedImplicitly]
+ public ActivityTestFixture ConfigureContext(Action configure)
+ {
+ _configureContextAction += configure;
+ return this;
+ }
+
+ ///
+ /// Executes the activity and returns the execution context.
+ ///
+ /// The ActivityExecutionContext after execution
+ public async Task ExecuteAsync()
+ {
+ var context = await BuildAsync();
// Set up variables and inputs, then execute the activity
- await SetupExistingVariablesAsync(activity, context);
+ await SetupExistingVariablesAsync(Activity, context);
await context.EvaluateInputPropertiesAsync();
- await activity.ExecuteAsync(context);
+ await Activity.ExecuteAsync(context);
return context;
}
///
- /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities.
- /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern
- /// to provide proper context for activities.
+ /// Builds the ActivityExecutionContext without executing the activity.
///
- private static async Task CreateMinimalActivityExecutionContext(IActivity activity, Action? configureServices)
+ public async Task BuildAsync()
{
- // Create a minimal service provider with the required services for expression evaluation
- var services = new ServiceCollection();
-
- // Add core services
- services.AddLogging();
- services.AddSingleton(_ => Substitute.For());
- services.AddSingleton(_ => Substitute.For());
- services.AddSingleton();
- services.AddScoped();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
-
- // Add the default expression descriptor provider which includes Literal expressions
- services.AddSingleton();
- services.AddSingleton();
-
- services.AddSingleton(_ => Substitute.For());
-
- services.AddSingleton(_ => Substitute.For());
- services.AddSingleton(_ => Substitute.For());
- services.AddSingleton(_ => Substitute.For());
-
- // Call the configure services action if provided.
- configureServices?.Invoke(services);
-
- var serviceProvider = services.BuildServiceProvider();
+ var serviceProvider = Services.BuildServiceProvider();
var activityRegistry = serviceProvider.GetRequiredService();
var workflowGraphBuilder = serviceProvider.GetRequiredService();
- await activityRegistry.RegisterAsync(activity.GetType());
- var workflow = Workflow.FromActivity(activity);
+
+ await activityRegistry.RegisterAsync(Activity.GetType());
+
+ var workflow = Workflow.FromActivity(Activity);
var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow);
// Create workflow execution context using the static factory method
@@ -98,7 +111,12 @@ public static class ActivityTestHelper
);
// Create ActivityExecutionContext for the actual activity we want to test
- return await workflowExecutionContext.CreateActivityExecutionContextAsync(activity);
+ var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity);
+
+ // Apply any context configuration action
+ _configureContextAction?.Invoke(context);
+
+ return context;
}
///
@@ -120,4 +138,33 @@ public static class ActivityTestHelper
return Task.CompletedTask;
}
+
+ private static void AddCoreWorkflowServices(IServiceCollection services)
+ {
+ services.AddLogging();
+ services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs
new file mode 100644
index 000000000..6d473e011
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs
@@ -0,0 +1,43 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Attributes;
+using Xunit;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// General extension methods for ActivityTestFixture.
+///
+public static class ActivityTestFixtureExtensions
+{
+ ///
+ /// Validates that an activity has the expected attribute configuration.
+ /// This is useful for ensuring activities are properly decorated with metadata.
+ ///
+ /// The test fixture (unused but enables extension method syntax)
+ /// Expected namespace (e.g., "Elsa")
+ /// /// Expected activity kind
+ /// Expected category (e.g., "HTTP")
+ /// Expected display name
+ /// Expected description
+ public static void AssertActivityAttributes(
+ this ActivityTestFixture fixture,
+ string expectedNamespace,
+ ActivityKind expectedKind,
+ string? expectedCategory = null,
+ string? expectedDisplayName = null,
+ string? expectedDescription = null
+ )
+ {
+ var activityType = fixture.Activity.GetType();
+ var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false)
+ .Cast().FirstOrDefault();
+
+ Assert.NotNull(activityAttribute);
+ Assert.Equal(expectedNamespace, activityAttribute.Namespace);
+ Assert.Equal(expectedKind, activityAttribute.Kind);
+
+ if (expectedCategory != null) Assert.Equal(expectedCategory, activityAttribute.Category);
+ if (expectedDescription != null) Assert.Equal(expectedDescription, activityAttribute.Description);
+ if (expectedDisplayName != null) Assert.Equal(expectedDisplayName, activityAttribute.DisplayName);
+ }
+}
diff --git a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj
index 812045425..89c9b383e 100644
--- a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj
+++ b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj
@@ -9,7 +9,9 @@
+
+
diff --git a/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs
new file mode 100644
index 000000000..c8bfeb541
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs
@@ -0,0 +1,46 @@
+using Elsa.Extensions;
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Options;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// Provides a fake implementation of the interface,
+/// designed for testing purposes. This class allows scheduling of activities within an activity execution context
+/// and enables control over workflows in a simulated environment.
+///
+public class FakeActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy
+{
+ ///
+ /// Schedules the provided activity for execution within the given activity execution context.
+ ///
+ /// The current activity execution context in which to schedule the activity.
+ /// The activity to be scheduled. Can be null, in which case no operation is performed.
+ /// The owner activity execution context, if any.
+ /// Optional scheduling options to influence how the activity is scheduled.
+ public Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null)
+ {
+ if (activity == null)
+ return Task.CompletedTask;
+
+ var activityNode = new ActivityNode(activity!, "");
+ return ScheduleActivityAsync(context, activityNode, owner, options);
+ }
+
+ ///
+ /// Schedules the provided activity for execution within the specified activity execution context using the given scheduling strategy.
+ ///
+ /// The activity execution context in which the activity will be scheduled.
+ /// The activity node to be scheduled. If null, no operation is performed.
+ /// The owner activity execution context, if any, that triggered the scheduling request.
+ /// Optional scheduling parameters to customize the scheduling behavior.
+ public Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null)
+ {
+ if (activityNode == null)
+ return Task.CompletedTask;
+
+ context.WorkflowExecutionContext.Schedule(activityNode!, owner ?? context, options);
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs
new file mode 100644
index 000000000..3604678ad
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs
@@ -0,0 +1,26 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Options;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// Bypasses validation and schedules the activity immediately.
+///
+public class FakeWorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy
+{
+ ///
+ /// Schedules an activity within the workflow execution context.
+ ///
+ /// The workflow execution context within which the activity will be scheduled.
+ /// The activity node representing the activity to be scheduled.
+ /// The execution context owning the activity execution.
+ /// Optional scheduling options for configuring the behavior of the scheduling process.
+ /// Returns an representing the scheduled activity work item.
+ public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null)
+ {
+ var workItem = new ActivityWorkItem(activityNode.Activity, owner);
+ context.Scheduler.Schedule(workItem);
+ return workItem;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs
index a7c8092e6..4a4f24811 100644
--- a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs
+++ b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs
@@ -4,37 +4,25 @@ using Elsa.Expressions.Models;
namespace Elsa.Expressions.Services;
///
-public class ExpressionEvaluator : IExpressionEvaluator
+public class ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) : IExpressionEvaluator
{
- private readonly IExpressionDescriptorRegistry _registry;
- private readonly IServiceProvider _serviceProvider;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider)
- {
- _registry = registry;
- _serviceProvider = serviceProvider;
- }
-
///
- public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default)
+ public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null)
{
return (T?)await EvaluateAsync(expression, typeof(T), context, options);
}
///
- public async ValueTask