In this mini-series we have analyzed different challenges around IoT Edge development, and explored a few best practices that will help us avoid any unnecessary roughness when developing Edge applications. In the last post we saw how we can use most of these best practices to compose a loosely coupled Azure IoT Edge application and some tooling that allows us to have a true F5 development experience. Today we will see the main driver for all these patterns, which is unit testing.

The main challenge with any modern application is finding ways to test most of it's code, and then automating that. This automated testing capability can be perceived as the holy grail of every production system, because it greatly accelerates the software development life cycle iterations and eventually increases the overall product quality.

The challenge with Azure IoT Edge development is that the security and communication protocols are tightly coupled to the IoT C# SDK, making it very difficult to run a test without the corresponding infrastructure. Nevertheless, as we saw in this post, there is an easy way to abstract this SDK dependency and run the business code using a mock implementation of the SDK. We will use the same approach to mock all other dependencies, and eventually test a stand alone business method, a unit test.

Unit Testing Setup

We will be using the xunit unit testing framework, a very popular option in .NET Core. We will build two projects from scratch, one IoT Edge module project similar to this template, and an xunit unit testing project that references the former.

  1. Create the solution and project folders
mkdir UnitTestingExample
cd UnitTestingExample

mkdir IoTModule
mkdir IoTModuleTests

2. Create the IoT Edge Module project and add the required nugets

cd IoTModule
dotnet new console
dotnet add package Microsoft.Azure.Devices.Client
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Serilog.Extensions.Hosting
dotnet add package Serilog.Settings.Configuration
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Enrichers.Thread

4. Add the module code. We will replace the Program.cs code with:

namespace IoTModule
{
    using System.Threading.Tasks;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Serilog;

    class Program
    {
        static async Task Main(string[] args)
        {
            using (var host = Host.CreateDefaultBuilder(args)
                 .ConfigureServices((hostContext, services) =>
                 {
                    services.AddSingleton<IModuleClient, ModuleClientWrapper>();
                    services.AddHostedService<EventHandlerModule>();
                 })
                 .UseSerilog()
                 .UseConsoleLifetime()
                 .Build())
            {
                await host.RunAsync();
            }
        }
    }
}

5. This code references the IModuleClient  the ModuleClientWrapper and the module class, the EventHandlerModule. We have seen before the first two before. They are useful to create an abstraction layer over the SDK and can be found in this post.

6. The EventHandlerModule.cs code is this:

namespace IoTModule
{
    using Microsoft.Azure.Devices.Client;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;

    public class EventHandlerModule : IHostedService
    {
        readonly IModuleClient moduleClient;
        readonly ILogger logger;

        public EventHandlerModule(IModuleClient moduleClient,
            ILogger<EventHandlerModule> logger)
        {
            this.moduleClient = moduleClient;
            this.logger = logger;
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            await moduleClient.OpenAsync(cancellationToken);

            await moduleClient.SetInputMessageHandlerAsync("input",
                new MessageHandler(async (message, context) =>
                {
                    var messageText = Encoding.UTF8.GetString(message.GetBytes());
                    logger.LogInformation($"Message received: {messageText}");
                    return await Task.FromResult(MessageResponse.Completed);
                }), this);

            logger.LogInformation("Started.");
        }

        public async Task StopAsync(CancellationToken cancellationToken)
        {
            await moduleClient.CloseAsync(cancellationToken);
            logger.LogInformation("Stopped.");
        }
    }
}

This module is fairly simple, it just expects messages in the input called input and logs the message content as string. When we try to run this application, we get an exception from the IoT SDK complaining about missing environment variable, required to connect to the edge daemon:

Environment variable IOTEDGE_WORKLOADURI is required.

It is clear that to test this message handler callback, we need to mock out the SDK.

Unit test project

Let's setup our unit testing project now. We'll add the Moq nuget too:

cd ..
cd IoTModuleTests
dotnet new xunit
dotnet add package Moq
dotnet add reference ..\IoTModule

Let's replace the default test code with this empty test:

namespace ModuleTests
{
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Xunit;
    using SimulatedTemperatureSensor;
    using Moq;
    using System.Threading;
    using Microsoft.Extensions.Logging;
    using Microsoft.Azure.Devices.Client;
    using System.Text;
    using System.Collections.Generic;
    using System.Threading.Tasks;

    public class EventHandlerModuleTester
    {
        [Fact]
        public async Task TestInputMessageHandler()
        {
            Assert.True(true);
        }
    }
}
This is a good moment to verify your code, you should be able to run this test successfully. Simply run dotnet test inside the IoTModuleTests directory

This will be our first unit test that sends a message to the input input and expects a successful execution of the callback.  

Mocking the IoT SDK

Our test needs to setup a similar dependency injection mechanism and add in this mechanism all involved services, except for the ModuleClientWrapper which we will be mocking. This mocking will be based on the SDK abstraction layer we created in this post.

Abstracting an SDK has the added benefit of controlling the area of exposure of our codebase to this SDK. By abstracting only the useful portion of the SDK we reduce the impact the future versions have on our application.

The test with just the dependency injection mechanism becomes:

[Fact]
public void TestInputMessageHandler()
{
    var services = new ServiceCollection();

    services.AddHostedService<EventHandlerModule>();

    var serviceProvider = services.BuildServiceProvider();
    var hostedService = serviceProvider.GetService<IHostedService>();
    hostedService.StartAsync(new CancellationToken());
}

This code will try to create and start our module, the EventHandlerModule hosted service class. Looking at this class constructor we can see that this service depends on two other services, the IModuleClient and the ILogger<EventHandlerModule> services.

Lets add a mock version of them:

[Fact]
public async Task TestInputMessageHandler()
{
    var services = new ServiceCollection();

    services.AddHostedService<EventHandlerModule>();
    services.AddSingleton((s) =>
    {
        var moduleClient = new Mock<IModuleClient>();
        return moduleClient.Object;
    });

    services.AddSingleton((s) =>
    {
        var mockLogger = new Mock<ILogger<EventHandlerModule>>();
        return mockLogger.Object;
    });

    var serviceProvider = services.BuildServiceProvider();

    var hostedService = serviceProvider.GetService<IHostedService>();
    await hostedService.StartAsync(new CancellationToken());
}

Now this test is complete, in terms having a valid dependency hierarchy in place. In fact, running this test will yield a successful result. We are still missing two last tasks, to inject a message to the module's input and validate a successful callback execution. In other words, we will override the IModuleClient's  SendEventAsync  and SetInputMessageHandlerAsync methods to send a message to the registered callback. To do this, we will use a Dictionary<string, (MessageHandler, object)> structure to keep the callback in memory and invoke it when we get a message:

[Fact]
public void _TestInputMessageHandler()
{
    var inputMessageHandlers = new Dictionary<string, (MessageHandler, object)>();

    var services = new ServiceCollection();

    services.AddHostedService<EventHandlerModule>();
    services.AddSingleton((s) =>
    {
        var moduleClient = new Mock<IModuleClient>();

        moduleClient.Setup(e => e.SetInputMessageHandlerAsync(
            It.IsAny<string>(),
            It.IsAny<MessageHandler>(),
            It.IsAny<object>()))
        .Callback<string, MessageHandler, object>((inputName, messageHandler, userContext) =>
        {
            inputMessageHandlers[inputName] = (messageHandler, userContext);
        }).Returns(Task.FromResult(0));

        moduleClient.Setup(e => e.SendEventAsync(
            It.IsAny<string>(),
            It.IsAny<Message>()))
        .Callback<string, Message>((output, message) =>
        {
            var result = inputMessageHandlers[output].Item1(message,
                        inputMessageHandlers[output].Item2).GetAwaiter().GetResult();

            Assert.Equal(MessageResponse.Completed, result);

        }).Returns(Task.FromResult(0));

        return moduleClient.Object;
    });

    services.AddSingleton((s) =>
    {
        var mockLogger = new Mock<ILogger<EventHandlerModule>>();
        return mockLogger.Object;
    });

    var serviceProvider = services.BuildServiceProvider();

    var hostedService = serviceProvider.GetService<IHostedService>();
    hostedService.StartAsync(new CancellationToken()).GetAwaiter().GetResult();

    serviceProvider.GetService<IModuleClient>()
        .SendEventAsync("input",
            new Message(Encoding.UTF8.GetBytes("This is a mocked message!"))).GetAwaiter().GetResult();
} 

By calling the Assert.Equal(MessageResponse.Completed, result); inside the SendEventAsync mock , our unit test validates that the callback executed successfully.

Although this test initially might seem too complicated for such a simple callback, most of this code is common to any other unit test we might implement, and it can be hidden in a dependency initialization step, shared across all other tests for the same module. Indeed, this common initialization step could be moved in this Init method:

public async Task<ServiceProvider> Init(Action<MessageResponse> messageResultCallback)
{
    var inputMessageHandlers = new Dictionary<string, (MessageHandler, object)>();

    var services = new ServiceCollection();

    services.AddHostedService<EventHandlerModule>();
    services.AddSingleton((s) =>
    {
        var moduleClient = new Mock<IModuleClient>();

        moduleClient.Setup(e => e.SetInputMessageHandlerAsync(
            It.IsAny<string>(),
            It.IsAny<MessageHandler>(),
            It.IsAny<object>()))
        .Callback<string, MessageHandler, object>((inputName, messageHandler, userContext) =>
        {
            inputMessageHandlers[inputName] = (messageHandler, userContext);
        }).Returns(Task.FromResult(0));

        moduleClient.Setup(e => e.SendEventAsync(
            It.IsAny<string>(),
            It.IsAny<Message>()))
        .Callback<string, Message>((output, message) =>
        {
            var result = inputMessageHandlers[output].Item1(message,
                        inputMessageHandlers[output].Item2).GetAwaiter().GetResult();

            messageResultCallback(result);

        }).Returns(Task.FromResult(0));

        return moduleClient.Object;
    });

    services.AddSingleton((s) =>
    {
        var mockLogger = new Mock<ILogger<EventHandlerModule>>();
        return mockLogger.Object;
    });

    var serviceProvider = services.BuildServiceProvider();

    var hostedService = serviceProvider.GetService<IHostedService>();
    await hostedService.StartAsync(new CancellationToken());
    return serviceProvider;
}

and in this case, our message handler unit test code becomes:

[Fact]
public async Task TestInputMessageHandler()
{
    MessageResponse messageResponse = MessageResponse.Abandoned;
    var serviceProvider = await Init((e) => messageResponse = e);

    await serviceProvider.GetService<IModuleClient>()
        .SendEventAsync("input",
            new Message(Encoding.UTF8.GetBytes("This is a mocked message!")));

    Assert.Equal(MessageResponse.Completed, messageResponse);
}
The code of this example has been published here.

Recap

We saw how we can use the default .NET Core unit testing frameworks to build unit tests for our IoT Edge application. Following this approach we can define any other type of IoT Edge unit test, like twin update, direct method calls etc.