Skip to content

Commit 3b7601e

Browse files
authored
Add dependency injection support to DurableTaskTestHost (#613)
* initial commit * update * udpate version * udpate pkg ver * address coopilot feedback * fix small typo * update
1 parent da748a4 commit 3b7601e

File tree

9 files changed

+985
-12
lines changed

9 files changed

+985
-12
lines changed

Directory.Packages.props

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@
5151

5252
<!-- Microsoft.CodeAnalysis.* Packages -->
5353
<ItemGroup>
54-
<PackageVersion Include="Microsoft.CodeAnalysis" Version="4.12.0" />
54+
<PackageVersion Include="Microsoft.CodeAnalysis" Version="4.14.0" />
5555
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
56-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
57-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
58-
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
56+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
57+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
58+
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
5959
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
6060
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
6161
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.2" />
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using DurableTask.Core;
5+
using Grpc.Net.Client;
6+
using Microsoft.AspNetCore.Builder;
7+
using Microsoft.AspNetCore.Hosting;
8+
using Microsoft.AspNetCore.Server.Kestrel.Core;
9+
using Microsoft.DurableTask.Client;
10+
using Microsoft.DurableTask.Client.Grpc;
11+
using Microsoft.DurableTask.Testing.Sidecar;
12+
using Microsoft.DurableTask.Testing.Sidecar.Grpc;
13+
using Microsoft.DurableTask.Worker;
14+
using Microsoft.DurableTask.Worker.Grpc;
15+
using Microsoft.Extensions.DependencyInjection;
16+
using Microsoft.Extensions.Hosting;
17+
using Microsoft.Extensions.Logging;
18+
19+
namespace Microsoft.DurableTask.Testing;
20+
21+
/// <summary>
22+
/// Extension methods for integrating in-memory durable task testing with your existing DI container,
23+
/// such as WebApplicationFactory.
24+
/// </summary>
25+
public static class DurableTaskTestExtensions
26+
{
27+
/// <summary>
28+
/// These extensions allow you to inject the <see cref="InMemoryOrchestrationService"/> into your
29+
/// existing test host so that your orchestrations and activities can resolve services from your DI container.
30+
/// </summary>
31+
/// <param name="services">The service collection (from your WebApplicationFactory or host).</param>
32+
/// <param name="configureTasks">Action to register orchestrators and activities.</param>
33+
/// <param name="options">Optional configuration options.</param>
34+
/// <returns>The service collection for chaining.</returns>
35+
public static IServiceCollection AddInMemoryDurableTask(
36+
this IServiceCollection services,
37+
Action<DurableTaskRegistry> configureTasks,
38+
InMemoryDurableTaskOptions? options = null)
39+
{
40+
ArgumentNullException.ThrowIfNull(services);
41+
ArgumentNullException.ThrowIfNull(configureTasks);
42+
43+
options ??= new InMemoryDurableTaskOptions();
44+
45+
// Determine port for the internal gRPC server
46+
int port = options.Port ?? Random.Shared.Next(30000, 40000);
47+
string address = $"http://localhost:{port}";
48+
49+
// Register the in-memory orchestration service as a singleton
50+
services.AddSingleton<InMemoryOrchestrationService>(sp =>
51+
{
52+
var loggerFactory = sp.GetService<ILoggerFactory>();
53+
return new InMemoryOrchestrationService(loggerFactory);
54+
});
55+
services.AddSingleton<IOrchestrationService>(sp => sp.GetRequiredService<InMemoryOrchestrationService>());
56+
services.AddSingleton<IOrchestrationServiceClient>(sp => sp.GetRequiredService<InMemoryOrchestrationService>());
57+
58+
// Register the gRPC sidecar server as a hosted service
59+
services.AddSingleton<TaskHubGrpcServer>();
60+
services.AddHostedService<InMemoryGrpcSidecarHost>(sp =>
61+
{
62+
return new InMemoryGrpcSidecarHost(
63+
address,
64+
sp.GetRequiredService<InMemoryOrchestrationService>(),
65+
sp.GetService<ILoggerFactory>());
66+
});
67+
68+
// Create a gRPC channel that will connect to our internal sidecar
69+
services.AddSingleton<GrpcChannel>(sp => GrpcChannel.ForAddress(address));
70+
71+
// Register the durable task worker (connects to our internal sidecar)
72+
services.AddDurableTaskWorker(builder =>
73+
{
74+
builder.UseGrpc(address);
75+
builder.AddTasks(configureTasks);
76+
});
77+
78+
// Register the durable task client (connects to our internal sidecar)
79+
services.AddDurableTaskClient(builder =>
80+
{
81+
builder.UseGrpc(address);
82+
builder.RegisterDirectly();
83+
});
84+
85+
return services;
86+
}
87+
88+
/// <summary>
89+
/// Gets the <see cref="InMemoryOrchestrationService"/> from the service provider.
90+
/// Useful for advanced scenarios like inspecting orchestration state.
91+
/// </summary>
92+
/// <param name="services">The service provider.</param>
93+
/// <returns>The in-memory orchestration service instance.</returns>
94+
public static InMemoryOrchestrationService GetInMemoryOrchestrationService(this IServiceProvider services)
95+
{
96+
return services.GetRequiredService<InMemoryOrchestrationService>();
97+
}
98+
}
99+
100+
/// <summary>
101+
/// Options for configuring in-memory durable task support.
102+
/// </summary>
103+
public class InMemoryDurableTaskOptions
104+
{
105+
/// <summary>
106+
/// Gets or sets the port for the internal gRPC server.
107+
/// If not set, a random port between 30000-40000 will be used.
108+
/// </summary>
109+
public int? Port { get; set; }
110+
}
111+
112+
/// <summary>
113+
/// Internal hosted service that runs the gRPC sidecar within the user's host.
114+
/// </summary>
115+
sealed class InMemoryGrpcSidecarHost : IHostedService, IAsyncDisposable
116+
{
117+
readonly string address;
118+
readonly InMemoryOrchestrationService orchestrationService;
119+
readonly ILoggerFactory? loggerFactory;
120+
IHost? inMemorySidecarHost;
121+
122+
public InMemoryGrpcSidecarHost(
123+
string address,
124+
InMemoryOrchestrationService orchestrationService,
125+
ILoggerFactory? loggerFactory)
126+
{
127+
this.address = address;
128+
this.orchestrationService = orchestrationService;
129+
this.loggerFactory = loggerFactory;
130+
}
131+
132+
public async Task StartAsync(CancellationToken cancellationToken)
133+
{
134+
// Build and start the gRPC sidecar
135+
this.inMemorySidecarHost = Host.CreateDefaultBuilder()
136+
.ConfigureLogging(logging =>
137+
{
138+
logging.ClearProviders();
139+
if (this.loggerFactory != null)
140+
{
141+
logging.Services.AddSingleton(this.loggerFactory);
142+
}
143+
})
144+
.ConfigureWebHostDefaults(webBuilder =>
145+
{
146+
webBuilder.UseUrls(this.address);
147+
webBuilder.ConfigureKestrel(kestrelOptions =>
148+
{
149+
kestrelOptions.ConfigureEndpointDefaults(listenOptions =>
150+
listenOptions.Protocols = HttpProtocols.Http2);
151+
});
152+
153+
webBuilder.ConfigureServices(services =>
154+
{
155+
services.AddGrpc();
156+
// Use the SAME orchestration service instance
157+
services.AddSingleton<IOrchestrationService>(this.orchestrationService);
158+
services.AddSingleton<IOrchestrationServiceClient>(this.orchestrationService);
159+
services.AddSingleton<TaskHubGrpcServer>();
160+
});
161+
162+
webBuilder.Configure(app =>
163+
{
164+
app.UseRouting();
165+
app.UseEndpoints(endpoints =>
166+
{
167+
endpoints.MapGrpcService<TaskHubGrpcServer>();
168+
});
169+
});
170+
})
171+
.Build();
172+
173+
await this.inMemorySidecarHost.StartAsync(cancellationToken);
174+
}
175+
176+
public async Task StopAsync(CancellationToken cancellationToken)
177+
{
178+
if (this.inMemorySidecarHost != null)
179+
{
180+
await this.inMemorySidecarHost.StopAsync(cancellationToken);
181+
}
182+
}
183+
184+
public async ValueTask DisposeAsync()
185+
{
186+
if (this.inMemorySidecarHost != null)
187+
{
188+
await this.inMemorySidecarHost.StopAsync();
189+
this.inMemorySidecarHost.Dispose();
190+
}
191+
}
192+
}

src/InProcessTestHost/DurableTaskTestHost.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ public DurableTaskTestHost(IHost sidecarHost, IHost workerHost, GrpcChannel grpc
4646
/// </summary>
4747
public DurableTaskClient Client { get; }
4848

49+
/// <summary>
50+
/// Gets the service provider from the worker host.
51+
/// Use this to resolve services registered via <see cref="DurableTaskTestHostOptions.ConfigureServices"/>.
52+
/// </summary>
53+
public IServiceProvider Services => this.workerHost.Services;
54+
4955
/// <summary>
5056
/// Starts a new in-process test host with the specified orchestrators and activities.
5157
/// </summary>
@@ -113,6 +119,10 @@ public static async Task<DurableTaskTestHost> StartAsync(
113119
})
114120
.ConfigureServices(services =>
115121
{
122+
// Allow user to register their own services FIRST
123+
// This ensures their services are available when activities are resolved
124+
options.ConfigureServices?.Invoke(services);
125+
116126
// Register worker that connects to our in-process sidecar
117127
services.AddDurableTaskWorker(builder =>
118128
{
@@ -170,4 +180,10 @@ public class DurableTaskTestHostOptions
170180
/// Null by default.
171181
/// </summary>
172182
public ILoggerFactory? LoggerFactory { get; set; }
183+
184+
/// <summary>
185+
/// Gets or sets an optional callback to configure additional services in the worker host's DI container.
186+
/// Use this to register services that your activities and orchestrators depend on.
187+
/// </summary>
188+
public Action<IServiceCollection>? ConfigureServices { get; set; }
173189
}

src/InProcessTestHost/InProcessTestHost.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<RootNamespace>Microsoft.DurableTask.Testing</RootNamespace>
66
<AssemblyName>Microsoft.DurableTask.InProcessTestHost</AssemblyName>
77
<PackageId>Microsoft.DurableTask.InProcessTestHost</PackageId>
8-
<Version>0.1.0-preview.1</Version>
8+
<Version>0.2.0-preview.1</Version>
99

1010
<!-- Suppress CA1848: Use LoggerMessage delegates for high-performance logging scenarios -->
1111
<NoWarn>$(NoWarn);CA1848</NoWarn>

src/InProcessTestHost/README.md

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@ Supports both **class-based** and **function-based** syntax.
66

77
## Quick Start
88

9-
1. Configure options
9+
### 1. Configure options (optional)
10+
1011
```csharp
1112
var options = new DurableTaskTestHostOptions
1213
{
1314
Port = 31000, // Optional: specific port (random by default)
1415
LoggerFactory = myLoggerFactory // Optional: pass logger factory for logging
1516
};
16-
1717
```
1818

19-
2. Register test orchestrations and activities.
19+
### 2. Register test orchestrations and activities
2020

2121
```csharp
2222
await using var testHost = await DurableTaskTestHost.StartAsync(registry =>
@@ -29,15 +29,121 @@ await using var testHost = await DurableTaskTestHost.StartAsync(registry =>
2929
registry.AddOrchestratorFunc("MyFunc", (ctx, input) => Task.FromResult("done"));
3030
registry.AddActivityFunc("MyActivity", (ctx, input) => Task.FromResult("result"));
3131
});
32-
3332
```
3433

35-
3. Test
34+
### 3. Test
35+
3636
```csharp
3737
string instanceId = await testHost.Client.ScheduleNewOrchestrationInstanceAsync("MyOrchestrator");
3838
var result = await testHost.Client.WaitForInstanceCompletionAsync(instanceId);
3939
```
40-
.
40+
41+
## Dependency Injection
42+
43+
When your activities depend on services, there are two approaches:
44+
45+
| Approach | When to Use |
46+
|----------|-------------|
47+
| **Option 1: ConfigureServices** | Simple tests where you register a few services directly |
48+
| **Option 2: AddInMemoryDurableTask** | When you have an existing host (e.g., `WebApplicationFactory`) with complex DI setup |
49+
50+
### Option 1: ConfigureServices
51+
52+
Use this when you want the test host to manage everything. Register services directly in the test host options.
53+
54+
```csharp
55+
await using var host = await DurableTaskTestHost.StartAsync(
56+
tasks =>
57+
{
58+
tasks.AddOrchestrator<MyOrchestrator>();
59+
tasks.AddActivity<MyActivity>();
60+
},
61+
new DurableTaskTestHostOptions
62+
{
63+
ConfigureServices = services =>
64+
{
65+
// Register services required by your orchestrator or activity function
66+
services.AddSingleton<IMyService, MyService>();
67+
services.AddSingleton<IUserRepository, InMemoryUserRepository>();
68+
services.AddLogging();
69+
}
70+
});
71+
72+
var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(nameof(MyOrchestrator), "input");
73+
var result = await host.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true);
74+
```
75+
76+
Access registered services via `host.Services`:
77+
78+
```csharp
79+
var myService = host.Services.GetRequiredService<IMyService>();
80+
```
81+
82+
### Option 2: AddInMemoryDurableTask
83+
84+
Use this when you already have a host with complex DI setup (database, auth, external APIs, etc.) and want to add durable task testing to it.
85+
86+
```csharp
87+
public class MyIntegrationTests : IAsyncLifetime
88+
{
89+
IHost host = null!;
90+
DurableTaskClient client = null!;
91+
92+
public async Task InitializeAsync()
93+
{
94+
this.host = Host.CreateDefaultBuilder()
95+
.ConfigureServices(services =>
96+
{
97+
// Your existing services (from Program.cs, Startup.cs, etc.)
98+
services.AddSingleton<IUserRepository, InMemoryUserRepository>();
99+
services.AddScoped<IOrderService, OrderService>();
100+
services.AddDbContext<MyDbContext>();
101+
102+
// Add in-memory durable task support
103+
services.AddInMemoryDurableTask(tasks =>
104+
{
105+
tasks.AddOrchestrator<MyOrchestrator>();
106+
tasks.AddActivity<MyActivity>();
107+
});
108+
})
109+
.Build();
110+
111+
await this.host.StartAsync();
112+
this.client = this.host.Services.GetRequiredService<DurableTaskClient>();
113+
}
114+
}
115+
```
116+
117+
Access the in-memory orchestration service:
118+
119+
```csharp
120+
var orchestrationService = host.Services.GetInMemoryOrchestrationService();
121+
```
122+
123+
## API Reference
124+
125+
### DurableTaskTestHostOptions
126+
127+
| Property | Type | Description |
128+
|----------|------|-------------|
129+
| `Port` | `int?` | Specific port for gRPC sidecar. Random 30000-40000 if not set. |
130+
| `LoggerFactory` | `ILoggerFactory?` | Logger factory for capturing logs during tests. |
131+
| `ConfigureServices` | `Action<IServiceCollection>?` | Callback to register services for DI. |
132+
133+
### DurableTaskTestHost
134+
135+
| Property | Type | Description |
136+
|----------|------|-------------|
137+
| `Client` | `DurableTaskClient` | Client for scheduling and managing orchestrations. |
138+
| `Services` | `IServiceProvider` | Service provider with registered services. |
139+
140+
### Extension Methods
141+
142+
| Method | Description |
143+
|--------|-------------|
144+
| `services.AddInMemoryDurableTask(configureTasks)` | Adds in-memory durable task support to an existing `IServiceCollection`. |
145+
| `services.GetInMemoryOrchestrationService()` | Gets the `InMemoryOrchestrationService` from the service provider. |
146+
41147
## More Samples
42148

43-
See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs) for complete samples showing both class-syntax and function-syntax orchestrations.
149+
See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs), [DependencyInjectionTests.cs](../../test/InProcessTestHost.Tests/DependencyInjectionTests.cs), and [WebApplicationFactoryIntegrationTests.cs](../../test/InProcessTestHost.Tests/WebApplicationFactoryIntegrationTests.cs) for complete samples.

0 commit comments

Comments
 (0)