Durable Functions 单元测试Durable Functions unit testing

单元测试是现代软件开发实践中的重要组成部分。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 的复杂性很容易增大,因此,引入单元测试有助于避免中断性变更。Durable Functions can easily grow in complexity so introducing unit tests will help to avoid breaking changes. 以下部分介绍如何对三种函数类型执行单元测试 - 业务流程客户端、业务流程协调程序和活动函数。The following sections explain how to unit test the three function types - Orchestration client, orchestrator, and activity functions.

备注

本文提供了针对 Durable Functions 2.x 的 Durable Functions 应用的单元测试指南。This article provides guidance for unit testing for Durable Functions apps targeting Durable Functions 2.x. 有关版本之间差异的详细信息,请参阅 Durable Functions 版本一文。For more information about the differences between versions, see the Durable Functions versions article.

先决条件Prerequisites

学习本文中的示例需要了解以下概念和框架:The examples in this article require knowledge of the following concepts and frameworks:

  • 单元测试Unit testing

  • Durable FunctionsDurable Functions

  • xUnit - 测试框架xUnit - Testing framework

  • moq - 模拟框架moq - Mocking framework

用于模拟的基类Base classes for mocking

通过以下接口支持模拟:Mocking is supported via the following interface:

这些接口可以与 Durable Functions 支持的各种触发器和绑定配合使用。These interfaces can be used with the various trigger and bindings supported by Durable Functions. 执行 Azure Functions 时,函数运行时会运行包含这些接口的具体实现的函数代码。When executing your Azure Functions, the functions runtime will run your function code with a concrete implementation of these interfaces. 对于单元测试,可以传递这些接口的模拟版本来测试业务逻辑。For unit testing, you can pass in a mocked version of these interfaces to test your business logic.

对触发器函数进行单元测试Unit testing trigger functions

在此部分,单元测试将会验证用于启动新业务流程的以下 HTTP 触发器函数的逻辑。In this section, the unit test will validate the logic of the following HTTP trigger function for starting new orchestrations.

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;

namespace VSSample
{
    public static class HttpStart
    {
        [FunctionName("HttpStart")]
        public static async Task<HttpResponseMessage> Run(
            [HttpTrigger(AuthorizationLevel.Function, methods: "post", Route = "orchestrators/{functionName}")] HttpRequestMessage req,
            [DurableClient] IDurableClient starter,
            string functionName,
            ILogger log)
        {
            // Function input comes from the request content.
            object eventData = await req.Content.ReadAsAsync<object>();
            string instanceId = await starter.StartNewAsync(functionName, eventData);

            log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

            return starter.CreateCheckStatusResponse(req, instanceId);
        }
    }
}

单元测试任务是验证响应有效负载中提供的 Retry-After 标头的值。The unit test task will be to verify the value of the Retry-After header provided in the response payload. 因此,单元测试将模拟某些 IDurableClient 方法,以确保行为可预测。So the unit test will mock some of IDurableClient methods to ensure predictable behavior.

首先,使用模拟框架(在此示例中为 moq)来模拟 IDurableClientFirst, we use a mocking framework (moq in this case) to mock IDurableClient:

// Mock IDurableClient
var durableClientMock = new Mock<IDurableClient>();

备注

尽管可以通过直接将接口实现为类来模拟接口,但模拟框架从各方面简化了该过程。While you can mock interfaces by directly implementing the interface as a class, mocking frameworks simplify the process in various ways. 例如,如果在多个次要版本中向接口添加了新的方法,则 moq 不需要任何代码更改,这与具体的实现不同。For instance, if a new method is added to the interface across minor releases, moq will not require any code changes unlike concrete implementations.

然后,模拟 StartNewAsync 方法来返回已知的实例 ID。Then StartNewAsync method is mocked to return a well-known instance ID.

// Mock StartNewAsync method
durableClientMock.
    Setup(x => x.StartNewAsync(functionName, It.IsAny<object>())).
    ReturnsAsync(instanceId);

接下来模拟 CreateCheckStatusResponse,以便始终返回空白的 HTTP 200 响应。Next CreateCheckStatusResponse is mocked to always return an empty HTTP 200 response.

// Mock CreateCheckStatusResponse method
durableClientMock
    // Notice that even though the HttpStart function does not call IDurableClient.CreateCheckStatusResponse() 
    // with the optional parameter returnInternalServerErrorOnFailure, moq requires the method to be set up
    // with each of the optional parameters provided. Simply use It.IsAny<> for each optional parameter
    .Setup(x => x.CreateCheckStatusResponse(It.IsAny<HttpRequestMessage>(), instanceId, returnInternalServerErrorOnFailure: It.IsAny<bool>())
    .Returns(new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = new StringContent(string.Empty),
        Headers =
        {
            RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(10))
        }
    });

另外还要模拟 ILoggerILogger is also mocked:

// Mock ILogger
var loggerMock = new Mock<ILogger>();

现在,从单元测试调用 Run 方法:Now the Run method is called from the unit test:

// Call Orchestration trigger function
var result = await HttpStart.Run(
    new HttpRequestMessage()
    {
        Content = new StringContent("{}", Encoding.UTF8, "application/json"),
        RequestUri = new Uri("http://localhost:7071/orchestrators/E1_HelloSequence"),
    },
    durableClientMock.Object,
    functionName,
    loggerMock.Object);

最后一步是将输出与预期值进行比较:The last step is to compare the output with the expected value:

// Validate that output is not null
Assert.NotNull(result.Headers.RetryAfter);

// Validate output's Retry-After header value
Assert.Equal(TimeSpan.FromSeconds(10), result.Headers.RetryAfter.Delta);

合并所有步骤后,单元测试将获得以下代码:After combining all steps, the unit test will have the following code:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace VSSample.Tests
{
    using System;
    using System.Net;
    using System.Net.Http;
    using System.Text;
    using System.Threading.Tasks;
    using System.Net.Http.Headers;
    using Microsoft.Azure.WebJobs.Extensions.DurableTask;
    using Microsoft.Extensions.Logging;
    using Moq;
    using Xunit;

    public class HttpStartTests
    {
        [Fact]
        public async Task HttpStart_returns_retryafter_header()
        {
            // Define constants
            const string functionName = "SampleFunction";
            const string instanceId = "7E467BDB-213F-407A-B86A-1954053D3C24";

            // Mock TraceWriter
            var loggerMock = new Mock<ILogger>();

            // Mock DurableOrchestrationClientBase
            var clientMock = new Mock<IDurableClient>();

            // Mock StartNewAsync method
            clientMock.
                Setup(x => x.StartNewAsync(functionName, It.IsAny<string>(), It.IsAny<object>())).
                ReturnsAsync(instanceId);

            // Mock CreateCheckStatusResponse method
            clientMock
                .Setup(x => x.CreateCheckStatusResponse(It.IsAny<HttpRequestMessage>(), instanceId, false))
                .Returns(new HttpResponseMessage
                {
                    StatusCode = HttpStatusCode.OK,
                    Content = new StringContent(string.Empty),
                    Headers =
                    {
                        RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(10))
                    }
                });

            // Call Orchestration trigger function
            var result = await HttpStart.Run(
                new HttpRequestMessage()
                {
                    Content = new StringContent("{}", Encoding.UTF8, "application/json"),
                    RequestUri = new Uri("http://localhost:7071/orchestrators/E1_HelloSequence"),
                },
                clientMock.Object,
                functionName,
                loggerMock.Object);

            // Validate that output is not null
            Assert.NotNull(result.Headers.RetryAfter);

            // Validate output's Retry-After header value
            Assert.Equal(TimeSpan.FromSeconds(10), result.Headers.RetryAfter.Delta);
        }
    }
}

对业务流程协调程序函数进行单元测试Unit testing orchestrator functions

业务流程协调程序包含的业务逻辑通常要多得多,因此,它们的单元测试更有趣。Orchestrator functions are even more interesting for unit testing since they usually have a lot more business logic.

在本部分,单元测试将验证 E1_HelloSequence 业务流程协调程序函数的输出:In this section the unit tests will validate the output of the E1_HelloSequence Orchestrator function:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;

namespace VSSample
{
    public static class HelloSequence
    {
        [FunctionName("E1_HelloSequence")]
        public static async Task<List<string>> Run(
            [OrchestrationTrigger] IDurableOrchestrationContext context)
        {
            var outputs = new List<string>();

            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello", "Tokyo"));
            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello", "Seattle"));
            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello_DirectInput", "London"));

            // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
            return outputs;
        }

        [FunctionName("E1_SayHello")]
        public static string SayHello([ActivityTrigger] IDurableActivityContext context)
        {
            string name = context.GetInput<string>();
            return $"Hello {name}!";
        }

        [FunctionName("E1_SayHello_DirectInput")]
        public static string SayHelloDirectInput([ActivityTrigger] string name)
        {
            return $"Hello {name}!";
        }
    }
 }

单元测试代码首先会创建模拟:The unit test code will start with creating a mock:

var durableOrchestrationContextMock = new Mock<IDurableOrchestrationContext>();

然后模拟活动方法调用:Then the activity method calls will be mocked:

durableOrchestrationContextMock.Setup(x => x.CallActivityAsync<string>("E1_SayHello", "Tokyo")).ReturnsAsync("Hello Tokyo!");
durableOrchestrationContextMock.Setup(x => x.CallActivityAsync<string>("E1_SayHello", "Seattle")).ReturnsAsync("Hello Seattle!");
durableOrchestrationContextMock.Setup(x => x.CallActivityAsync<string>("E1_SayHello", "London")).ReturnsAsync("Hello London!");

接下来,单元测试调用 HelloSequence.Run 方法:Next the unit test will call HelloSequence.Run method:

var result = await HelloSequence.Run(durableOrchestrationContextMock.Object);

最后验证输出:And finally the output will be validated:

Assert.Equal(3, result.Count);
Assert.Equal("Hello Tokyo!", result[0]);
Assert.Equal("Hello Seattle!", result[1]);
Assert.Equal("Hello London!", result[2]);

合并所有步骤后,单元测试将获得以下代码:After combining all steps, the unit test will have the following code:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace VSSample.Tests
{
    using System.Threading.Tasks;
    using Microsoft.Azure.WebJobs.Extensions.DurableTask;
    using Moq;
    using Xunit;

    public class HelloSequenceTests
    {
        [Fact]
        public async Task Run_returns_multiple_greetings()
        {
            var mockContext = new Mock<IDurableOrchestrationContext>();
            mockContext.Setup(x => x.CallActivityAsync<string>("E1_SayHello", "Tokyo")).ReturnsAsync("Hello Tokyo!");
            mockContext.Setup(x => x.CallActivityAsync<string>("E1_SayHello", "Seattle")).ReturnsAsync("Hello Seattle!");
            mockContext.Setup(x => x.CallActivityAsync<string>("E1_SayHello_DirectInput", "London")).ReturnsAsync("Hello London!");

            var result = await HelloSequence.Run(mockContext.Object);

            Assert.Equal(3, result.Count);
            Assert.Equal("Hello Tokyo!", result[0]);
            Assert.Equal("Hello Seattle!", result[1]);
            Assert.Equal("Hello London!", result[2]);
        }
    }
}

对活动函数进行单元测试Unit testing activity functions

可以像测试非持久性函数一样对活动函数进行单元测试。Activity functions can be unit tested in the same way as non-durable functions.

在本部分,单元测试将验证 E1_SayHello 活动函数的行为:In this section the unit test will validate the behavior of the E1_SayHello Activity function:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;

namespace VSSample
{
    public static class HelloSequence
    {
        [FunctionName("E1_HelloSequence")]
        public static async Task<List<string>> Run(
            [OrchestrationTrigger] IDurableOrchestrationContext context)
        {
            var outputs = new List<string>();

            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello", "Tokyo"));
            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello", "Seattle"));
            outputs.Add(await context.CallActivityAsync<string>("E1_SayHello_DirectInput", "London"));

            // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
            return outputs;
        }

        [FunctionName("E1_SayHello")]
        public static string SayHello([ActivityTrigger] IDurableActivityContext context)
        {
            string name = context.GetInput<string>();
            return $"Hello {name}!";
        }

        [FunctionName("E1_SayHello_DirectInput")]
        public static string SayHelloDirectInput([ActivityTrigger] string name)
        {
            return $"Hello {name}!";
        }
    }
 }

另外,单元测试将验证输出格式。And the unit tests will verify the format of the output. 单元测试可以直接使用参数类型,也可以模拟 IDurableActivityContext 类:The unit tests can use the parameter types directly or mock IDurableActivityContext class:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace VSSample.Tests
{
    using Microsoft.Azure.WebJobs.Extensions.DurableTask;
    using Xunit;
    using Moq;

    public class HelloSequenceActivityTests
    {
        [Fact]
        public void SayHello_returns_greeting()
        {
            var durableActivityContextMock = new Mock<IDurableActivityContext>();
            durableActivityContextMock.Setup(x => x.GetInput<string>()).Returns("John");
            var result = HelloSequence.SayHello(durableActivityContextMock.Object);
            Assert.Equal("Hello John!", result);
        }

        [Fact]
        public void SayHello_returns_greeting_direct_input()
        {
            var result = HelloSequence.SayHelloDirectInput("John");
            Assert.Equal("Hello John!", result);
        }
    }
}

后续步骤Next steps