In our last article, we looked at how the builder pattern increases the readability of our tests while simultaneously decoupling us from the construction details around the objects we’re testing. Sometimes this pattern by itself isn’t sufficient. Occasionally, we need to write code depending on a different feature area that isn’t directly part of what we’re doing, but has to exist in order for our feature to work. Having to create these other dependencies threatens to undo the readability and decoupling we achieved with a builder. For these complex scenarios, we can use the Test Context pattern.

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.

Without Test Context
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.

With Test Context
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.

Let's Talk

Have a tech-oriented question? We'd love to talk.