Our Move to Microservices - Testing

Properly testing services requires a more comprehensive approach than unit testing individual classes. Services exist within an environment, depending on it and other services to properly function. Traditional unit testing usually begins and ends with a single class, but that is only the starting point for testing a service. From there, we gradually increase the scope of the unit under test until we've reached the subsystem (or microservice) level. This is called the Spiral of Test and is described in Chapter 11 of Programming WCF Services. (It is also briefly described in the Escaping Appland article.)

The spirals, or scopes, for the "unit under test" are:

  • As a POCO
  • As an actor/service
  • With differing levels of integration
  • Within a use case (subsystem / microservice level)

Let's examine how this works with a simple scenario.

Scenario

We need to notify a user by email or SMS whenever a stock they're interested in buying goes under a certain value. The call chain for the use case might look like this:

Call chains are read from left to right, so the sequence of steps are:

  1. Retrieve a message template (e.g. stock alert template)
  2. Format the template (e.g. fill in stock ticker symbol, current price, and date)
  3. Deliver the formatted message to the user based on their preference (SMS or email)

We will keep the definitions for most of the components simple:

[ServiceContract]
interface ITemplateAccess : IService  
{
  [OperationContract]
  Task<Template> RetrieveTemplateAsync(int TemplateId);
}

class TemplateAccess : ITemplateAccess { ... }

[ServiceContract]
interface IFormattingEngine : IService  
{
  [OperationContract]
  Task<Message> FormatMessageAsync(Template template, ...);
}

class FormattingEngine : IFormattingEngine { ... }

[ServiceContract]
interface IDeliveryAccess : IService  
{
  [OperationContract]
  Task<bool> DeliverMessageAsync(Message message);
}

class DeliveryAccess : IDeliveryAccess { ... }  

And provide a bit more detail for the manager:

[ServiceContract]
interface INotificationManager : IService  
{
  [OperationContract]
  Task<bool> NotifyUserAsync(User user, Stock stock);
}

class NotificationManager : INotificationManager  
{
    public Task<bool> NotifyUserAsync(User user, Stock stock)
    {
        var templateAccess = ServiceProxy.Create<TemplateAccess>(...);
        var template = await templateAccess.RetrieveTemplateAsync(STOCK_ALERT);

        var formattingEngine = ServiceProxy.Create<FormattingEngine>(...);
        var formattedMessage = await formattingEngine.FormatMessageAsync(template, user, stock);

        var deliveryAccess = ServiceProxy.Create<DeliveryAccess>(...);
        return deliveryAcess.DeliverMessageAsync(formattedMessage);
    }
}

Test as POCO

This tests the class as if it were a normal class.

[Fact]
public async void Test_FormatMessageAsync_AsPoco()  
{
    var template = new Template("Stock {0} has dropped to {1}!");

    Action<IFormattingEngine> action = (sut) =>
    {
        var result = await sut.FormatMessageAsync(template, "NT", 1.25m);

        Assert.Equal("Stock NT has dropped to 1.25!", result);
    }

    harness.TestServicePoco<IFormattingEngine>(action);
}

What's going on here? We're leveraging ServiceModelEx.ServiceFabric.Test, part of the ServiceModelEx library provided by IDesign. In a future article, we'll see how the library allows us to write services that can easily be deployed both locally and in ServiceFabric by "flipping a switch", but for now we'll stick to its testing capabilities.

The testing infrastructure exposes an abstract base class, ServiceTestBase, which you inherit from and then use inside fixtures.

public class TestHarness : ServiceTestBase { }

public class FormattingEngineFixture  
{
    private readonly TestHarness harness;

    FormattingEngineFixture()
    {
        harness = new TestHarness();
    }

    [Fact]
    public async void Test_FormatMessageAsync_AsPoco()
    {
        ...
        harness.TestServicePoco<IFormattingEngine>(action);
    }
}

Why bother when the formatting engine doesn't use any other services? In extremely simple cases, it may not make a difference, but what if your service depends on ambient context? The infrastructure allows you hooks to "fill in" that information without changing any code while still testing your class as a POCO. The next spiral adds another reason to follow this pattern.

Test as Service

The code in this test should look eerily familiar as we've only removed four letters from the previous one.

[Fact]
public async void Test_FormatMessageAsync_AsService()  
{
    var template = new Template("Stock {0} has dropped to {1}!");

    Action<IFormattingEngine> action = (sut) =>
    {
      var result = await sut.FormatMessageAsync(template, "NT", 1.25m);

      Assert.Equal("Stock NT has dropped to 1.25!", result);
    }

    harness.TestService<IFormattingEngine>(action);
}

In this instance, the testing infrastructure is creating a service environment in the background and then invoking our operation in "the real world". Why would we care? Because the environment can make a difference: method parameters or return values might be larger than limits allow, exceptions might not be properly declared, etc. Luckily for us, we can keep our POCO and service tests practically identical and let the infrastructure do the heavy lifting.

Test with Integration / as Use Case

In this scenario, the integration tests at the manager level will offer us everything we need to test the use case as well. In more complex situations, we might need various levels of integration testing lower down the chain.

Different levels of integration allow us to mock one or more dependencies as required. For instance, we could mock both access components the manager uses and leave the real formatting engine in place.

[Fact]
public async void Test_NotifyUserAsync_AsPoco_With_AccessMocks()  
{
     var user = new User("Blackadder");
     var stock = new Stock("Potatoes");

    var templateAccesMock = new Mock<ITemplateAccess>();
    templateAccessMock.Setup(x => x.RetrieveTemplateAsync(It.IsAny<int>())).ReturnsAsync(new Template());

    var deliveryAccessMock = new Mock<IDeliveryAccess>();
    deliveryAccessMock.Setup(x => x.DeliverMessageAsync(It.IsAny<FormattedMessage>())).ReturnsAsync(true);

    Action<INotificationManager> action = (sut) =>
    {
       var result = await sut.NotifyUserAsync(user, stock);

        Assert.True(result);
    }

    harness.TestServicePoco<INotificationManager>(action, templateAccessMock, deliveryAccessMock);
}

Now we see another reason why being able to control the environment when testing services is so important: we are able to decide which components (or services) are real and dictate how the others will behave. For those of you who saw the implementation of NotifyUserAsync and thought "that's impossible to test!", hopefully you now have your answer :)

Next Up

First, to return to the promised monthly cadence :)

Secondly, diving into more detail on service implementation or deployment seems like a reasonable follow-up. Another option would be a discussion of "why service locator instead of DI". Any and all feedback is appreciated!