DEV Community

Steven Lemon
Steven Lemon

Posted on

3 Patterns for Reducing Duplication in Your Unit Tests

Our team used to have a lot of difficulties with our unit tests. They were slow to write, slow to run and time-consuming to maintain. The tests were fragile and prone to breaking. Small changes to our code could lead to hours fixing tests all across our entire suite. The tests were inconsistently designed and required many different approaches to fix.

Our unit tests had become such a hassle that when developing new features, we were spending more time fixing up existing tests than we spent creating new tests.

Realizing that we needed to turn this around, and after some investigation, we determined that the primary cause of our troubles was code duplication. Our tests were poorly structured and too concerned with creating the same objects over and over again. We researched, discussed and experimented and settled on three patterns to help us improve our unit test setup: the Object Mother, Test Class Builder and Test Fixture.

The following example demonstrates the difference these patterns made to our unit tests.

// Before
public void AddToCart_AddingMultipleItems_TotalPriceIsCorrect() 
{
    var customer = new Customer("customerId", LoyaltyStatus.None);

    var product1 = new Product("id1", 5.00m, new Size("id", "name"), new Colour("id", "name"));
    var product2 = new Product("id2", 5.00m, new Size("id", "name"), new Colour("id", "name"));

    var discountService = new Mock<IDiscountService>();
    discountService.Setup(x => x.GetDiscounts(It.IsAny<>())).Returns(null);

    var cartRepository = new Mock<ICartRepository>();
    cartRepository.Setup(x => x.AddProductToCart(It.IsAny<>())).Returns(null);
    var cart = new Cart(customer, discountService.Object, 
        cartRepository.Object, new Mock<ILogManager>().Object);

    cart.AddProduct(product1);
    cart.AddProduct(product2);

    Assert.AreEqual(10.00m, cart.TotalPrice);
}

// After
public void AddToCart_AddingMultipleItems_TotalPriceIsCorrect() 
{
    var cart = new Fixture().GetSut();

    cart.AddProduct(new ProductBuilder().WithPrice(5.00m).Build());
    cart.AddProduct(new ProductBuilder().WithPrice(5.00m).Build());

    Assert.AreEqual(10.00m, cart.TotalPrice);
}

What was getting duplicated?

From our example above, we can identify the following three categories of objects whose creation was being duplicated across test cases. Each type fulfils a different role in our tests and correspondingly led to a separate creational pattern being adopted to assist its role.

  • Relevant objects - These objects contain at least one property that is relevant to the test scenario. In our example, we want to specify each product's price within the test so that the verification is evident to the reader.
  • Irrelevant objects - These objects have no bearing on the test; however, we often end up needing to create them as they are required parameters to construct relevant objects or call methods. Examples in the above test would be the Customer, Size and Colour objects.
  • The system under test, often abbreviated to the "SUT", is the class that we are testing. In our example, this is the Cart class, and much of the test method is spent constructing it and setting up its dependencies.

The problems with duplication in test methods

Duplication hides intent

Our example test is 11 lines, but only 3 of those lines are to do with our test scenario—adding two objects to the cart and asserting the total price. The other 8 lines are all spent setting up our test objects. These additional lines make it harder to find meaningful lines amongst the noise. Further, duplication makes it difficult to determine the difference between the tests in the file. It is easy to have as little as a one-character difference between two test's setup phases result in opposite outcomes in the verify phase. You want to make it easy for future maintainers to figure out why some tests are passing while others are failing.

If you are creating your objects within your test cases, you aren't taking advantage of wrapping object creation in intent-revealing names. Which of the following is easier to understand?

var event = new Event("1", "2", "3", 4, null, null, DateTime.Now, "event");
// or 
var event = _eventMother.CreateEventWithoutStaffMember()

The tests become more concerned with creating objects than testing functionality

Each unit test should be testing a minimum isolated unit; keeping them simple, fast and reducing false positives. When your tests are responsible for constructing multiple required objects and their dependencies, they know too much. Each created object, dependency and 'new' keyword is a potential point of failure. Your tests may fail to compile or fail to run because of an unrelated change to a dependency. A small change might require hours updating tests across your entire test suite. While a feature is still in development, the tests undergo constant churn as your objects are in flux.

New tests are hard to write

Excessive duplication is indicative of test code that is lacking structure. Developers write tests by finding a similar test from which to copy-paste object creation and dependency setup. You end up spending more time thinking about how to set up your test than the case you are trying to test.

Duplicated code is prone to variation

As the test cases get updated over time, the code that started from copy-pasting between test methods starts to vary. Not only do future alterations need to be performed in too many places, but each of those locations requires a separate approach.

Pattern 1: Object Mother

The simplest of the three patterns, the Object Mother pattern is a collection of test-ready objects for typical scenarios your classes can be configured in.

// ObjectCreation/Mothers/CustomerMother.cs
static class CustomerMother {
    public ICustomer CreateCustomer() {
        return new Customer("firstName", "lastName");
    }

    public ICustomer CreateCustomerWithSilverLoyaltyStatus() {
        return new Customer("firstName", "lastName") {
            LoyaltyStatus = LoyaltyStatus.Silver
        };
    }
}

// Tests/CartTests.cs
[TestClass]
class CartTests {
    private CustomerMother _customerMother = new CustomerMother();

    [TestMethod]
    public void AddToCart_AddingMultipleItems_TotalPriceIsCorrect() {
        var customer = _customerMother.CreateCustomer();
        // ... snip ...
    }
}

When to use it

The Object Mother pattern has two usages: firstly, when we need an object as a required parameter, but its contents are irrelevant to our test, and secondly, when we want a variation of an object that can be simply described and doesn't require further customization. For example, _categoryMother.CreateCategoryWithSimpleDiscount().

Advantages

  • Provides a single location for creating objects across the test suite, promoting reuse.
  • Reduces the number of locations your tests construct objects.
  • Allows object creation to be moved behind intent-revealing names.
  • Indicates that the returned objects aren't significant to the test.
  • Allows you to name and reuse common or important edge cases.
  • Can be a form of documentation enumerating the possible ways that an object, or collection of objects, can be set up.

Nesting

Object mothers can be nested, allowing you to set up more complicated scenarios. In this example, we want a category with a discount, but we still aren't concerned about the contents of that discount.

// ObjectCreation/Mothers/DiscountMother.cs
static class DiscountMother {
    public IDiscount CreateDiscount() {
        return new Discount("discountId", "name");
    }
}

// ObjectCreation/Mothers/CategoryMother.cs
static class CategoryMother {
    public IDiscount CreateCategory() {
        return new Category("categoryId", "name");
    }

    public IDiscount CreateCategoryWithSimpleDiscount() {
        var discountMother = new DicountMother();
        return new Category("categoryId", "name") {
            Discount = discountMother.CreateDiscount();
        }
    }
}

Pattern 2: Test Object Builder

Test Object Builders are responsible for building instances of the object with default properties while allowing relevant properties to be overridden using a fluent syntax. They have similarities to the builder pattern, except that they also provide a default value for every property.

// ObjectCreation/Builders/ProductBuilder.cs
static class ProductBuilder {
    private decimal _price;
    private string _categoryId;

    public ProductBuilder WithPrice(decimal price) {
        _price = price;
        return this;
    }

    public ProductBuilder WithCategoryId(string categoryId) {
        _categoryId = categoryId;
        return this;
    }

    public Product Build() {
        return new Product(
            "id1", 
            _price ?? 5.00m,
            _categoryId ?? "categoryId",
            new Size("id", "name"), 
            new Colour("id", "name")
        );
    }
}

// Tests/CartTests.cs
[TestClass]
class CartTests {
    [TestMethod]
    public void AddToCart_AddingMultipleItems_TotalPriceIsCorrect() {
        // ... snip ...
        cart.AddProduct(new ProductBuilder().WithPrice(5.00m).Build());
        cart.AddProduct(new ProductBuilder().WithPrice(5.00m).Build());
        // ... snip ...
    }
}

When to use it

Like the Object Mother, the Test Object Builder is for creating simple objects. However, the Test Object Builder pattern is better suited for when you want to set properties on the resultant object.

In our cart example, we want to specify the price of each item in the test, rather than have it hidden away in a helper method. Having the value specified makes the test scenario clear to the reader, allowing them to follow along 5.00 + 5.00 = 10.00. The Test Object Builder pattern allows us to specify just the price, without having to configure any of the other properties which aren't relevant to what we are testing.

Advantages

  • Makes it clear what properties of the object are meaningful to the test, clarifying intent.
  • Hides properties of the object that aren't relevant.
  • Provides a single location for creating each object across all tests, promoting reuse.
  • Can start simple and be extended as you need to customize additional properties, without affecting existing test classes.

Object Mother vs TestObjectBuilder?

On the face of it, these two patterns are very similar; both are for creating simple objects and moving that creation to a single location. The difference lies in how much you need to customize the objects you are creating. For example, _categoryMother.CreateCategoryWithId2AndParentId1() starts getting very verbose and means you need to start creating many Object Mother methods.

On the other hand, in straightforward cases, you could skip using _productMother.CreateProduct() and use new ProductBuilder.Build() directly. The Object Mother excels when you have multiple known setups, or setups involving multiple classes, though often it can be just a matter of personal taste.

Pattern 3: Test Fixture

The Test Fixture is a pattern for creating the class we are testing, and its dependencies. The pattern moves the setup into a private class and exposes methods to allow the tests to customize the dependencies.

In the following example, the creation of the cart class and mocks of its dependencies have shifted from the test method into the Fixture class.

// Tests/CartTests.cs
[TestClass]
class CartTests {
    private CustomerMother _customerMother = new CustomerMother();

    [TestMethod]
    public void AddToCart_WithApplicableDiscount_TotalPriceIsCorrect() {
        var product = new ProductBuilder().WithId("productId").WithPrice(5.00).Build()
        var appliedDiscount = new ProductDiscountBuilder.WithProductId("productId").WithFlatDiscount(1.00).Build();

        var fixture = new Fixture();
        fixture.WithGetDiscountResponse(new List<IDiscount> { appliedDiscount });

        Cart sut = fixture.GetSut();

        // ... snip ...
    }

    private class Fixture : BaseTestFixture<ICart> {
        private IList<IDiscount> _discountResponse = new Array.Empty<IDiscount>();

        public void WithGetDiscountResponse(IList<IDiscount> discountResponse) {
            _discountResponse = discountResponse;
        }

        public override ICart GetSut() {
            var discountService = new Mock<IDiscountService>();
            discountService.Setup(x => x.GetDiscounts(It.IsAny<>())).Returns(discountResponse);

            var cartRespository = new Mock<ICartRespository>();
            cartRepository.Setup(x => x.AddProductToCart(It.IsAny<>())).Returns(null);

            return new Cart(discountService.Object, cartRepository.Object, new Mock<ILogManager>().Object);
        }
    }
}

When to use it

For creating the class that each file's test methods are testing.

Advantages

  • Moves a significant source of duplicated code out of each test method.
  • All setup of the class under test is in a single location, so any changes to the constructor or dependencies only need to happen in one location.
  • Places modifications to the SUT's dependencies behind descriptive and meaningful phases/intent-revealing names.
  • The setup of common or complex dependencies can be shared by multiple fixtures by being placed in a base fixture class.

Disadvantages

All three patterns share the same disadvantage: they all introduce additional boilerplate and structure that takes more time to set up at first. Overall, however, the benefits they offer to maintainability are a net gain.

Despite the initial overhead of creating boilerplate, once our team adopted these patterns, we found that our tests became more straightforward to maintain and write. Consequently, we started writing more tests and spending less time debugging broken tests.

Top comments (1)

Collapse
 
jamesmh profile image
James Hickey

Very nice. Strange that this article hasn't had more likes? Perhaps....people aren't interested in improving their testing? 🙃

Thanks!