The book xUnit Test Patterns defines a Test Context as “everything a system under test (SUT) needs to have in place in order to exercise it for the purpose of verifying its behavior.” While this could be taken to mean the actual test fixture itself in whatever our particular testing tool of choice happens to be, it’s more useful as application developers implementing a feature test to apply the label to a helper object.
Let’s look at an example. Here is a test suite that verifies we can import lab results for medical tests that were ordered for a patient. It has two potential facets that can introduce pain into our tests. Read it over, and then we’ll look at what those two facets are.
class LabResultImporterTests
{
public void Should_log_error_when_patient_test_result_cannot_be_imported()
{
// arrange
var patient = new PatientBuilder.Build("ABC");
var patientChart = new ChartBuilder.Build(patient);
var patientLabTest = new LabTestBuilder.Build("Blood sugar test", patientChart.Id);
var labBatch = new LabBatchBuilder.Build("20200827");
var labOrder = labBatch.Orders.Add(patientLabTest);
labOrder.Result.Add("bad test result value to throw error");
SaveToDatabase(patient, patientLabTest, labBatch);
// act
var fakeLogger = new FakeLogger();
var labResultEndpoint = new FakeEndpoint(fakeLogger);
var labResultImporter = new LabResultImporter(labResultEndpoint);
var result = labResultImporter.Import(labBatch.Id);
// assert
result.ImportSucceeded.ShouldBe(false);
fakeLogger.LoggedMessages.Count.ShouldBe(1);
}
public void Should_import_a_lab_batch_with_blood_sugar_test_results()
{
// arrange
var patient = new PatientBuilder.Build("ABC");
var patientChart = new ChartBuilder.Build(patient);
var patientLabTest = new LabTestBuilder.Build("Blood sugar test", patientChart.Id);
var labBatch = new LabBatchBuilder.Build("20200827");
var labOrder = labBatch.Orders.Add(patientLabTest);
labOrder.Result.Add("131 mg/dL");
SaveToDatabase(patient, patientLabTest, labBatch);
// act
var fakeLogger = new FakeLogger();
var labResultEndpoint = new FakeEndpoint(fakeLogger);
var labResultImporter = new LabResultImporter(labResultEndpoint);
var result = labResultImporter.Import(labBatch.Id);
// assert
result.ImportSucceeded.ShouldBe(true);
fakeLogger.LoggedMessages.Count.ShouldBe(0);
var patientLabResult = LoadPatientTestResults(patient.Id);
patientLabResult.BloodSugar.ShouldBe("131 mg/dL");
}
}
Dealing with tangential dependencies
Potential pain point number one should be familiar: The importer is reliant on a communication endpoint and logging implementation. If those infrastructure pieces change, all of our lab importer tests will become broken. We know how to solve this problem with builders.
Potential pain point number two is a little more subtle. The importer we are testing operates on a lab batch. Lab batches are composed of lab orders. Lab orders don’t exist in a vacuum, though, they are associated with a patient’s chart (which is itself associated with a patient). We don’t actively assert anything about the patient chart in our tests, but we have to have it to create an order.
Here, we’re facing the same duplicated setup logic problem, raising the specter of having to update multiple tests when those setup needs change. We can’t just relocate this lab result setup that is specific to these importer tests into the patient chart builder though—that decreases the cohesion of the builder, which would then be forced into supporting two unrelated concerns.
Refactoring to a Test Context
A better restructuring is to introduce a new Test Context object that holds references to all of the input objects involved in our test and write a new method to centralize building up an instance.
class LabResultImporterContextTests
{
public void Should_log_error_when_patient_test_result_cannot_be_imported()
{
// arrange
var context = BuildHappyPathTestContext();
context.LabTest.Orders[0].Result = "bad test result value to throw error";
SaveToDatabase(context.Patient, context.LabTest, context.LabBatch);
// act
var result = RunImport(context);
// assert
result.ImportSucceeded.ShouldBe(false);
context.Logger.LoggedMessages.Count.ShouldBe(1);
}
public void Should_import_a_lab_batch_with_blood_sugar_test_results()
{
// arrange
var context = BuildHappyPathTestContext();
SaveToDatabase(context.Patient, context.LabTest, context.LabBatch);
// act
var result = RunImport(context);
// assert
result.ImportResult.ImportSucceeded.ShouldBe(true);
context.Logger.LoggedMessages.Count.ShouldBe(0);
var patientLabResult = LoadPatientTestResults(context.Patient.Id);
patientLabResult.BloodSugar.ShouldBe("131 mg/dL");
}
public TestContext BuildHappyPathTestContext()
{
var patient = new PatientBuilder.Build("ABC");
var patientChart = new ChartBuilder.Build(patient);
var patientLabTest = new LabTestBuilder.Build("Blood sugar test", patientChart.Id);
var labBatch = new LabBatchBuilder.Build("20200827");
var labOrder = labBatch.Orders.Add(patientLabTest);
labOrder.Result.Add("131 mg/dL");
return new TestContext()
{
Patient = patient,
PatientLabTest = patientLabTest,
LabBatch = labBatch,
Logger = new FakeLogger()
};
}
public ImportResult RunImport(TestContext context)
{
var labResultEndpoint = new FakeEndpoint(context.Logger);
var labResultImporter = new LabResultImporter(labResultEndpoint);
return labResultImporter.Import(context.LabBatch.Id);
}
}
public class TestContext
{
public Patient Patient { get; set; }
public PatientLabTest LabTest { get; set; }
public LabBatch LabBatch { get; set; }
public FakeLogger Logger { get; set; }
}
This shares some similarities with the builder pattern, but is different enough to warrant its own name. While we do have a “builder method” creating our Test Context, the pattern’s purpose isn’t just to encapsulate the instantiation of a sole object. The Test Context is the single point of reference for all of the objects involved in this particular functionality. The payoff for adding the extra TextContext class and BuildHappyPathTestContext method is in completely removing the tangential setup concerns from the individual tests. When future adjustments happen to patients or patient charts, only our central BuildHappyPathTestContext method will be affected.
Avoiding potential pitfalls
While the Test context pattern is helpful in taming multiple aspects, it is extra code that we have to add and maintain, so we should make sure we realize the benefits. The key “code smell” of unnecessary usage is when the context object has properties with no verification checks against them. It’s pointless to shuttle around unused references in memory, and implies to future code readers that something is more important to our test than it really is. Another possible red flag is if you find yourself wanting to reuse Test Contexts between different tests. A well-designed Test Context meets the complete needs of a single test suite—it’s not intended to be reusable. If you have a Test Context that is reusable, you probably have a builder in disguise.
Summing up
Occasionally the builder pattern by itself may not be enough for us to have well structured tests that are easy to read and protected from design changes. Test contexts are a specialized technique that can help with unique situations when our feature is dependent upon either other features or the application infrastructure. When builders alone aren’t enough, you can use a test context to unify all of your SUT’s unrelated but necessary dependencies to help maintain the readability and decoupling aspects of your tests.
Resources
XUnit Test Patterns. 2007. Gerard Meszaros.