Boosting the Builder Pattern Using Bogus

February 14, 2024#Software Development
Article
Author image.

Kyle McMaster, Senior Consultant

This article is part of a series on leveling up your testing skills

Introduction 👋

In this entry in the series on leveling up your testing skills, we’ll take a look at a combination of patterns that I have been using that combines some of the lessons learned in part 1 and part 3. First, we’ll revisit the Builder pattern to create test data for a Data Transfer Object. Then, we’ll see how we can improve that builder to use Bogus to simplify the boilerplate involved and improve the quality of the test data created.

For our sample domain, we’ll continue using the Contributor entity that we’ve used throughout this series. In this case, however, we have a ContributorDTO that represents an object used by a ContributorService to send a message to a queue or push data to an external system. In practice, you might not always be able to negotiate the contract of the data with the external system. This can lead to some differences between your domain model and the contract DTO. In this case, the ContributorDTO has a Status property that is a string, but the Contributor entity has a Status property that is a ContributorStatus enum. The ContributorDTO also has a few additional properties that are not present in the Contributor entity but may be useful in the context of the external system. For the sake of our example, we’ll just assume that either our application is capable of producing these values or that they may be optional in the external system. The reason they are present is to demonstrate how useful the Builder pattern and Bogus can be when generating objects with a large amount of properties.

public class Contributor(
  string email,
  string firstName,
  string lastName,
  int followers,
  int following,
  int stars,
  ContributorStatus status) : EntityBase, IAggregateRoot
{
  public string Email { get; internal set; } = Guard.Against.NullOrEmpty(email);
  public string FirstName { get; set; } = Guard.Against.NullOrEmpty(firstName);
  public string LastName { get; internal set; } = Guard.Against.NullOrEmpty(lastName);
  public int Followers { get; internal set; } = Guard.Against.Negative(followers);
  public int Following { get; internal set; } = Guard.Against.Negative(following);
  public int Stars { get; internal set; } = Guard.Against.Negative(stars);
  public ContributorStatus Status { get; internal set; } = Guard.Against.Null(status);
}

public class ContributorStatus : SmartEnum<ContributorStatus>
{
  public static readonly ContributorStatus CoreTeam = new(nameof(CoreTeam), 1);
  public static readonly ContributorStatus Community = new(nameof(Community), 2);
  public static readonly ContributorStatus NotSet = new(nameof(NotSet), 3);

  protected ContributorStatus(string name, int value) : base(name, value) { }
}

public record ContributorDTO(
  int Id,
  string Email,
  string FirstName,
  string LastName,
  int Followers,
  int Following,
  int Stars,
  string Status,
  DateTimeOffset? CreatedOn,
  DateTimeOffset? ModifiedOn,
  int? ContributionCount,
  int? NumberOfDaysInStreak,
  int? LongestStreakEver,
  int? MostContributionsInOneDay);

Next, we have a ContributorService that has a method SendSomeMessage. This is the service that will be the focus of our tests. For the sake of this example, SendSomeMessage will simply validate that the Id property of ContributorDTO is not negative or zero and return a completed task. This validation gives us the ability to write at least 2 tests to assert the success and failure paths of that validation. In a real-world scenario, we’d expect that this method would then go on to send a message to a queue or service bus.

public class ContributorService
{
  /// <summary>
  /// Should send a message to a queue or some endpoint, but for now just validates contributorDTO.Id and returns a completed task
  /// </summary>
  /// <param name="contributorDTO"></param>
  /// <returns></returns>
  public Task SendSomeMessage(ContributorDTO contributorDTO)
  {
    Guard.Against.NegativeOrZero(contributorDTO.Id, nameof(contributorDTO.Id));
    return Task.CompletedTask;
  }
}

Builder Pattern Refresh 🔍

The following two tests demonstrate how we can use the Builder pattern to create a ContributorDTO with a valid Id and an invalid Id. The ContributorDtoBuilder in this iteration implements the conventional builder pattern with private fields for test values, “WithSomeProperty” methods for each customizable property, and a Build method to instantiate the output type (in this case ContributorDTO). If you need a refresher on the Builder pattern, check out part 1 of this series Creating Test Objects via Design Patterns. Once we’ve instantiated our ContributorDTO with the ContributorDtoBuilder, we can then instantiate the ContributorService or System Under Test (SUT) and assert that the SendSomeMessage method behaves as expected.

public class SendSomeMessage
{
  [Fact]
  public void ShouldNotThrowExceptionWhenContributorDTOIsPopulated()
  {
    var contributorDto = new ContributorDtoBuilder()
      .WithTestValues()
      .WithStatus(ContributorStatus.CoreTeam)
      .Build();
    var sut = new ContributorService();

    Action act = () => sut.SendSomeMessage(contributorDto);

    act.Should().NotThrow<Exception>();
  }

  [Theory]
  [InlineData(-1)]
  [InlineData(0)]
  public void ShouldThrowAnExceptionWhenDtoFailsValidation(int id)
  {
    var contributorDto = new ContributorDtoBuilder()
      .WithTestValues()
      .WithId(id)
      .WithStatus(ContributorStatus.CoreTeam)
      .Build();
    var sut = new ContributorService();

    Action act = () => sut.SendSomeMessage(contributorDto);

    act.Should().Throw<Exception>();
  }
}
public class ContributorDtoBuilder
{
  private int _id;
  private string _email;
  private string _firstName;
  private string _lastName;
  private int _followers;
  private int _following;
  private int _stars;
  private string _status;
  private DateTimeOffset? _createdOn;
  private DateTimeOffset? _modifiedOn;
  private int? _contributionCount;
  private int? _numberOfDaysInStreak;
  private int? _longestStreakEver;
  private int? _mostContributionsInOneDay;

  public ContributorDtoBuilder WithTestValues()
  {
    _id = 1;
    _email = "test@email.com";
    _firstName = "Test";
    _lastName = "User";
    _followers = 1;
    _following = 1;
    _stars = 1;
    _status = ContributorStatus.NotSet.Name;
    _createdOn = DateTimeOffset.UtcNow;
    _modifiedOn = DateTimeOffset.UtcNow;
    _contributionCount = 1;
    _numberOfDaysInStreak = 1;
    _longestStreakEver = 1;
    _mostContributionsInOneDay = 1;
    return this;
  }

  public ContributorDtoBuilder WithId(int id)
  {
    _id = id;
    return this;
  }

  // ...other "WithSomeProperty" methods excluded

  public ContributorDTO Build()
  {
    return new(
      Id: _id,
      Email: _email,
      FirstName: _firstName,
      LastName: _lastName,
      Followers: _followers,
      Following: _following,
      Stars: _stars,
      Status: _status,
      CreatedOn: _createdOn,
      ModifiedOn: _modifiedOn,
      ContributionCount: _contributionCount,
      NumberOfDaysInStreak: _numberOfDaysInStreak,
      LongestStreakEver: _longestStreakEver,
      MostContributionsInOneDay: _mostContributionsInOneDay);
  }
}

One thing to note about the ContributorDtoBuilder is that it does save quite a bit of repeated code in our tests. But it does come with a bit of overhead as we need to create a field for each property of the ContributorDTO and “WithSomeProperty” setter methods if the tests are dependent on arranging those fields. This can be a bit of a pain to maintain, especially if the ContributorDTO has a large number of properties or changes often.

It’s Bogus Time! 🎉

We’ll skip the introduction to Bogus and its many benefits. If you missed that article, check out part 3 of this series Creating Domain-Driven Test Data With Bogus. Combining Bogus with the Builder pattern has many benefits. We can simplify the Builder implementation, provide greater flexibility over strongly typed Bogus classes, and improve the quality of the test data for our test suite. This can be a powerful refactoring in test suites with many Builders that generate large amounts of real-world or user-driven test data which can be tedious for developers to creatively come up with.

The following is the updated ContributorDtoBuilder that uses Bogus to generate the ContributorDTO test data. The WithTestValues method has been removed along with most of the private fields. We can still utilize the “WithSomeProperty” arrangement convention if we want to set test-specific values. This behavior is easily preserved by simply passing the field to the Faker or null coalescing the field and having the Faker generate a value as seen with the Id and Status fields below.

public class ContributorDtoBuilder
{
  private int? _id;
  private ContributorStatus? _status;

  public ContributorDtoBuilderV2 WithId(int id)
  {
    _id = id;
    return this;
  }

  public ContributorDtoBuilder WithStatus(ContributorStatus status)
  {
    _status = status;
    return this;
  }

  public ContributorDTO Build()
  {
    var faker = new Faker<ContributorDTO>()
      .CustomInstantiator(f => new ContributorDTO(
        Id: _id ?? f.UniqueIndex, // <-- Using a private field or generating unique index if not set
        Email: f.Person.Email,
        FirstName: f.Person.FirstName,
        LastName: f.Person.LastName,
        Followers: f.Random.Int(1, 100),
        Following: f.Random.Int(1, 100),
        Stars: f.Random.Int(1, 100),
        Status: _status?.Name ?? f.PickRandom<ContributorStatus>(ContributorStatus.List).Name,
        CreatedOn: f.Date.Past(),
        ModifiedOn: f.Date.Past(),
        ContributionCount: f.Random.Int(100, 1000),
        NumberOfDaysInStreak: f.Random.Int(7, 10),
        LongestStreakEver: f.Random.Int(11, 100),
        MostContributionsInOneDay: f.Random.Int(10, 20)));

    return faker.Generate();
  }
}

It’s also important to note that in most implementations using this style of Builder, the changes discussed in this article shouldn’t be a large breaking change as any existing arrangement of values would likely be using “WithSomeProperty” methods. If you have a large test suite or many builders, you may want to consider a gradual migration which thankfully is quite easy to do since this approach is incrementally adoptable for most tests. 😉

In summary, we revisited the Builder pattern and saw how we could refactor an existing builder with the Bogus library for an improved test data generation experience. We saw how the Bogus library can simplify the Builder pattern implementation while still providing the flexibility to arrange values per test as needed. Lastly, we discussed how the combination of these practices can be adopted incrementally in a large code base.

Resources 📖


Copyright © 2024 NimblePros - All Rights Reserved