This article is part of a series on leveling up your testing skills
- Creating Test Objects via Design Patterns
- Supercharging Your Test Data With AutoFixture
- Creating Domain-Driven Test Data With Bogus
- Boosting the Builder Pattern Using Bogus
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.