Using Context/Specification to better express complicated tests

I’m trying to help one of our teams at work that constantly modifies a very large, very complex, 12-15 year old managed workflow system. Like many shops, we’re working to improve our testing practices, and our developers are pretty diligent about adding tests for new code.

Great, but the next step in my opinion is to adopt some different approaches for structuring the code to make unit testing easier and lead toward smaller, more focused unit tests when possible (see my post on a Real Life TDD Example for some of my thinking on that).

All that being said, it’s a very complicated system with data elements out the wazoo that coordinates work across a bevy of internal and external services. Sometimes there’s a single operation that necessarily does a lot of things in one unit of work (almost inevitably an NServiceBus message handler in this case) like:

  • Changing state in business entities based on incoming commands — and the system will frequently change more than one entity at a time
  • Sending out additional command or event messages based on the inputs and existing state of the system

To deal with the complexity of testing these kinds of message handlers, I’m suggesting that we dust off the old BDD-ish “Context/Specification” style of tests. If you think of automated tests generally following some sort of arrange/act/assertion structure, the Context/Specification style in an OO language is going to follow this structure:

  1. A class with a name that describes the scenario being tested
  2. A single scenario set up that performs both the “arrange” and “act” parts of the logical test group
  3. Multiple, granular tests with descriptive names that make a single, logical assertion against the expectations of the desired behavior

Jumping into a simple example, here’s a test class from the built in Open Telemetry instrumentation in Wolverine:

public class when_creating_an_execution_activity
{
    private readonly Activity theActivity;
    private readonly Envelope theEnvelope;

    public when_creating_an_execution_activity()
    {
        // In BDD terms....
        // Given a message envelope
        // When creating a new Otel activity for processing a message
        // Then the activity uses the envelope conversation id as the otel messaging conversation id
        // And [a bunch of other things]
        theEnvelope = ObjectMother.Envelope();
        theEnvelope.ConversationId = Guid.NewGuid();

        theEnvelope.MessageType = "FooMessage";
        theEnvelope.CorrelationId = Guid.NewGuid().ToString();
        theEnvelope.Destination = new Uri("tcp://localhost:6666");

        theActivity = new Activity("process");
        theEnvelope.WriteTags(theActivity);
    }

    [Fact]
    public void should_set_the_otel_conversation_id_to_correlation_id()
    {
        theActivity.GetTagItem(WolverineTracing.MessagingConversationId)
            .ShouldBe(theEnvelope.ConversationId);
    }

    [Fact]
    public void tags_the_message_id()
    {
        theActivity.GetTagItem(WolverineTracing.MessagingMessageId)
            .ShouldBe(theEnvelope.Id);
    }

    [Fact]
    public void sets_the_message_system_to_destination_uri_scheme()
    {
        theActivity.GetTagItem(WolverineTracing.MessagingSystem)
            .ShouldBe("tcp");
    }

    [Fact]
    public void sets_the_message_type_name()
    {
        theActivity.GetTagItem(WolverineTracing.MessageType)
            .ShouldBe(theEnvelope.MessageType);
    }

    [Fact]
    public void the_destination_should_be_the_envelope_destination()
    {
        theActivity.GetTagItem(WolverineTracing.MessagingDestination)
            .ShouldBe(theEnvelope.Destination);
    }

    [Fact]
    public void should_set_the_payload_size_bytes_when_it_exists()
    {
        theActivity.GetTagItem(WolverineTracing.PayloadSizeBytes)
            .ShouldBe(theEnvelope.Data!.Length);
    }

    [Fact]
    public void trace_the_conversation_id()
    {
        theActivity.GetTagItem(WolverineTracing.MessagingConversationId)
            .ShouldBe(theEnvelope.ConversationId);
    }
}

In the case above, the constructor is doing the “arrange” and “act” part of the group of tests, but each individual [Fact] is a logical assertion on the expected outcomes.

Here’s some takeaways from this style and when and where it might be useful:

  • It’s long been a truism that unit tests should have a single logical assertion. That’s just a rule of thumb, but I still find it to be useful in making tests readable and “digestable”
  • With that testing style, I find it easier to work on one assertion at a time in a red/green/refactor cycle than it can be to specify all the related assertions in one bigger test
  • Arguably, that style can at least sometimes do a much better job of making the tests act as useful documentation about how the system should behave than more monolithic tests
  • This style doesn’t require the usage of specialized Gherkin style tools, but at some point when you’re dealing with data intensive tests a Gherkin-based tool becomes much more attractive
  • This style is verbose, and it’s not my default test structure for everything by any means

For structure or grouping, you might structure these tests like:

// Some people like to use the other class to group the tests
// in IDE test runners. It's not necessary, but it might be
// advantageous
public class SomeHandlerSpecs
{
    // A single scenario
    public class when_some_description_of_the_specific_scenario1
    {
        public when_some_description_of_the_specific_scenario1()
        {
            // shared context setup
            // the logical "arrange" and "act"
        }

        [Fact]
        public void then_some_kind_of_descriptive_name_for_a_single_logical_assertion()
        {
            // do an assertion
        }
        
        [Fact]
        public void then_some_kind_of_descriptive_name_for_a_single_logical_assertion_2()
        {
            // do an assertion
        }
    }
    
    // A second scenario
    public class when_some_description_of_the_second_scenario1
    {
        public when_some_description_of_the_second_scenario1()
        {
            // shared context setup
            // the logical "arrange" and "act"
        }

        [Fact]
        public void then_some_kind_of_descriptive_name_for_a_single_logical_assertion()
        {
            // do an assertion
        }
        
        [Fact]
        public void then_some_kind_of_descriptive_name_for_a_single_logical_assertion_2()
        {
            // do an assertion
        }
    }
}

Admittedly, I frequently end up doing quite a bit of copy/paste between different scenarios when I use this style. I’m going to say that’s mostly okay because test code should be optimized for readability rather than for eliminating duplication as we would in production code (see the discussion about DAMP vs DRY in this post for more context).

To be honest, I couldn’t remember what this style of test was even called until I spent some time googling for better examples today. I remember this being a major topic of discussion in the late 00’s, but not really since. I think it’s maybe a shame that Behavior Driven Development (BDD) became too synonymous with Cucumber tooling, because there was definitely some very useful thinking going on with BDD approaches. Way too many “how many Angels can dance on the head of a pin” arguments too of course too though.

Here’s an old talk from Philip Japikse that’s the best resource I could find this morning on this idea.

3 thoughts on “Using Context/Specification to better express complicated tests

  1. “Complicated” tests are quite often integration tests. Integration tests most likely will have dependencies (db, queue) that add a time dimension to the arrange & act steps. This means that it might take some time to setup and go through the “act” stage. And for 10 tests it will take 10x time (at least for XUnit, where the constructor runs for every test).

    Do you see how “context/specification” style can be applied to such resource and time intensive integration tests?

    Some of the logic (like setup) can be moved to a dedicated fixture (in XUnit terms) and reused, but, as you mentioned, this will hinder readability of the context/specification.

    These complications keep us doing act & arrange of the integration scenarios in every test, but we constrain multiple assertions for a single “logical” context.

Leave a comment