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 d2fa07e7b..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,8 +94,9 @@ 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.
-- In case the activity sets outputs, assert using `context.GetExecutionOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)), otherwise, use `context.Get()`.
+- 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 (does not set output):**
```csharp
@@ -90,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);
@@ -98,39 +118,56 @@ public async Task Should_Set_Variable_Integer()
}
```
-**Example (sets output):**
+**Example (sets output using fluent configuration):**
```csharp
[Fact]
- public async Task Should_Send_GET_Request_And_Handle_Success_Response()
+public async Task Should_Send_Get_Request_And_Handle_Success_Response()
+{
+ // Arrange
+ var expectedUrl = new Uri("https://example.com");
+ var sendHttpRequest = new SendHttpRequest
{
- // Arrange
- // Extra arrange steps omitted for brevity
- var sendHttpRequest = new SendHttpRequest
- {
- Url = new Input(expectedUrl),
- Method = new Input("GET"),
- ExpectedStatusCodes = new List()
- };
+ Url = new Input(expectedUrl),
+ Method = new Input("GET")
+ };
- // Act
- var context = await ActivityTestHelper.ExecuteActivityAsync(sendHttpRequest, services =>
- {
- // http configuration omitted for brevity
- });
+ // Act
+ var fixture = new ActivityTestFixture(sendHttpRequest)
+ .WithHttpServices(responseHandler); // HTTP-specific extension
+ var context = await fixture.ExecuteAsync();
- // Assert
- var statusCodeOutput = context.GetExecutionOutput(_ => sendHttpRequest.StatusCode);
- Assert.Equal(200, statusCodeOutput);
- }
+ // 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)
@@ -167,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.
---
@@ -198,9 +259,9 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu
## Deterministic patterns to avoid flaky tests
-1. **For activities 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.
+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.
---
@@ -210,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
@@ -221,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
}
@@ -255,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)
@@ -271,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?**
@@ -279,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).
---
@@ -287,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/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
new file mode 100644
index 000000000..ab1b7dbb1
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
@@ -0,0 +1,174 @@
+using Elsa.Common;
+using Elsa.Expressions.Contracts;
+using Elsa.Expressions.Services;
+using Elsa.Extensions;
+using Elsa.Http.ContentWriters;
+using Elsa.Http.Parsers;
+using Elsa.Mediator.Contracts;
+using Elsa.Resilience;
+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;
+using Elsa.Http;
+
+namespace Elsa.Testing.Shared;
+
+///
+/// A test fixture for unit testing activities in isolation.
+/// Provides a fluent API to configure services, variables, and execution context.
+///
+public class ActivityTestFixture
+{
+ private Action? _configureContextAction;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The activity to test
+ public ActivityTestFixture(IActivity activity)
+ {
+ 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 context.EvaluateInputPropertiesAsync();
+ await Activity.ExecuteAsync(context);
+
+ return context;
+ }
+
+ ///
+ /// Builds the ActivityExecutionContext without executing the activity.
+ ///
+ public async Task BuildAsync()
+ {
+ var serviceProvider = Services.BuildServiceProvider();
+ var activityRegistry = serviceProvider.GetRequiredService();
+ var workflowGraphBuilder = serviceProvider.GetRequiredService();
+
+ 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
+ var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(
+ serviceProvider,
+ workflowGraph,
+ $"test-instance-{Guid.NewGuid()}",
+ CancellationToken.None
+ );
+
+ // Create ActivityExecutionContext for the actual activity we want to test
+ var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity);
+
+ // Apply any context configuration action
+ _configureContextAction?.Invoke(context);
+
+ return context;
+ }
+
+ ///
+ /// Sets up existing variables found on the activity in the execution context.
+ /// This is necessary because in unit tests, variables need to be initialized.
+ ///
+ private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context)
+ {
+ var activityType = activity.GetType();
+ var variableProperties = activityType.GetProperties()
+ .Where(p => p.PropertyType.IsGenericType &&
+ p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>))
+ .ToList();
+
+ foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!))
+ {
+ variable.Set(context.ExpressionExecutionContext, variable.Value);
+ }
+
+ 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();
+ }
+}
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