Our application is built using Angular + ASP.NET Core WebAPI, a very common good combination these days. To ensure the quality, we have developed a very good suite of integration tests. Integration tests might mean different things to different people. In our application, it is this setup:
- API is deployed to a web server with its own databases. It is real.
- A test project where the code makes calls to the test API. The API is decorated with Swagger and generates a NuGet package. The test project consumes the package and makes calls with type safe – just like calling a normal method.
It works perfectly and serves our purpose well. However, there is a problem. We do not again any code coverage with integration tests. We understand that integration tests are not supposed to gain code coverage. It is the job of unit tests.
So we have a bunch of unit tests to ensure the quality as well as code coverage. Then we realize that most of the code in integration tests and unit tests are identical. Would be cool and beneficial if we can take advantages of integration tests for unit test? In other word, is it possible to write tests once but run in 2 places?
Yes. It is possible. Below is the process of how we accomplish it. Note that it is applicable in our circumstances, it might not in yours.
Identify Pattern
Where do I start? The first thing I usually do is to find a pattern. There is always a pattern. I have to find a commodity between the two. Here it is:
- Prepare a request
- Send the request to the service endpoints
- Process the response – either a valid response or an exception (such as not authorized, not allowed, bad request)
It is better to see some code. This is not a production code. I make it up to demonstrate the point.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
public class FoodController : Controller
{
[HttpGet]
[Route("/food/{id}")]
[SwaggerOperation(
Summary = "Get details about food",
Description = "Get details about food",
OperationId = "FoodGetById")]
[SwaggerResponse(StatusCodes.Status200OK, "FoodDetailResponse", typeof(FoodDetailResponse))]
[SwaggerResponse(StatusCodes.Status400BadRequest)]
public IActionResult GetById(Guid id)
{
return Ok(new FoodDetailResponse());
}
}
The endpoint is to get food by id. Beside the common MVC things, it is decorated with SwaggerOperation. Once the client is generated, it allows us to write tests code as below
[TestFixture]
public class FoodTest
{
[Test]
public void CanGetFoodById()
{
var client = GetClient();
var foodId = Guid.NewGuid("exiting-id");
var response = client.FoodGetById(foodId);
Assert.AreEqual(foodId, response.Id);
}
private static Client GetClient()
{
// Swagger generated client point to an API server
return new Client("http://api.food-test.com");
}
}
So how could we make the test code exercise again the Controller – the FoodController.GetById method is invoked.
Read the code carefully, there is a flow:
- Prepare data
- Create client
- Invoke a method on client
- Assert the outcome
Step 2 – Create client: If it is in integration test context, the client is a generated Swagger client, in unit test, it is a controller instance.
Step 3 – Invoke a method: In integration test, it is the generated Swagger method defined by the OperationId, in unit test, it is a controller action.
Refactor To Template
The idea is to create a test template and delegate the actual context dependent to concrete contexts. Here is the new version, explanation comes below
public interface IClientFactory
{
Client CreateClient();
}
public interface IExecutionProxy
{
TResponse Execute<TResponse>(Client client, string methodName, params object[] parameters);
}
[TestFixture]
public class FoodTest
{
private IClientFactory _clientFactory;
private IExecutionProxy _executionProxy;
protected virtual IClientFactory CreateClientFactory()
{
return new SwaggerClientFactory();
}
protected virtual IExecutionProxy CreateExecutionProxy()
{
return new SwaggerExecutionProxy();
}
[SetUp]
public void ContextSetup()
{
_clientFactory = CreateClientFactory();
_executionProxy = CreateExecutionProxy();
}
[Test]
public void CanGetFoodById()
{
var client = _clientFactory.CreateClient();
var foodId = Guid.NewGuid("exiting-id");
var response = _executionProxy.Execute<Guid>(client, nameof(client.FoodGetById), foodId);
Assert.AreEqual(foodId, response.Id);
}
}
IClientFactory – Create Client. In the context of unit test, the Client does not matter.
IExecutionProxy – Execute a method.
Look at the CanGetFoodById test method again. It is up to the IExecutionProxy to decide where to send the request. The magic is at the implementation of the 2 interfaces. Passing the method name to the Execute method allows using reflection to invoke the expected method. The real implementation has more code but in the nutshell they are simple as shown.
Here is the proxy implementation. Because tests start at the integration level, so the proxy version is pretty straight forward.
public class SwaggerClientFactory : IClientFactory
{
public Client CreateClient()
{
// Swagger generated client point to an API server
return new Client("http://api.food-test.com");
}
}
public class SwaggerExecutionProxy : IExecutionProxy
{
public TResponse Execute<TResponse>(Client client, string methodName, params object[] parameters)
{
var method = client.GetType().GetMethod(methodName);
return (TResponse)method.Invoke(client, parameters);
}
}
The controller implementation is bit complicated but still not that much.
public class ControllerClientFactory : IClientFactory
{
public Client CreateClient()
{
// Just any dummy client is ok. Because the Controller version does not use the Client instance
return new Client("http://any-thing-is-fine.com");
}
}
public class ControllerExecutionProxy : IExecutionProxy
{
private readonly Fixture _fixture;
public ControllerExecutionProxy()
{
_fixture = new Fixture();
}
static ControllerExecutionProxy()
{
var controllers = typeof(FoodController).Assembly
.GetTypes()
.Where(x => x.Name.EndsWith("Controller"));
foreach (var c in controllers)
{
foreach (var methodInfo in c.GetMethods(BindingFlags.Instance | BindingFlags.Public))
{
var attr = methodInfo.GetAttribute<SwaggerOperationAttribute>();
if (attr == null || string.IsNullOrWhiteSpace(attr.OperationId))
{
continue;
}
if (MethodMappings.ContainsKey(attr.OperationId))
{
Console.WriteLine($"Operation {attr.OperationId} is duplicated from Swagger decoration. Consider to rename it");
continue;
}
MethodMappings.Add(attr.OperationId, (c, methodInfo));
}
}
}
private static readonly Dictionary<string, (Type controller, MethodInfo action)> MethodMappings = new Dictionary<string, (Type controller, MethodInfo action)>();
public TResponse Execute<TResponse>(Client client, string methodName, params object[] parameters)
{
var invoke = MethodMappings[actualName];
var controller = _fixture.Create(invoke.controller);
var expectedParams = invoke.action.GetParameters();
var convertedParameters = ConvertSwaggerTypeToController(parameters, expectedParams);
var actionResult = (OkObjectResult)invoke.action.Invoke(controller, convertedParameters.ToArray());
return JsonConvert.DeserializeObject<TResponse>(JsonConvert.SerializeObject(actionResult.Value, SerializerSettings));
}
private static List<object> ConvertSwaggerTypeToController(object[] parameters, ParameterInfo[] expectedParams)
{
// Most of the parameters have the same structure, just different type due to the generated type from Swagger
// Therefore a simple serialization/deserialization will solve the problem of converting data type
var result = new List<object>();
for (int i = 0; i < parameters.Length; i++)
{
result.Add(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(parameters[i], SerializerSettings), expectedParams[i].ParameterType));
}
return result;
}
}
The above implementation is to prove the point. It is not a complete implementation. There are many assumptions that I make:
- I use AutoFixture to create objects in test.
- API is sync. But in reality, they are async.
- Not deal with authorization attribute
- Not deal with exception. For example, when we want to test a bad request to the API.
- Inconsistent parameters count between controllers and generated Swagger client. Ex: a GET action method where the parameters are custom classes, they are flatten into many parameters in Swagger client. Each primitive parameter is passed via query string.
Apply to Unit Test
So far, everything is still in the integration test context. Let’s make it worked for unit test
public class FoodControllerTest : FoodTest
{
override CreateClientFactory(){
return new ControllerClientFactory();
}
override CreateExecutionProxy(){
return new ControllerExecutionProxy();
}
}
That’s it. Create a new test inside the context of a unit test and tell the FoodTest to use the controller versions. All tests in FoodTest are applied for unit tests.
Where To Go Next?
Is it value to your project? Maybe, maybe not. However, would it be a good thing for you to get that implementation completed? There are many knowledge areas that you can harvest:
- Decorating Web API with Swagger and consume its client.
- Unit testing controller. Use AutoFixture to create components instead of manually new objects.
- A bit of reflection.
- Learn how to design for extension.
- And it is fun to get something done and cool.
In our projects, we have enjoyed the benefits of this approach so much. Less code to write, completely independent to the implementation, to name a few. There is devil in the detail but the reward is worthy.