Moving from unit test to integration/system test
This post is not directly about Blazor but I wanted to blog about this technical change.
On my Toss project I decided to move from a Unit Test approach to an Integration test approach. In this blog post I’ll try to explain the reasons and the technique employed.
How I build my unit test
I build my unit test this way : a method (unit) has input (parameters and dependencies) and output (return, dependencies and exceptions).
A unit test will “arrange” the input :
- the parameters are forced
- the dependencies are setup via a mocking framework (Setup() in Moq) And “assert” the output :
- the expected return and exceptions are checked
- the dependencies are validated via the mocking framework (Verify() in Moq)
In theory this is perfect :
- only production code is executed at test assert, I don’t depend on other systems.
- if one test fails, the reason will be easy to find.
- every piece of code will be writen following the red/green process (create test and add code until tests passes).
Problems with unit tests
This approach has drawbacks that lead me to getting rid of it :
- Because of the mandatory mocking/faking of dependencies, you are not testing an interface but an implementation. And a method is the lower block of implementation. Every time I’ll do a small change in my implementation (for instance I change a loop on “SaveOne” for a call to “SaveAll”) I will need to change my tests.
- Setting up everything is a lot of code, look at this file I had to create to mock the ASPNET Core Identity dependencies. If I have the simple formula “1 LoC = X bugs”, we can say that I will spend more time debugging my tests (and that’s what happened) than my actual code !
- Because you are not testing everything altogether you can have problem at runtime : you didn’t setup DI the right way, because your class doesn’t implement the good interface, your configuration is not set …
Technical solution
The solution is system test, we could call it integration test but for some of them there might be no dependency involved. Here I want to test my system as a whole.
Use the ASPNET Core DI setup
Here is my class setting up this :
public class TestFixture
{
public const string DataBaseName = "Tests";
public const string UserName = "username";
private static ServiceProvider _provider;
//only mock we need :)
private static Mock<IHttpContextAccessor> _httpContextAccessor;
public static ClaimsPrincipal ClaimPrincipal { get; set; }
static TestFixture()
{
var dict = new Dictionary<string, string>
{
{ "GoogleClientId", ""},
{ "GoogleClientSecret", ""},
{ "MailJetApiKey", ""},
{ "MailJetApiSecret", ""},
{ "MailJetSender", ""},
{ "CosmosDBEndpoint", "https://localhost:8081"},
{ "CosmosDBKey", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="},
{ "StripeSecretKey", ""},
{"test","true" },
{"dataBaseName",DataBaseName }
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(dict)
.Build();
var startup = new Startup(config);
var services = new ServiceCollection();
startup.ConfigureServices(services);
_httpContextAccessor = new Mock<IHttpContextAccessor>();
services.AddSingleton(_httpContextAccessor.Object);
services.AddScoped(typeof(ILoggerFactory), typeof(LoggerFactory));
services.AddScoped(typeof(ILogger<>), typeof(Logger<>));
_provider = services.BuildServiceProvider();
}
public async static Task CreateTestUser()
{
var userManager = _provider.GetService<UserManager<ApplicationUser>>();
ApplicationUser user = new ApplicationUser()
{
UserName = UserName,
Email = "test@yopmail.com",
EmailConfirmed = true
};
await userManager.CreateAsync(user);
ClaimPrincipal = new ClaimsPrincipal(
new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.Name, UserName)
},
"Basic"));
(ClaimPrincipal.Identity as ClaimsIdentity).AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
_httpContextAccessor
.SetupGet(h => h.HttpContext)
.Returns(() =>
new DefaultHttpContext()
{
User = ClaimPrincipal
});
}
public static void SetControllerContext(Controller controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = _httpContextAccessor.Object.HttpContext
};
}
public static void SetControllerContext(ControllerBase controller)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = _httpContextAccessor.Object.HttpContext
};
}
public static T GetInstance<T>()
{
T result = _provider.GetRequiredService<T>();
ControllerBase controllerBase = result as ControllerBase;
if (controllerBase != null)
{
SetControllerContext(controllerBase);
}
Controller controller = result as Controller;
if (controller != null)
{
SetControllerContext(controller);
}
return result;
}
}
- I have to mock the HttpContextAccessor as there is no Http Query and I need it for knowing who is the connected user
- I pass “test” “true” to the config so I can setup my fake/mock in Configure()
- I had to force the logger DI setup, I guess it’s set by something in ConfigureService
Choosing the tested layer
I chose to test at the mediator layer (from MediatR), my controller layer is very thin so I prefer to test my app here.
My test setup are really simple, they are basically like this :
var mediator = TestFixture.GetInstance<IMediator>();
mediator.Send(new MyCommand());
var res = mediator.Send(new MyQuery());
Assert.Single(res);
- This test will uses both the command and query so with this 4 LoC I test a lot of code : DI setup, interface declaration, interface implementation …
External dependencies
I still need to mock some dependencies that I don’t manage : Stripe or MailJet or even Random. Here is how I setup the fake
// Add application services.
if (Configuration.GetValue<string>("test") == null)
{
services.AddTransient<IEmailSender, EmailSender>();
services.AddSingleton<IStripeClient, StripeClient>(s => new StripeClient(Configuration.GetValue<string>("StripeSecretKey")));
}
else
{
//We had it as singleton so we can get the content later during the asset phase
services.AddSingleton<IEmailSender, FakeEmailSender>();
services.AddSingleton<IStripeClient, FakeStripeClient>();
}
- The fake code are very simple (you can find it in my Toss repo) they just record the received parameters and have a static property for giving the next expected result
Internal dependencies
I call internal dependencies, dependencies that I manage entirely like CosmosDB. CosmosDB doesn’t support transaction with multiple client request like SQL Server(you have to create a server side sp for using transactions) so I have to clean up the database after each tests. Here is my base class for doing this :
public class BaseCosmosTest : IAsyncLifetime
{
public BaseCosmosTest()
{
}
public async Task InitializeAsync()
{
}
public async Task DisposeAsync()
{
var _client = TestFixture.GetInstance<DocumentClient>();
var collections = _client.CreateDocumentCollectionQuery(UriFactory.CreateDatabaseUri(TestFixture.DataBaseName)).ToList();
foreach (var item in collections)
{
var docs = _client.CreateDocumentQuery(item.SelfLink);
foreach (var doc in docs)
{
await _client.DeleteDocumentAsync(doc.SelfLink);
}
}
}
}
- I only remove the document not the collections, so my test will run faster
- IAsyncLifetime is here for having an async Dispose method
- I cannot clean the DB before the test as the DB will not be created until the Startup.Configure method is called
- I need to force the test to run non parallel (don’t know if this term is correct in english but you get my point) as they all use the same DB/Collections here is the xunit.runner.json needed (you need to Copy it in output directory) :
{
"parallelizeTestCollections": false
}
New problems
There is of course drawbacks in this way of testing :
- A test can fail for many reason, so in the long term debugging failing test my be more difficult than with a unit test approach.
- You have to be able to clean all the dependencies between each test, if not they will fall in the external category. This mean writing “test only” code.
- The test will take longer to run.
- I don’t test as much as I want : route, controller … I could do only E2E test but they are too much pain to create, so I only have a few of them.