- Published on
Using Builder pattern in Unit Tests
Table of Contents
When writing unit tests we often have to write repetitive boilerplate code in the Arrange phase of the unit test, so that our System Under Test and its Collaborators are in a state such that we can actually test what we want to test.
Suppose we want to test the GetRecent()
method of below class.
public class TransactionApplicationService
{
private readonly IUserRepository userRepository;
private readonly ITransactionRepository transactionRepository;
public TransactionApplicationService(IUserRepository userRepository, ITransactionRepository transactionRepository)
{
this.userRepository = userRepository;
this.transactionRepository = transactionRepository;
}
public async Task<IReadOnlyList<Transaction>> GetRecent(long userId, int size)
{
var user = await userRepository.Get(userId);
if (user is null)
{
throw new UserNotFoundException(userId);
}
if (!user.IsPremiumSubscriber)
{
throw new UnauthorizedException("You are not authorized to view the transactions.");
}
return await transactionRepository.List(userId, size);
}
}
Here is how I would write a unit test for this method.
[Fact]
public async Task GetRecent_ShouldReturnRecentTransactionsOfTheUser()
{
// Arrange
var users = new List<User>
{
new User { Id = 1, IsPremiumSubscriber = true },
};
var userRepository = new FakeUserRepository(users);
var transactions = new List<Transaction>
{
new Transaction { Id = 1, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 2, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 3, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 4, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 5, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
};
var transactionRepository = new FakeTransactionRepository(transactions);
var sut = new TransactionApplicationService(userRepository, transactionRepository);
// Act
var resultTransactions = await sut.GetRecent(1, 5);
// Assert
Assert.Equal(5, resultTransactions.Count);
Assert.True(resultTransactions.All(t => t.UserId == 1));
}
Look at the size of code written in the Arrange phase in comparison to the Act/Assert phase. This is the setup code we have to write to put the system in a state so that we can perform our test.
This pattern had become quite normal in my unit tests and I did not liked it due to its verbosity. Also, because of its verbosity it sometimes becomes hard to figure out what is actually required to execute this test successfully.
I needed a more clean and elegant way to construct my setup code and data.
Solution 1 - Helper/Factory Methods
The first solution I thought was to move this setup code into some helper methods.
[Fact]
public async Task GetRecent_ShouldReturnRecentTransactionsOfTheUser()
{
// Arrange
var sut = CreateTransactionApplicationService();
// Act
var resultTransactions = await sut.GetRecent(1, 5);
// Assert
Assert.Equal(5, resultTransactions.Count);
Assert.True(resultTransactions.All(t => t.UserId == 1));
}
private TransactionApplicationService CreateTransactionApplicationService()
{
var users = new List<User>
{
new User { Id = 1, IsPremiumSubscriber = true },
};
var userRepository = new FakeUserRepository(users);
var transactions = new List<Transaction>
{
new Transaction { Id = 1, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 2, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 3, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 4, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
new Transaction { Id = 5, UserId = 1, CreatedAt = new DateTime(2022, 7, 20) },
};
var transactionRepository = new FakeTransactionRepository(transactions);
return new TransactionApplicationService(userRepository, transactionRepository);
}
Though the test has become quite easy to read now, there are still problems with this approach.
- We just have moved the verbosity to another place instead of removing it.
- The setup code is now hidden from the reader. I don’t know about you but for me it is a problem. I want everything which is related to the test in the test itself.
- Inflexible. For every new scenario which demands a change in setup code, I would have to create new helper methods or pass arguments to the existing ones.
Solution 2 - Builder Pattern
One day I was reading Growing Object-Oriented Software: Guided by Tests, there I saw this beautiful technique of using Builders to create setup code and data.
Below is the same example but the setup code created through builders. With builders and composing them together, I was able to create a simple and concise API to construct the setup code and data in one place.
[Fact]
public async Task GetRecent_ShouldReturnRecentTransactionsOfTheUser()
{
// Arrange
var sut = new TransactionApplicationServiceBuilder()
.WithUserRepository(new FakeUserRepository(new UserListBuilder(1)
.WithId(1)
.WithIsPremium(true)
.Build()
))
.WithTransactionRepository(new FakeTransactionRepository(
new TransactionListBuilder(5)
.WithId(new long[] { 1, 2, 3, 4, 5 })
.WithUserId(1)
.WithCreatedAt(new DateTime(2022, 7, 20))
.Build()
))
.Build();
// Act
var resultTransactions = await sut.GetRecent(1, 5);
// Assert
Assert.Equal(5, resultTransactions.Count);
Assert.True(resultTransactions.All(t => t.UserId == 1));
}
Unlike the helper methods approach I can clearly see in the test, how the setup code is being constructed. With builders I only need to set those values which are required for the test, others are automatically initialized with default values. You will see this in the builder implementation below.
I would like to make a simple distinction here and that is the type of builders used. Here I am using 3 builders.
- TransactionApplicationServiceBuilder
- UserListBuilder
- TransactionListBuilder
The last two builders are used to construct the data i.e. list of Users and Transactions respectively. And therefore I like to call them Data Builders rather than simply Builders.
Data Builders take a size parameter in their constructors which specify how many items will be created and they have overloaded method calls. First takes a simple scalar value (.WithUserId(1)
) and the second one takes a list of values (.WithId(new long[] { 1, 2, 3, 4, 5 })
). With the first method, the single value will be repeated for all the items which will be created and with the second method you have the ability to provide different values for the items.
Below is the implementation example of both types of builders for your reference. Though it takes time and effort to create these builders but they provide a great Return On Investment.
public class TransactionApplicationServiceBuilder
{
private IUserRepository userRepository;
private ITransactionRepository transactionRepository;
public TransactionApplicationServiceBuilder WithUserRepository(IUserRepository userRepository)
{
this.userRepository = userRepository;
return this;
}
public TransactionApplicationServiceBuilder WithTransactionRepository(ITransactionRepository transactionRepository)
{
this.transactionRepository = transactionRepository;
return this;
}
public TransactionApplicationService Build()
{
return new TransactionApplicationService(userRepository, transactionRepository);
}
}
public class TransactionListBuilder
{
private readonly int size;
private List<long> ids;
private List<long> userIds;
private List<string> types;
private List<decimal> amounts;
private List<DateTime> createdAtList;
public TransactionListBuilder(int size)
{
this.size = size;
ids = Enumerable.Repeat(default(long), size).ToList();
userIds = Enumerable.Repeat(default(long), size).ToList();
types = Enumerable.Repeat(default(string), size).ToList();
amounts = Enumerable.Repeat(default(decimal), size).ToList();
createdAtList = Enumerable.Repeat(default(DateTime), size).ToList();
}
public TransactionListBuilder WithId(long id)
{
ids = Enumerable.Repeat(id, size).ToList();
return this;
}
public TransactionListBuilder WithId(IEnumerable<long> ids)
{
this.ids = ids.ToList();
return this;
}
public TransactionListBuilder WithUserId(long userId)
{
userIds = Enumerable.Repeat(userId, size).ToList();
return this;
}
public TransactionListBuilder WithUserId(IEnumerable<long> userIds)
{
this.userIds = userIds.ToList();
return this;
}
public TransactionListBuilder WithType(string type)
{
types = Enumerable.Repeat(type, size).ToList();
return this;
}
public TransactionListBuilder WithType(IEnumerable<string> types)
{
this.types = types.ToList();
return this;
}
public TransactionListBuilder WithAmount(decimal amount)
{
amounts = Enumerable.Repeat(amount, size).ToList();
return this;
}
public TransactionListBuilder WithAmount(IEnumerable<decimal> amounts)
{
this.amounts = amounts.ToList();
return this;
}
public TransactionListBuilder WithCreatedAt(DateTime createdAt)
{
createdAtList = Enumerable.Repeat(createdAt, size).ToList();
return this;
}
public TransactionListBuilder WithCreatedAt(IEnumerable<DateTime> createdAtList)
{
this.createdAtList = createdAtList.ToList();
return this;
}
public List<Transaction> Build()
{
var transactions = new List<Transaction>();
for (var i = 0; i < size; i++)
{
transactions.Add(new Transaction
{
Amount = amounts[i],
CreatedAt = createdAtList[i],
Id = ids[i],
Type = types[i],
UserId = userIds[i]
});
}
return transactions;
}
}
Happy Coding 😁