Write Clear Unit Tests in C# with Builder Methods and Method Chaining

Create Readable Unit Test Cases that PR Reviewers Will Thank You For

Introduction

Setting up unit test initial conditions can quickly become long, complex, and hard-to-read unless development teams adopt a consistent approach. This article explains why you should consider using builder methods in your unit tests, constructing more understandable business scenarios.

The Problem

Let’s assume we’re writing software for an online sandwich shop, allowing customers to choose their fillings and dressings to build up their ideal sandwich.

Create these enums to represent the kinds of fillings you can have:

    enum TomatoType
{
CHERRY,
ROMA
}
enum LettuceType
{
CRISPHEAD,
BUTTERHEAD,
ROMAINE,
LOOSELEAF
}
enum BaconType
{
REGULAR,
SMOKED
}
enum ChickenType
{
REGULAR,
BBQ
}
enum DressingType
{
RANCH,
MUSTARD,
SWEET_CHILLI,
THOUSAND_ISLAND
}

Create the following interfaces for each filling:

    interface ITomatoSlice
{
TomatoType Type { get; }
}
interface ILettuceLeaf
{
LettuceType Type { get; }
}
interface IBaconRasher
{
BaconType Type { get; }
}
interface IChickenPiece
{
ChickenType Type { get; }
}
interface IDressing
{
DressingType Type { get; }
}

And implement these interfaces in concrete classes:

    class TomatoSlice : ITomatoSlice
{
public TomatoSlice(TomatoType type)
{
Type = type;
}
TomatoType Type { get; }
}
class LettuceLeaf : ILettuceLeaf
{
public LettuceLeaf(LettuceType type)
{
Type = type;
}
public LettuceType Type { get; }
}
class BaconRasher : IBaconRasher
{
public BaconRasher(BaconType type)
{
Type = type;
}
public BaconType Type { get; }
}
class ChickenPiece : IChickenPiece
{
public ChickenPiece(ChickenType type)
{
Type = type;
}
public ChickenType Type { get; }
}
class Dressing : IDressing
{
public Dressing(DressingType type)
{
Type = type;
}
public DressingType Type { get; }
}

Create an ISandwich interface and concrete Sandwich class, which has one or more kinds of each filling:

    interface ISandwich
{
ICollection<ITomatoSlice> Tomato { get; }
ICollection<ILettuceLeaf> Lettuce { get; }
ICollection<IBaconRasher> Bacon { get; }
ICollection<IChickenPiece> Chicken { get; }
ICollection<IDressing> Dressings { get; }
}
class Sandwich : ISandwich
{
public Sandwich(
ICollection<ITomatoSlice> tomatoSlices,
ICollection<ILettuceLeaf> lettuceLeaves,
ICollection<IBaconRasher> baconRashers,
ICollection<IChickenPiece> chickenPieces,
ICollection<IDressing> dressings)
{
Tomato = tomatoSlices;
Lettuce = lettuceLeaves;
Bacon = baconRashers;
Chicken = chickenPieces;
Dressings = dressings;
}
public ICollection<ITomatoSlice> Tomato { get; }
public ICollection<ILettuceLeaf> Lettuce { get; }
public ICollection<IBaconRasher> Bacon { get; }
public ICollection<IChickenPiece> Chicken { get; }
public ICollection<IDressing> Dressings { get; }
}

(There’s further scope for improvement here, including using a generic IFilling collection instead of declaring all these properties — which might never be used — but let’s keep everything simple for demonstration purposes).

In our unit test suite, we want to write a test case to confirm that a customer has ordered a sandwich with the following fillings:

  • Two tomato slices (1 Cherry, 1 Roma)
  • Two lettuce leaves (1 Looseleaf, 1 Romaine)
  • One bacon rasher (Smoked)
  • One piece of chicken (BBQ)
  • One dressing (Sweet Chili)

Let’s now build a trivial unit test — consisting of arrange and act steps only — that confirms a new instance of Sandwich is set up according to our scenario conditions:

        [Test]
public void Sandwich_Test()
{
// arrange
var tomatoSlices = new List<ITomatoSlice>
{
new TomatoSlice(TomatoType.CHERRY),
new TomatoSlice(TomatoType.ROMA)
};
var lettuceLeaves = new List<ILettuceLeaf>
{
new LettuceLeaf(LettuceType.LOOSELEAF),
new LettuceLeaf(LettuceType.ROMAINE)
};
var baconRashers = new List<IBaconRasher>
{
new BaconRasher(BaconType.SMOKED)
};
var chickenPieces = new List<IChickenPiece>
{
new ChickenPiece(ChickenType.BBQ)
};
var dressings = new List<IDressing>
{
new Dressing(DressingType.SWEET_CHILLI)
};
var sandwich = new Sandwich(
tomatoSlices,
lettuceLeaves,
baconRashers,
chickenPieces,
dressings);
// assert
Assert.AreEqual(sandwich.Tomato.Count, 2);
Assert.AreEqual(
sandwich.Tomato.First().Type,
TomatoType.CHERRY);
Assert.AreEqual(
sandwich.Tomato.Last().Type,
TomatoType.ROMA);
Assert.AreEqual(sandwich.Lettuce.Count, 2);
Assert.AreEqual(
sandwich.Lettuce.First().Type,
LettuceType.LOOSELEAF);
Assert.AreEqual(
sandwich.Lettuce.Last().Type,
LettuceType.ROMAINE);
Assert.AreEqual(
sandwich.Bacon.Single().Type,
BaconType.SMOKED);
Assert.AreEqual(
sandwich.Chicken.Single().Type,
ChickenType.BBQ);
Assert.AreEqual(
sandwich.Dressings.Single().Type,
DressingType.SWEET_CHILLI);
}

Run the test case to show it passes:

This is a valid test, but its setup is cumbersome. As we go forward, we’ll need to do repetitive copying and pasting of these list instantiations to test different conditions.

And this was a simple case! Now, consider testing scenarios in a human resource application, where there are lots of sub-collections and relationships. It’s easy to see how Arrange blocks quickly become exhausting to comprehend.

We need developers to write consistent test setups that everybody understands— this is where builders come in.

The Solution

Consider using builder methods for setting up test conditions because they are:

  • Re-usable across unit test cases
  • Consistent, allowing developers to use the same builder methods
  • Less strenuous to read, making test cases easier to understand

Let’s re-write the sandwich class, this time setting all sandwich filler collections to an empty list:

    public class Sandwich : ISandwich
{
public Sandwich()
{
Tomato = new List<ITomatoSlice>();
Lettuce = new List<ILettuceLeaf>();
Bacon = new List<IBaconRasher>();
Chicken = new List<IChickenPiece>();
Dressings = new List<IDressing>();
}
public ICollection<ITomatoSlice> Tomato { get; }
public ICollection<ILettuceLeaf> Lettuce { get; }
public ICollection<IBaconRasher> Bacon { get; }
public ICollection<IChickenPiece> Chicken { get; }
public ICollection<IDressing> Dressings { get; }
}

Create a class called SandwichBuilder, containing a method called Build along with builder methods (with default argument assignments). The builder methods will add each filling to the sandwich and return the sandwich builder (this) to accommodate method chaining.

My code looks as follows:

    public class SandwichBuilder
{
private ISandwich sandwich = new Sandwich();
public SandwichBuilder WithTomato(
TomatoType type = TomatoType.CHERRY)
{
sandwich.Tomato.Add(new TomatoSlice(type));
return this;
}
public SandwichBuilder WithLettuce(
LettuceType type = LettuceType.LOOSELEAF)
{
sandwich.Lettuce.Add(new LettuceLeaf(type));
return this;
}
public SandwichBuilder WithBacon(
BaconType type = BaconType.REGULAR)
{
sandwich.Bacon.Add(new BaconRasher(type));
return this;
}
public SandwichBuilder WithChicken(
ChickenType type = ChickenType.REGULAR)
{
sandwich.Chicken.Add(new ChickenPiece(type));
return this;
}
public SandwichBuilder WithDressing(DressingType type)
{
sandwich.Dressings.Add(new Dressing(type));
return this;
}
public ISandwich Build()
{
return sandwich;
}
}

The test setup can now be simplified, reusing precisely the same Assert code as the previous example:

        [Test]
public void Sandwich_Test()
{
// arrange
var builder = new SandwichBuilder();
builder.WithTomato();
builder.WithTomato(TomatoType.ROMA);
builder.WithLettuce();
builder.WithLettuce(LettuceType.ROMAINE);
builder.WithBacon(BaconType.SMOKED);
builder.WithChicken(ChickenType.BBQ);
builder.WithDressing(DressingType.SWEET_CHILLI);
// act
var sandwich = builder.Build();
// assert
[...]
}

Although we’ve had to spend time creating a builder class, I think you’d agree that the test case is now much easier to read.

Run the same test case once move to confirm it passes:

Thanks for reading! Let me know what you think in the comments section below, and don’t forget to subscribe. 👍

George is a software engineer, author, blogger, and tech enthusiast who believes in helping others to make us happier and healthier.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store