Dependency Injection – Vaccinate your tests from messy code

Fed up with messy test code that's difficult to maintain and extend? Read on to learn how to supercharge your automated test by implementing Inversion of Control using .NET Dependency Injection.

I recently came across some poorly designed test code. I hope this code snippet speaks for itself:

public class PaymentsSteps
{
  public object APaymentIsSent(PaymentType payload)
  {
    var tokenService = new TokenService();
    var client = new RestClient(AppSettings.BaseUrl);
    return client.Post<object>("/api/fpsmessage", payload, tokenService.GetToken());
  }
 
  public object ARepeatPaymentIsSent(PaymentType payload)
  {
    var tokenService = new TokenService();
    var client = new RestClient(AppSettings.BaseUrl);
    return client.Post<object>("/api/fpsmessage/repeat-request", payload, tokenService.GetToken());
  }
}

In case the code doesn't tell the whole story, the issues I have with it are as follows:

  1. It's inefficient. Every test ends up constructing a huge graph of objects which fill up the heap and slow down my machine (and the tests).
  2. It's messy. The poorly managed dependencies make it unnecessarily difficult to read the code and understand its intent.
  3. It's difficult to debug. When a test fails it's hard to find where it went wrong and pinpoint the specific object that had the issue.
  4. It's hard to maintain. A lack of sensible coding practices and SOLID principles means working with code like this is difficult and slow.
  5. It spreads like wildfire. If poor coding patterns like these are left unchecked they tend to proliferate across a codebase, further compounding the issues.

Managing dependencies

In solving these problems two possible solutions first came to mind.

One option would be to use static objects rather than instance objects. The problem with this approach is that if the tests are configured to run in parallel (which they really should be) and the static objects are stateful, then each test that runs will overwrite the state, causing other tests to fail.

Another option is to use a test base class that instantiates all the dependencies in a single constructor/method before each test runs. The trouble here is that each test will need a different set of dependencies. However, if all the tests inherit from the same base class they will end up instantiating objects they won't use need. This is inefficient and will slow the tests down. It could also cause brittleness in the tests, as any change to the base class would impact every test.

With these two options off the table, I considered using an IoC (Inversion of Control) container to inject the dependencies instead. The benefit of using an IoC container is that I would be able to leverage the different scoping options to ensure that each test only instantiates the dependencies it actually needs. In addition, I could centralise all the registrations into a single class, making it easier to manage and maintain and wouldn't rely on static objects or base classes.

Identifying dependencies

The test framework I was working with followed a common pattern of using Steps classes that defined each of the actions that took place within each test.

The first step I took was to identify the dependencies within each of these Steps classes and centralise them. In some cases the same dependency was being instantiated every time it was called. I replaced these with a single instance, which I instantiated in the constructor and stored a reference to it in a private read-only field.

public class PaymentsSteps
{
  private readonly TokenService _tokenService;
  private readonly IRestClient _client;
 
  private PaymentsSteps()
  {
    _tokenService = new TokenService();
    _client = new RestClient(AppSettings.BaseUrl);
  }
 
  public object APaymentIsSent(PaymentType payload) =>
    return _client.Post<object>("/api/fpsmessage", payload, _tokenService.GetToken());
 
  public object ARepeatPaymentIsSent(PaymentType payload) =>
    return _client.Post<object>("/api/fpsmessage/repeat-request", payload, _tokenService.GetToken());
}

Inverting control

Having confirmed the tests still passed, I then refactored the constructor to accept each dependency as a parameter, which I could later inject using an IoC container.

public class PaymentsSteps
{
  private readonly TokenService _tokenService;
  private readonly IRestClient _client;
 
  private PaymentsSteps(TokenService tokenService, IRestClient client)
  {
    _tokenService = tokenService;
    _client = client;
  }
 
  public object APaymentIsSent(PaymentType payload) =>
    return _client.Post<object>("/api/fpsmessage", payload, _tokenService.GetToken());
 
  public object ARepeatPaymentIsSent(PaymentType payload) =>
    return _client.Post<object>("/api/fpsmessage/repeat-request", payload, _tokenService.GetToken());
}

Registering dependencies

From here I created a new Service Registrar class, which would be responsible for registering all of these dependencies using the .NET DI framework.

Having already inverted the dependencies for each Step class, I was able to identify a common set of objects to register with the IoC container.

using Microsoft.Extensions.DependencyInjection;
 
public class ServiceRegistrar
{
  private readonly IServiceCollection _serviceCollection;
 
  public ServiceRegistrar(IServiceCollection serviceCollection) =>
    _serviceCollection = serviceCollection;
 
  public IServiceProvider Register()
  {
    _serviceCollection.AddSingleton<IRestClient>(_ => new RestClient(AppSettings.BaseUrl));
    _serviceCollection.AddSingleton<AuditService>();
    _serviceCollection.AddSingleton<TokenService>();
    _serviceCollection.AddSingleton<PaymentsService>();
    _serviceCollection.AddSingleton<WebhookService>();
 
    return _serviceCollection.BuildServiceProvider();
  }
}

I was then able to register each of the Steps classes.

_serviceCollection.AddSingleton<AccountSteps>();
_serviceCollection.AddSingleton<AuditSteps>();
_serviceCollection.AddSingleton<PaymentsSteps>();

One further dependency I needed to register was a test context object, which share data between the different classes. Read more about good practices with test context - The scourge of the test context

using Microsoft.Extensions.DependencyInjection;
 
public class ServiceRegistrar
{
  private readonly IServiceCollection _serviceCollection;
 
  public ServiceRegistrar(IServiceCollection serviceCollection) =>
    _serviceCollection = serviceCollection;
 
  public IServiceProvider Register()
  {
    _serviceCollection.AddSingleton<TestScenarioContext>();
 
    _serviceCollection.AddSingleton<IRestClient>(_ => new RestClient(AppSettings.BaseUrl));
    _serviceCollection.AddSingleton<AuditService>();
    _serviceCollection.AddSingleton<TokenService>();
    _serviceCollection.AddSingleton<PaymentsService>();
    _serviceCollection.AddSingleton<WebhookService>();
 
    _serviceCollection.AddSingleton<AccountSteps>();
    _serviceCollection.AddSingleton<AuditSteps>();
    _serviceCollection.AddSingleton<PaymentsSteps>();
 
    return _serviceCollection.BuildServiceProvider();
  }
}

Resolving dependencies

The final step was to instantiate the Service Registrar from the test setup. The ServiceCollection could then be used to resolve the specific Steps classes required for each test.

using BDTest.Test;
using Microsoft.Extensions.DependencyInjection;
using System.Threading.Tasks;
using Xunit;
 
public class PaymentsTests
{
  private readonly AccountSteps _accounts;
  private readonly PaymentsSteps _payments;
 
  public PaymentsTests()
  {
    var services = new ServiceRegistrar(new ServiceCollection()).Register();
    _accounts = services.GetService<AccountSteps>();
    _payments = services.GetService<PaymentsSteps>();
  }
 
  [Fact]
  public async Task Outbound_payment_can_be_sent()
  {
    await Given(() => _accounts.ACurrentAccount())
    .When(() => _payments.APaymentIsSent())
    .Then(() => _accounts.TheAccountIsDebited())
    .BDTestAsync();
  }
}

In some places, the dependencies registered with the IoC container had further dependencies themselves. For these I was able to rinse and repeat the same process. Invert the dependencies, inject them into the constructor and register them with the DI framework.

Original post: https://syrett.blog/dependency-injection-vaccinate-your-tests-from-messy-code/

Neil Syrett

QA SDET, ClearBank