|
| 1 | +--- |
| 2 | +title: Unit Testing Durable Functions in .NET Isolated |
| 3 | +description: Learn about unit testing best practices for Durable functions written in .NET Isolated for Azure Functions. |
| 4 | +author: nytian |
| 5 | +ms.topic: conceptual |
| 6 | +ms.date: 05/19/2025 |
| 7 | +ms.author: azfuncdf |
| 8 | +--- |
| 9 | + |
| 10 | +# Durable Functions unit testing (C# Isolated) |
| 11 | + |
| 12 | +Unit testing is an important part of modern software development practices. Unit tests verify business logic behavior and protect from introducing unnoticed breaking changes in the future. Durable Functions can easily grow in complexity so introducing unit tests helps avoid breaking changes. The following sections explain how to unit test the three function types - Orchestration client, orchestrator, and activity functions. |
| 13 | + |
| 14 | +> [!NOTE] |
| 15 | +> - This article provides guidance for unit testing for Durable Functions apps written in C# for the .NET isolated worker. For more information about Durable Functions in the .NET isolated worker, see the [Durable Functions in the .NET isolated worker](durable-functions-dotnet-isolated-overview.md) article. |
| 16 | +> - **The complete sample code for this unit testing guide can be found in [the sample code repository](https://github.com/Azure/azure-functions-durable-extension/tree/dev/samples/isolated-unit-tests)**. |
| 17 | +> - For Durable Functions using C# in-process, refer to [unit testing guide](durable-functions-unit-testing.md). |
| 18 | +
|
| 19 | +## Prerequisites |
| 20 | + |
| 21 | +The examples in this article require knowledge of the following concepts and frameworks: |
| 22 | + |
| 23 | +* Unit testing |
| 24 | +* Durable Functions |
| 25 | +* [xUnit](https://github.com/xunit/xunit) - Testing framework |
| 26 | +* [moq](https://github.com/moq/moq4) - Mocking framework |
| 27 | + |
| 28 | +## Base classes for mocking |
| 29 | + |
| 30 | +Mocking is supported via the following interfaces and classes: |
| 31 | + |
| 32 | +* `DurableTaskClient` - For orchestrator client operations |
| 33 | +* `TaskOrchestrationContext` - For orchestrator function execution |
| 34 | +* `FunctionContext` - For function execution context |
| 35 | +* `HttpRequestData` and `HttpResponseData` - For HTTP trigger functions |
| 36 | + |
| 37 | +These classes can be used with the various trigger and bindings supported by Durable Functions. While it's executing your Azure Functions, the functions runtime runs your function code with concrete implementations of these classes. For unit testing, you can pass in a mocked version of these classes to test your business logic. |
| 38 | + |
| 39 | +## Unit testing trigger functions |
| 40 | + |
| 41 | +In this section, the unit test validates the logic of the following HTTP trigger function for starting new orchestrations. |
| 42 | + |
| 43 | +```csharp |
| 44 | +[Function("HelloCitiesOrchestration_HttpStart")] |
| 45 | +public static async Task<HttpResponseData> HttpStart( |
| 46 | + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, |
| 47 | + [DurableClient] DurableTaskClient client, |
| 48 | + FunctionContext executionContext) |
| 49 | +{ |
| 50 | + // Function input comes from the request content. |
| 51 | + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( |
| 52 | + nameof(HelloCitiesOrchestration)); |
| 53 | + |
| 54 | + // Returns an HTTP 202 response with an instance management payload. |
| 55 | + return await client.CreateCheckStatusResponseAsync(req, instanceId); |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +The unit test verifies the HTTP response status code and the instance ID in the response payload. The test mocks the `DurableTaskClient` to ensure predictable behavior. |
| 60 | + |
| 61 | +First, we use a mocking framework (Moq in this case) to mock `DurableTaskClient` and FunctionContext: |
| 62 | + |
| 63 | +```csharp |
| 64 | +// Mock DurableTaskClient |
| 65 | +var durableClientMock = new Mock<DurableTaskClient>("testClient"); |
| 66 | +var functionContextMock = new Mock<FunctionContext>(); |
| 67 | +``` |
| 68 | + |
| 69 | +Then `ScheduleNewOrchestrationInstanceAsync` method is mocked to return an instance ID: |
| 70 | + |
| 71 | +```csharp |
| 72 | +var instanceId = Guid.NewGuid().ToString(); |
| 73 | + |
| 74 | +// Mock ScheduleNewOrchestrationInstanceAsync method |
| 75 | +durableClientMock |
| 76 | + .Setup(x => x.ScheduleNewOrchestrationInstanceAsync( |
| 77 | + It.IsAny<TaskName>(), |
| 78 | + It.IsAny<object>(), |
| 79 | + It.IsAny<StartOrchestrationOptions>(), |
| 80 | + It.IsAny<CancellationToken>())) |
| 81 | + .ReturnsAsync(instanceId); |
| 82 | +``` |
| 83 | + |
| 84 | +Next, we need to mock the HTTP request and response data: |
| 85 | + |
| 86 | +```csharp |
| 87 | +// Mock HttpRequestData that sent to the http trigger |
| 88 | +var mockRequest = MockHttpRequestAndResponseData(); |
| 89 | + |
| 90 | +var responseMock = new Mock<HttpResponseData>(functionContextMock.Object); |
| 91 | +responseMock.SetupGet(r => r.StatusCode).Returns(HttpStatusCode.Accepted); |
| 92 | +``` |
| 93 | + |
| 94 | +Here's the complete helper method for mocking HttpRequestData: |
| 95 | + |
| 96 | +```csharp |
| 97 | +private HttpRequestData MockHttpRequestAndResponseData(HttpHeadersCollection? headers = null) |
| 98 | +{ |
| 99 | + var mockObjectSerializer = new Mock<ObjectSerializer>(); |
| 100 | + |
| 101 | + // Setup the SerializeAsync method |
| 102 | + mockObjectSerializer.Setup(s => s.SerializeAsync(It.IsAny<Stream>(), It.IsAny<object?>(), It.IsAny<Type>(), It.IsAny<CancellationToken>())) |
| 103 | + .Returns<Stream, object?, Type, CancellationToken>(async (stream, value, type, token) => |
| 104 | + { |
| 105 | + await System.Text.Json.JsonSerializer.SerializeAsync(stream, value, type, cancellationToken: token); |
| 106 | + }); |
| 107 | + |
| 108 | + var workerOptions = new WorkerOptions |
| 109 | + { |
| 110 | + Serializer = mockObjectSerializer.Object |
| 111 | + }; |
| 112 | + var mockOptions = new Mock<IOptions<WorkerOptions>>(); |
| 113 | + mockOptions.Setup(o => o.Value).Returns(workerOptions); |
| 114 | + |
| 115 | + // Mock the service provider |
| 116 | + var mockServiceProvider = new Mock<IServiceProvider>(); |
| 117 | + |
| 118 | + // Set up the service provider to return the mock IOptions<WorkerOptions> |
| 119 | + mockServiceProvider.Setup(sp => sp.GetService(typeof(IOptions<WorkerOptions>))) |
| 120 | + .Returns(mockOptions.Object); |
| 121 | + |
| 122 | + // Set up the service provider to return the mock ObjectSerializer |
| 123 | + mockServiceProvider.Setup(sp => sp.GetService(typeof(ObjectSerializer))) |
| 124 | + .Returns(mockObjectSerializer.Object); |
| 125 | + |
| 126 | + // Create a mock FunctionContext and assign the service provider |
| 127 | + var mockFunctionContext = new Mock<FunctionContext>(); |
| 128 | + mockFunctionContext.SetupGet(c => c.InstanceServices).Returns(mockServiceProvider.Object); |
| 129 | + var mockHttpRequestData = new Mock<HttpRequestData>(mockFunctionContext.Object); |
| 130 | + |
| 131 | + // Set up the URL property |
| 132 | + mockHttpRequestData.SetupGet(r => r.Url).Returns(new Uri("https://localhost:7075/orchestrators/HelloCities")); |
| 133 | + |
| 134 | + // If headers are provided, use them, otherwise create a new empty HttpHeadersCollection |
| 135 | + headers ??= new HttpHeadersCollection(); |
| 136 | + |
| 137 | + // Setup the Headers property to return the empty headers |
| 138 | + mockHttpRequestData.SetupGet(r => r.Headers).Returns(headers); |
| 139 | + |
| 140 | + var mockHttpResponseData = new Mock<HttpResponseData>(mockFunctionContext.Object) |
| 141 | + { |
| 142 | + DefaultValue = DefaultValue.Mock |
| 143 | + }; |
| 144 | + |
| 145 | + // Enable setting StatusCode and Body as mutable properties |
| 146 | + mockHttpResponseData.SetupProperty(r => r.StatusCode, HttpStatusCode.OK); |
| 147 | + mockHttpResponseData.SetupProperty(r => r.Body, new MemoryStream()); |
| 148 | + |
| 149 | + // Setup CreateResponse to return the configured HttpResponseData mock |
| 150 | + mockHttpRequestData.Setup(r => r.CreateResponse()) |
| 151 | + .Returns(mockHttpResponseData.Object); |
| 152 | + |
| 153 | + return mockHttpRequestData.Object; |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +Finally, we call the function and verify the results: |
| 158 | + |
| 159 | +```csharp |
| 160 | +var result = await HelloCitiesOrchestration.HttpStart(mockRequest, durableClientMock.Object, functionContextMock.Object); |
| 161 | + |
| 162 | +// Verify the status code |
| 163 | +Assert.Equal(HttpStatusCode.Accepted, result.StatusCode); |
| 164 | + |
| 165 | +// Reset stream position for reading |
| 166 | +result.Body.Position = 0; |
| 167 | +var serializedResponseBody = await System.Text.Json.JsonSerializer.DeserializeAsync<dynamic>(result.Body); |
| 168 | + |
| 169 | +// Verify the response returned contains the right data |
| 170 | +Assert.Equal(instanceId, serializedResponseBody!.GetProperty("Id").GetString()); |
| 171 | +``` |
| 172 | + |
| 173 | +> [!NOTE] |
| 174 | +> Currently, loggers created via FunctionContext in trigger functions aren't supported for mocking in unit tests. |
| 175 | +
|
| 176 | + |
| 177 | +## Unit testing orchestrator functions |
| 178 | + |
| 179 | +Orchestrator functions manage the execution of multiple activity functions. To test an orchestrator: |
| 180 | + |
| 181 | +* Mock the `TaskOrchestrationContext` to control function execution |
| 182 | +* Replace `TaskOrchestrationContext` methods needed for orchestrator execution like `CallActivityAsync` with mock functions |
| 183 | +* Call the orchestrator directly with the mocked context |
| 184 | +* Verify the orchestrator results using assertions |
| 185 | + |
| 186 | +In this section, the unit test validates the behavior of the `HelloCities` orchestrator function: |
| 187 | + |
| 188 | +```csharp |
| 189 | +[Function(nameof(HelloCitiesOrchestration))] |
| 190 | +public static async Task<List<string>> HelloCities( |
| 191 | + [OrchestrationTrigger] TaskOrchestrationContext context) |
| 192 | +{ |
| 193 | + ILogger logger = context.CreateReplaySafeLogger(nameof(HelloCities)); |
| 194 | + logger.LogInformation("Saying hello."); |
| 195 | + var outputs = new List<string>(); |
| 196 | + |
| 197 | + outputs.Add(await context.CallActivityAsync<string>(nameof(SayHello), "Tokyo")); |
| 198 | + outputs.Add(await context.CallActivityAsync<string>(nameof(SayHello), "Seattle")); |
| 199 | + outputs.Add(await context.CallActivityAsync<string>(nameof(SayHello), "London")); |
| 200 | + |
| 201 | + return outputs; |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +The unit test verifies that the orchestrator calls the correct activity functions with the expected parameters and returns the expected results. The test mocks the `TaskOrchestrationContext` to ensure predictable behavior. |
| 206 | + |
| 207 | +First, we use a mocking framework (Moq in this case) to mock `TaskOrchestrationContext`: |
| 208 | + |
| 209 | +```csharp |
| 210 | +// Mock TaskOrchestrationContext and setup logger |
| 211 | +var contextMock = new Mock<TaskOrchestrationContext>(); |
| 212 | +``` |
| 213 | + |
| 214 | +Then we mock the `CreateReplaySafeLogger` method to return our test logger: |
| 215 | + |
| 216 | +```csharp |
| 217 | +testLogger = new Mock<ILogger>(); |
| 218 | +contextMock.Setup(x => x.CreateReplaySafeLogger(It.IsAny<string>())) |
| 219 | + .Returns(testLogger.Object); |
| 220 | +``` |
| 221 | + |
| 222 | +Next, we mock the activity function calls with specific return values for each city: |
| 223 | + |
| 224 | +```csharp |
| 225 | +// Mock the activity function calls |
| 226 | +contextMock.Setup(x => x.CallActivityAsync<string>( |
| 227 | + It.Is<TaskName>(n => n.Name == nameof(HelloCitiesOrchestration.SayHello)), |
| 228 | + It.Is<string>(n => n == "Tokyo"), |
| 229 | + It.IsAny<TaskOptions>())) |
| 230 | + .ReturnsAsync("Hello Tokyo!"); |
| 231 | +contextMock.Setup(x => x.CallActivityAsync<string>( |
| 232 | + It.Is<TaskName>(n => n.Name == nameof(HelloCitiesOrchestration.SayHello)), |
| 233 | + It.Is<string>(n => n == "Seattle"), |
| 234 | + It.IsAny<TaskOptions>())) |
| 235 | + .ReturnsAsync("Hello Seattle!"); |
| 236 | +contextMock.Setup(x => x.CallActivityAsync<string>( |
| 237 | + It.Is<TaskName>(n => n.Name == nameof(HelloCitiesOrchestration.SayHello)), |
| 238 | + It.Is<string>(n => n == "London"), |
| 239 | + It.IsAny<TaskOptions>())) |
| 240 | + .ReturnsAsync("Hello London!"); |
| 241 | +``` |
| 242 | + |
| 243 | +Then we call the orchestrator function with the mocked context: |
| 244 | + |
| 245 | +```csharp |
| 246 | +var result = await HelloCitiesOrchestration.HelloCities(contextMock.Object); |
| 247 | +``` |
| 248 | + |
| 249 | +Finally, we verify the orchestration result and logging behavior: |
| 250 | + |
| 251 | +```csharp |
| 252 | +// Verify the orchestration result |
| 253 | +Assert.Equal(3, result.Count); |
| 254 | +Assert.Equal("Hello Tokyo!", result[0]); |
| 255 | +Assert.Equal("Hello Seattle!", result[1]); |
| 256 | +Assert.Equal("Hello London!", result[2]); |
| 257 | + |
| 258 | +// Verify logging |
| 259 | +testLogger.Verify( |
| 260 | + x => x.Log( |
| 261 | + It.Is<LogLevel>(l => l == LogLevel.Information), |
| 262 | + It.IsAny<EventId>(), |
| 263 | + It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Saying hello")), |
| 264 | + It.IsAny<Exception>(), |
| 265 | + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), |
| 266 | + Times.Once); |
| 267 | +``` |
| 268 | + |
| 269 | +## Unit testing activity functions |
| 270 | + |
| 271 | +Activity functions require no Durable-specific modifications to be tested. The guidance found in the Azure Functions unit testing overview is sufficient for testing these functions. |
| 272 | + |
| 273 | +In this section, the unit test validates the behavior of the `SayHello` Activity function: |
| 274 | + |
| 275 | +```csharp |
| 276 | +[Function(nameof(SayHello))] |
| 277 | +public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) |
| 278 | +{ |
| 279 | + return $"Hello {name}!"; |
| 280 | +} |
| 281 | +``` |
| 282 | + |
| 283 | +The unit test verifies the format of the output: |
| 284 | + |
| 285 | +```csharp |
| 286 | +[Fact] |
| 287 | +public void SayHello_ReturnsExpectedGreeting() |
| 288 | +{ |
| 289 | + var functionContextMock = new Mock<FunctionContext>(); |
| 290 | + |
| 291 | + const string name = "Tokyo"; |
| 292 | + |
| 293 | + var result = HelloCitiesOrchestration.SayHello(name, functionContextMock.Object); |
| 294 | + |
| 295 | + // Verify the activity function SayHello returns the right result |
| 296 | + Assert.Equal($"Hello {name}!", result); |
| 297 | +} |
| 298 | +``` |
| 299 | + |
| 300 | +> [!NOTE] |
| 301 | +> Currently, loggers created via FunctionContext in activity functions aren't supported for mocking in unit tests. |
| 302 | +
|
| 303 | +## Next steps |
| 304 | + |
| 305 | +* Learn more about [xUnit](https://xunit.net/) |
| 306 | +* Learn more about [Moq](https://github.com/moq/moq4) |
| 307 | +* Learn more about [Azure Functions isolated worker model](../dotnet-isolated-process-guide.md) |
0 commit comments