Durable Functions-Unittest (C#)

Der Unittest ist ein wichtiger Bestandteil moderner Softwareentwicklungsverfahren. Mit Unittests wird das Verhalten der Geschäftslogik überprüft und verhindert, dass in der Zukunft unbemerkte gravierende Änderungen eingeführt werden. Die Komplexität von Durable Functions kann problemlos wachsen, sodass die Einführung von Unittests dazu beiträgt, gravierende Änderungen zu vermeiden. In den folgenden Abschnitten wird erläutert, wie Unittests der drei Funktionstypen durchgeführt werden: Orchestrierungsclient-, Orchestrator- und Aktivitätsfunktionen.

Hinweis

In diesem Artikel finden Sie Anleitungen zum Komponententest für Durable Functions Apps, die in C# für den .NET In-Process Worker geschrieben wurden und 2 x auf Durable Functions ausgerichtet sind. Weitere Informationen zu den Unterschieden zwischen den Versionen finden Sie im Artikel Durable Functions-Versionen.

Voraussetzungen

Die Beispiele in diesem Artikel setzen Kenntnisse der folgenden Konzepte und Frameworks voraus:

  • Komponententest

  • Langlebige Funktionen

  • xUnit – Testframework

  • moq – Simulationsframework

Basisklassen für Simulation

Die Simulation wird über die folgenden Schnittstellen unterstützt:

Diese Schnittstellen können mit den verschiedenen von Durable Functions unterstützten Auslösern und Bindungen verwendet werden. Bei Ausführung Ihrer Azure-Funktionen führt die Funktionsruntime den Funktionscode mit einer konkreten Implementierung dieser Schnittstellen aus. Bei Unittests können Sie eine simulierte Version dieser Schnittstellen übergeben, um Ihre Geschäftslogik zu testen.

Unittest-Triggerfunktionen

In diesem Abschnitt überprüft der Unittest die Logik der folgenden HTTP-Triggerfunktion zum Starten neuer Orchestrierungen.

// 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);
        }
    }
}

Die Unittestaufgabe überprüft den Wert des in der Antwortnutzlast bereitgestellten Retry-After-Headers. Dazu simuliert der Unittest einige der IDurableClient-Methoden, um vorhersagbares Verhalten sicherzustellen.

Zunächst wird ein Simulationsframework (in diesem Fall moq) verwendet, um IDurableClient zu simulieren:

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

Hinweis

Sie können zwar Schnittstellen implementieren, indem Sie die Schnittstelle direkt als Klasse implementieren, aber Simulationsframeworks vereinfachen den Prozess in verschiedener Hinsicht. Wenn beispielsweise der Schnittstelle über mehrere Nebenversionen hinweg eine neue Methode hinzugefügt wird, erfordert moq im Gegensatz zu konkreten Implementierungen keine Codeänderungen.

Dann wird die StartNewAsync-Methode simuliert, um eine gut bekannte Instanz-ID zurückzugeben.

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

Als Nächstes wird CreateCheckStatusResponse simuliert, um immer eine leere HTTP 200-Antwort zurückzugeben.

// 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))
        }
    });

ILogger wird auch simuliert:

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

Jetzt wird die Run-Methode vom Unittest aufgerufen:

// 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);

Der letzte Schritt ist der Vergleich der Ausgabe mit dem erwarteten Wert:

// 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);

Nach der Kombination aller Schritte hat der Unittest folgenden 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);
        }
    }
}

Unittest-Orchestratorfunktionen

Orchestratorfunktionen sind für Unittests noch interessanter, da sie in der Regel viel mehr Geschäftslogik aufweisen.

In diesem Abschnitt überprüfen die Unittests die Ausgabe der E1_HelloSequence-Orchestratorfunktion:

// 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}!";
        }
    }
 }

Der Unittestcode beginnt mit dem Erstellen einer Simulation:

var durableOrchestrationContextMock = new Mock<IDurableOrchestrationContext>();

Dann werden die Aktivitätsmethodenaufrufe simuliert:

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!");

Als Nächstes ruft der Unittest die HelloSequence.Run-Methode auf:

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

Schließlich wird die Ausgabe überprüft:

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

Nach der Kombination aller Schritte hat der Unittest folgenden 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]);
        }
    }
}

Unittest-Aktivitätsfunktionen

Der Unittest von Aktivitätsfunktionen kann in gleicher Weise wie der nicht dauerhafter Funktionen durchgeführt werden.

In diesem Abschnitt überprüft der Unittest das Verhalten der E1_SayHello-Aktivitätsfunktion:

// 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}!";
        }
    }
 }

Die Komponententests überprüfen auch das Format der Ausgabe. Die Unittests können die Parametertypen direkt oder über die Pseudoklasse IDurableActivityContext verwenden:

// 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);
        }
    }
}

Nächste Schritte