Evolving a private helper method, guided by tests.

A frequently asked question about unit testing and test-driven development (TDD) is how to test private helper methods. I've already attempted to answer that question: through the public API, but a recent comment to a Stack Overflow question made me realise that I've failed to supply a code example.

Show, don't tell.

In this article I'll show a code example that outlines how a private helper method can evolve under TDD.

Threshold #

The code example in this article comes from my book Code That Fits in Your Head. When you buy the book, you get not only the finished code examples, but the entire Git repository, with detailed commit messages.

A central part of the code base is a method that decides whether or not to accept a reservation attempt. It's essentially a solution to the MaƮtre d' kata. I wrote most of the book's code with TDD, and after commit fa12fd69c158168178f3a75bcd900e5caa7e7dec I decided that I ought to refactor the implementation. As I wrote in the commit message:

Filter later reservations based on date

The line count of the willAccept method has now risen to 28. Cyclomatic
complexity is still at 7. It's ripe for refactoring.

I think, by the way, that I made a small mistake. As far as I can tell, the WillAccept line count in this commit is 26 - not 28:

public bool WillAccept(
    IEnumerable<Reservation> existingReservations,
    Reservation candidate)
{
    if (existingReservations is null)
        throw new ArgumentNullException(nameof(existingReservations));
    if (candidate is null)
        throw new ArgumentNullException(nameof(candidate));
 
    var relevantReservations = existingReservations
        .Where(r => candidate.At.Date == r.At.Date);
 
    List<Table> availableTables = Tables.ToList();
    foreach (var r in relevantReservations)
    {
        var table = availableTables.Find(t => r.Quantity <= t.Seats);
        if (table is { })
        {
            availableTables.Remove(table);
            if (table.IsCommunal)
                availableTables.Add(table.Reserve(r.Quantity));
        }
    }
 
    return availableTables.Any(t => candidate.Quantity <= t.Seats);
}

Still, I knew that it wasn't done - that I'd be adding more tests that would increase both the size and complexity of the method. It was brushing against more than one threshold. I decided that it was time for a prophylactic refactoring.

Notice that the red-green-refactor checklist explicitly states that refactoring is part of the process. It doesn't, however, mandate that refactoring must be done in the same commit as the green phase. Here, I did red-green-commit-refactor-commit.

While I decided to refactor, I also knew that I still had some way to go before WillAccept would be complete. With the code still in flux, I didn't want to couple tests to a new method, so I chose to extract a private helper method.

Helper method #

After the refactoring, the code looked like this:

public bool WillAccept(
    IEnumerable<Reservation> existingReservations,
    Reservation candidate)
{
    if (existingReservations is null)
        throw new ArgumentNullException(nameof(existingReservations));
    if (candidate is null)
        throw new ArgumentNullException(nameof(candidate));
 
    var relevantReservations = existingReservations
        .Where(r => candidate.At.Date == r.At.Date);
 
    var availableTables = Allocate(relevantReservations);
 
    return availableTables.Any(t => candidate.Quantity <= t.Seats);
}
 
private IEnumerable<Table> Allocate(
    IEnumerable<Reservation> reservations)
{
    List<Table> availableTables = Tables.ToList();
    foreach (var r in reservations)
    {
        var table = availableTables.Find(t => r.Quantity <= t.Seats);
        if (table is { })
        {
            availableTables.Remove(table);
            if (table.IsCommunal)
                availableTables.Add(table.Reserve(r.Quantity));
        }
    }
 
    return availableTables;
}

I committed the change, and wrote in the commit message:

Extract helper method from WillAccept

This quite improves the complexity of the method, which is now 4, and at
18 lines of code. The new helper method also has a cyclomatic complexity
of 4, and 17 lines of code.

A remaining issue with the WillAccept method is that the code operates
on different levels of abstraction. The call to Allocate represents an
abstraction, while the filter on date is as low-level as it can get.

As you can tell, I was well aware that there were remaining issues with the code.

Since the new Allocate helper method is private, unit tests can't reach it directly. It's still covered by tests, though, just as that code block was before I extracted it.

More tests #

I wasn't done with the WillAccept method, and after a bout of other refactorings, I added more test cases covering it.

While the method ultimately grew to exhibit moderately complex behaviour, I had only two test methods covering it: one (not shown) for the rejection case, and another for the accept (true) case:

[Theory, ClassData(typeof(AcceptTestCases))]
public void Accept(
    TimeSpan seatingDuration,
    IEnumerable<Table> tables,
    IEnumerable<Reservation> reservations)
{
    var sut = new MaitreD(seatingDuration, tables);
 
    var r = Some.Reservation.WithQuantity(11);
    var actual = sut.WillAccept(reservations, r);
 
    Assert.True(actual);
}

I based the example code on the impureim sandwich architecture, which meant that domain logic (including the WillAccept method) is all pure functions. The nice thing about pure functions is that they're easy to unit test.

The Accept test method uses an object data source (see the article Parametrised test primitive obsession code smell for another example of the motivation behind using objects for test parametrisation), so adding more test cases were simply a matter of adding them to the data source:

Add(TimeSpan.FromHours(6),
    new[] { Table.Communal(11) },
    new[] { Some.Reservation.WithQuantity(11).TheDayAfter() });
Add(TimeSpan.FromHours(2.5),
    new[] { Table.Standard(12) },
    new[] { Some.Reservation.WithQuantity(11).AddDate(
        TimeSpan.FromHours(-2.5)) });
Add(TimeSpan.FromHours(1),
    new[] { Table.Standard(14) },
    new[] { Some.Reservation.WithQuantity(9).AddDate(
        TimeSpan.FromHours(1)) });

The bottom two test cases are new additions. In that way, by adding new test cases, I could keep evolving WillAccept and its various private helper methods (of which I added more). While no tests directly exercise the private helper methods, the unit tests still transitively exercise the private parts of the code base.

Since I followed TDD, no private helper methods sprang into existence untested. I didn't have to jump through hoops in order to be able to unit test private helper methods. Rather, the private helper methods were a natural by-product of the red-green-refactor process - particularly, the refactor phase.

Conclusion #

Following TDD doesn't preclude the creation of private helper methods. In fact, private helper methods can (and should?) emerge during the refactoring phase of the red-green-refactoring cycle.

For long-time practitioners of TDD, there's nothing new in this, but people new to TDD are still learning. This question keeps coming up, so I hope that this example is useful.



Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.

Published

Monday, 13 September 2021 05:25:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 13 September 2021 05:25:00 UTC