Serverless, DevOps, and CI/CD: Part 1
Unit tests for Azure Functions
A few weeks ago Matthew Henderson was preparing a talk at ServerlessDays London on security. While going over the content with me he educated me on the very interesting theory of Risk Compensation. Risk compensation describes the phenomena where we are less careful when we perceive less risk. The canonical example is that people with seatbelts or anti-lock braking systems drive faster and closer to cars than those who don’t.
One of the side-effects I’ve noticed with serverless is that as more protections are offered from the platform itself, developers tend to take greater risks in their development process. For years we’ve been taught about test-driven development, automated builds, and continuous delivery — yet many serverless apps are published with a right-click. Some of that is risk compensation, but some stems from the fact that samples and tools and frameworks to support functional deployments are still emerging.
Let’s start with the basics: testing. The following example will use a function that will detect whether a number is odd or even. The first example will receive the number as an HTTP request, and return a response with the answer.
I need to write 3 unit tests:
- When I pass in an even number, I should get “even”
- When I pass in an odd number, I should get “odd”
- When I pass in a non-number I should get “Bad Request”
To do that I’ll create a test project (xUnit) in the same solution. I added a reference to the function app project and defined 3 unit tests as described above. Here’s one of the below:
As part of the execution, I’m utilizing a logger. Instead of using
TraceWriter which was the previous default for Azure Functions, the templates have been updated to now leverage
ILogger. This abstraction is nice for a few reasons, including making testing much easier. Here I’m using
NullLogger to pass into my tests. It does nothing but implements the
private readonly ILogger logger = NullLoggerFactory.Instance.CreateLogger(“Test”);
I can validate all my unit tests work using the Test Explorer in Visual Studio, or by running
dotnet test with the dotnet CLI.
That’s it for an HTTP triggered function, but what if my function doesn’t return a value? For example, instead of triggering on an HTTP request and returning an HTTP response, what if I triggered on a storage queue and had no return value? Instead, I may be interacting with an external database or API as a result of the calculation as shown below.
This presents us with two problems:
- How can I validate the number is set to odd or even if I don’t have a return value to validate?
- How can I run unit tests without causing side-effects on the external systems (a REST API in this example)?
We need to think about these problems a little different because with serverless functions the method signature has to follow prescribed patterns or the host won’t be able to execute the function. With #1, I can’t just add an arbitrary return value to the method as return values would break the signature (It should be noted that any function output binding can be set as a return value, but in this case, I’m not using output bindings).
With #2, I can’t just inject via traditional Dependency Injection my own HttpClient or HttpMessageHandler as that may also interfere with signature, and the static nature of methods means dependency injection today isn’t possible without other strategies (that this blog isn’t going to go into 😊).
Let’s start with the first question posed: How can I validate that odd or even is correctly assigned without a return value? I can think of a few potential options. I could set some public static variable in the class with the answer, and validate the variable has the right value after running the test. This works but feels a little strange to have my code set the value of a variable strictly for testing purposes.
Instead, I’m going to leverage pieces that are already injected (
ILogger) and use mocks to validate others. This is testing based on behavior verification and will allow me to validate code paths even without return values. First I created a simple implementation of
ILogger that would allow me to verify the right logs were being emitted based on inputs so I could validate the number was correctly assigned as odd or even.
And I can validate the even branch was executed with the following LINQ expression
Let’s continue this on and also solve for the #2 question posed above: How do I test a function that has side-effecting? If I execute my tests right now, HTTP requests would hit my APIs which may have consequences. One option could be to have the destination URL be variable so during my tests I can send to a ’test’ endpoint, but there is a much better way with mocks. Unfortunately, the story of dependency injection in .NET functions is still evolving (stay tuned). This is generally the best way to introduce mocks into a method execution. But I can still use some other approaches to achieve the same results.
With Azure Functions, it’s a best practice to re-use clients like HttpClient, SqlConnection, and EventHubsClient, so I already have a
static HttpClient in my class. Before executing my tests, I just need to replace the
HttpClient with a mock. This will cause all requests to automatically return an OK response. I could customize the setup to return dynamic responses based on the input as well.
Now when my tests run the HTTP request never hits my production system. I can also do additional validation that the request content matches what I expect based on the input provided, giving me an additional level of validation separate from the logs.
While here I’m mocking an
HttpClient, a similar approach could be used for other external systems like
SqlConnection. And in case you are wondering how you can test the actual HTTP request or SQL operation, that will be touched upon in a later blog about integration and end to end tests.
I’d encourage you to check out the full code for this sample. To continue, read part 2 of the blog that goes into building automated builds and releases for a CI/CD pipeline.