Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version bump from 4.12.0 to 4.14.0 for Microsoft.CodeAnalysis.Common appears unrelated to the dependency injection feature being added. This change should either be explained in the PR description or moved to a separate PR to maintain clear change boundaries.

Suggested change
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />

Copilot uses AI. Check for mistakes.
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.2" />
Expand Down
191 changes: 191 additions & 0 deletions src/InProcessTestHost/DurableTaskTestExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using DurableTask.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Grpc;
using Microsoft.DurableTask.Testing.Sidecar;
using Microsoft.DurableTask.Testing.Sidecar.Grpc;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.Grpc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Microsoft.DurableTask.Testing;

/// <summary>
/// Extension methods for integrating in-memory durable task testing with your existing DI container,
/// such as WebApplicationFactory.
/// </summary>
public static class DurableTaskTestExtensions
{
/// <summary>
/// These extensions allow you to inject the <see cref="InMemoryOrchestrationService"/> into your
/// existing test host so that your orchestrations and activities can resolve services from your DI container.
/// </summary>
/// <param name="services">The service collection (from your WebApplicationFactory or host).</param>
/// <param name="configureTasks">Action to register orchestrators and activities.</param>
/// <param name="options">Optional configuration options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInMemoryDurableTask(
this IServiceCollection services,
Action<DurableTaskRegistry> configureTasks,
InMemoryDurableTaskOptions? options = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureTasks);

options ??= new InMemoryDurableTaskOptions();

// Determine port for the internal gRPC server
int port = options.Port ?? Random.Shared.Next(30000, 40000);
string address = $"http://localhost:{port}";

// Register the in-memory orchestration service as a singleton
services.AddSingleton<InMemoryOrchestrationService>(sp =>
{
var loggerFactory = sp.GetService<ILoggerFactory>();
return new InMemoryOrchestrationService(loggerFactory);
});
services.AddSingleton<IOrchestrationService>(sp => sp.GetRequiredService<InMemoryOrchestrationService>());
services.AddSingleton<IOrchestrationServiceClient>(sp => sp.GetRequiredService<InMemoryOrchestrationService>());

// Register the gRPC sidecar server as a hosted service
services.AddSingleton<TaskHubGrpcServer>();
services.AddHostedService<InMemoryGrpcSidecarHost>(sp =>
{
return new InMemoryGrpcSidecarHost(
address,
sp.GetRequiredService<InMemoryOrchestrationService>(),
sp.GetService<ILoggerFactory>());
});

// Create a gRPC channel that will connect to our internal sidecar
services.AddSingleton<GrpcChannel>(sp => GrpcChannel.ForAddress(address));

// Register the durable task worker (connects to our internal sidecar)
services.AddDurableTaskWorker(builder =>
{
builder.UseGrpc(address);
builder.AddTasks(configureTasks);
});

// Register the durable task client (connects to our internal sidecar)
services.AddDurableTaskClient(builder =>
{
builder.UseGrpc(address);
builder.RegisterDirectly();
});

return services;
}

/// <summary>
/// Gets the <see cref="InMemoryOrchestrationService"/> from the service provider.
/// Useful for advanced scenarios like inspecting orchestration state.
/// </summary>
/// <param name="services">The service provider.</param>
/// <returns>The in-memory orchestration service instance.</returns>
public static InMemoryOrchestrationService GetInMemoryOrchestrationService(this IServiceProvider services)
{
return services.GetRequiredService<InMemoryOrchestrationService>();
}
}

/// <summary>
/// Options for configuring in-memory durable task support.
/// </summary>
public class InMemoryDurableTaskOptions
Comment on lines +100 to +103
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to coding guidelines, all private classes that do not serve as base classes should be sealed. InMemoryDurableTaskOptions does not serve as a base class and should be marked as sealed for better performance and clarity.

Copilot uses AI. Check for mistakes.
{
/// <summary>
/// Gets or sets the port for the internal gRPC server.
/// If not set, a random port between 30000-40000 will be used.
/// </summary>
public int? Port { get; set; }
}

/// <summary>
/// Internal hosted service that runs the gRPC sidecar within the user's host.
/// </summary>
internal sealed class InMemoryGrpcSidecarHost : IHostedService, IAsyncDisposable
{
private readonly string address;
private readonly InMemoryOrchestrationService orchestrationService;
private readonly ILoggerFactory? loggerFactory;
private IHost? inMemorySidecarHost;

public InMemoryGrpcSidecarHost(
string address,
InMemoryOrchestrationService orchestrationService,
ILoggerFactory? loggerFactory)
{
this.address = address;
this.orchestrationService = orchestrationService;
this.loggerFactory = loggerFactory;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
// Build and start the gRPC sidecar
this.inMemorySidecarHost = Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
logging.ClearProviders();
if (this.loggerFactory != null)
{
logging.Services.AddSingleton(this.loggerFactory);
}
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseUrls(this.address);
webBuilder.ConfigureKestrel(kestrelOptions =>
{
kestrelOptions.ConfigureEndpointDefaults(listenOptions =>
listenOptions.Protocols = HttpProtocols.Http2);
});

webBuilder.ConfigureServices(services =>
{
services.AddGrpc();
// Use the SAME orchestration service instance
services.AddSingleton<IOrchestrationService>(this.orchestrationService);
services.AddSingleton<IOrchestrationServiceClient>(this.orchestrationService);
services.AddSingleton<TaskHubGrpcServer>();
});

webBuilder.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<TaskHubGrpcServer>();
});
});
})
.Build();

await this.inMemorySidecarHost.StartAsync(cancellationToken);
}

public async Task StopAsync(CancellationToken cancellationToken)
{
if (this.inMemorySidecarHost != null)
{
await this.inMemorySidecarHost.StopAsync(cancellationToken);
}
}

public async ValueTask DisposeAsync()
{
if (this.inMemorySidecarHost != null)
{
this.inMemorySidecarHost.Dispose();
}
}
}
16 changes: 16 additions & 0 deletions src/InProcessTestHost/DurableTaskTestHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public DurableTaskTestHost(IHost sidecarHost, IHost workerHost, GrpcChannel grpc
/// </summary>
public DurableTaskClient Client { get; }

/// <summary>
/// Gets the service provider from the worker host.
/// Use this to resolve services registered via <see cref="DurableTaskTestHostOptions.ConfigureServices"/>.
/// </summary>
public IServiceProvider Services => this.workerHost.Services;

/// <summary>
/// Starts a new in-process test host with the specified orchestrators and activities.
/// </summary>
Expand Down Expand Up @@ -113,6 +119,10 @@ public static async Task<DurableTaskTestHost> StartAsync(
})
.ConfigureServices(services =>
{
// Allow user to register their own services FIRST
// This ensures their services are available when activities are resolved
options.ConfigureServices?.Invoke(services);

// Register worker that connects to our in-process sidecar
services.AddDurableTaskWorker(builder =>
{
Expand Down Expand Up @@ -170,4 +180,10 @@ public class DurableTaskTestHostOptions
/// Null by default.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }

/// <summary>
/// Gets or sets an optional callback to configure additional services in the worker host's DI container.
/// Use this to register services that your activities and orchestrators depend on.
/// </summary>
public Action<IServiceCollection>? ConfigureServices { get; set; }
}
2 changes: 1 addition & 1 deletion src/InProcessTestHost/InProcessTestHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<RootNamespace>Microsoft.DurableTask.Testing</RootNamespace>
<AssemblyName>Microsoft.DurableTask.InProcessTestHost</AssemblyName>
<PackageId>Microsoft.DurableTask.InProcessTestHost</PackageId>
<Version>0.1.0-preview.1</Version>
<Version>0.2.0-preview.1</Version>

<!-- Suppress CA1848: Use LoggerMessage delegates for high-performance logging scenarios -->
<NoWarn>$(NoWarn);CA1848</NoWarn>
Expand Down
120 changes: 113 additions & 7 deletions src/InProcessTestHost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ Supports both **class-based** and **function-based** syntax.

## Quick Start

1. Configure options
### 1. Configure options (optional)

```csharp
var options = new DurableTaskTestHostOptions
{
Port = 31000, // Optional: specific port (random by default)
LoggerFactory = myLoggerFactory // Optional: pass logger factory for logging
};

```

2. Register test orchestrations and activities.
### 2. Register test orchestrations and activities

```csharp
await using var testHost = await DurableTaskTestHost.StartAsync(registry =>
Expand All @@ -29,15 +29,121 @@ await using var testHost = await DurableTaskTestHost.StartAsync(registry =>
registry.AddOrchestratorFunc("MyFunc", (ctx, input) => Task.FromResult("done"));
registry.AddActivityFunc("MyActivity", (ctx, input) => Task.FromResult("result"));
});

```

3. Test
### 3. Test

```csharp
string instanceId = await testHost.Client.ScheduleNewOrchestrationInstanceAsync("MyOrchestrator");
var result = await testHost.Client.WaitForInstanceCompletionAsync(instanceId);
```
.

## Dependency Injection

When your activities depend on services, there are two approaches:

| Approach | When to Use |
|----------|-------------|
| **Option 1: ConfigureServices** | Simple tests where you register a few services directly |
| **Option 2: AddInMemoryDurableTask** | When you have an existing host (e.g., `WebApplicationFactory`) with complex DI setup |

### Option 1: ConfigureServices

Use this when you want the test host to manage everything. Register services directly in the test host options.

```csharp
await using var host = await DurableTaskTestHost.StartAsync(
tasks =>
{
tasks.AddOrchestrator<MyOrchestrator>();
tasks.AddActivity<MyActivity>();
},
new DurableTaskTestHostOptions
{
ConfigureServices = services =>
{
// Register services required by your orchestrator or activity function
services.AddSingleton<IMyService, MyService>();
services.AddSingleton<IUserRepository, InMemoryUserRepository>();
services.AddLogging();
}
});

var instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(nameof(MyOrchestrator), "input");
var result = await host.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true);
```

Access registered services via `host.Services`:

```csharp
var myService = host.Services.GetRequiredService<IMyService>();
```

### Option 2: AddInMemoryDurableTask

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.

```csharp
public class MyIntegrationTests : IAsyncLifetime
{
IHost host = null!;
DurableTaskClient client = null!;

public async Task InitializeAsync()
{
this.host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
// Your existing services (from Program.cs, Startup.cs, etc.)
services.AddSingleton<IUserRepository, InMemoryUserRepository>();
services.AddScoped<IOrderService, OrderService>();
services.AddDbContext<MyDbContext>();

// Add in-memory durable task support
services.AddInMemoryDurableTask(tasks =>
{
tasks.AddOrchestrator<MyOrchestrator>();
tasks.AddActivity<MyActivity>();
});
})
.Build();

await this.host.StartAsync();
this.client = this.host.Services.GetRequiredService<DurableTaskClient>();
}
}
```

Access the in-memory orchestration service:

```csharp
var orchestrationService = host.Services.GetInMemoryOrchestrationService();
```

## API Reference

### DurableTaskTestHostOptions

| Property | Type | Description |
|----------|------|-------------|
| `Port` | `int?` | Specific port for gRPC sidecar. Random 30000-40000 if not set. |
| `LoggerFactory` | `ILoggerFactory?` | Logger factory for capturing logs during tests. |
| `ConfigureServices` | `Action<IServiceCollection>?` | Callback to register services for DI. |

### DurableTaskTestHost

| Property | Type | Description |
|----------|------|-------------|
| `Client` | `DurableTaskClient` | Client for scheduling and managing orchestrations. |
| `Services` | `IServiceProvider` | Service provider with registered services. |

### Extension Methods

| Method | Description |
|--------|-------------|
| `services.AddInMemoryDurableTask(configureTasks)` | Adds in-memory durable task support to an existing `IServiceCollection`. |
| `services.GetInMemoryOrchestrationService()` | Gets the `InMemoryOrchestrationService` from the service provider. |

## More Samples

See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs) for complete samples showing both class-syntax and function-syntax orchestrations.
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.
Loading
Loading