The Builder Pattern and How It Will Save Your Bacon When Unit Testing
Dec 04, 2018
When writing unit tests around classes, often each test will call the constructor for our class and provide dependencies as arguments. However, this introduces some coupling between the tests and the signature of the constructor, which can make it very difficult to change our constructor in future.
For example, take the following unit tests:
public shouldDisplayMessageIfNoPosts() {
const repository = new FakePostRepository().withPosts([]);
const frontPage = new BlogFrontPage(repository);
Expect(frontPage.postList).toBe("<p>There are no posts to display</p>");
}
public shouldDisplayOnePostCorrectly() {
const repository = new FakePostRepository().withPosts([
{
title: "Bla bla, a blog post",
contents: "Some interesting points here"
}
]);
const frontPage = new BlogFrontPage(repository);
Expect(frontPage.postList).toBe(`<article>
<header>Bla bla, a blog post</header>
<section>Some interesting points here</section>
</article>`);
}
They are testing the BlogFrontPage
class, and our tests are very atomic - they set up some posts in the FakePostRepository
, create a BlogFrontPage
instance using the repository and then perform some assertions on the postList.
However, these two test cases aren't enough. For full coverage of the cases, there need to be tests covering:
- A variety of post numbers, are they rendered correctly with 2 posts, 10 posts, 100 posts?
- Different article headings:
- What if there are really long headings?
- What if they are right-to-left e.g. Arabic?
- What if they contain emojis?
- Different post contents:
- What if the contents are empty?
- What if there are images in the contents?
- What if the contents are right-to-left, contain emojis, etc
As you can imagine, by the time that all these edge cases have been tested, there are going to be tens, possibly hundreds of calls to new BlogFrontPage
. So what if we want to introduce a new parameter?
If we wanted to add, for example, a "Write Post" button in the navbar at the top of the page (not in the post list), there's no reason that these tests would need to change. However, if we wanted to inject in a UserAuthenticator
, we would then have to go through all of these tests, set up a fake UserAuthenticator
and just pass it into the constructor - for no functional reason other than to satisfy the method signature. Our two tests from the beginning would look like this:
public shouldDisplayMessageIfNoPosts() {
const repository = new FakePostRepository().withPosts([]);
const authenticator = new FakeUserAuthenticator();
const frontPage = new BlogFrontPage(repository, authenticator);
Expect(frontPage.postList).toBe("<p>There are no posts to display</p>");
}
public shouldDisplayOnePostCorrectly() {
const repository = new FakePostRepository().withPosts([
{
title: "Bla bla, a blog post",
contents: "Some interesting points here"
}
]);
const authenticator = new FakeUserAuthenticator();
const frontPage = new BlogFrontPage(repository, authenticator);
Expect(frontPage.postList).toBe(`<article>
<header>Bla bla, a blog post</header>
<section>Some interesting points here</section>
</article>`);
}
Notice that nothing at all has changed in those tests besides making a FakeUserAuthenticator
and passing it in. This is where the builder pattern comes in useful. The builder pattern is simply an abstraction over the constructor, setting up sensible defaults for your unit tests. Here's what a BlogFrontPageBuilder
may look like:
class BlogFrontPageBuilder {
private postRepository: IPostRepository = new FakePostRepository();
public withPostRepository(postRepository: IPostRepository) {
this.postRepository = postRepository;
return this;
}
public build() {
return new BlogFrontPage(this.postRepository);
}
}
As you can see, it sets up a default FakePostRepository
but also allows the developer to pass in an IPostRepository
if they write a test that requires specific behaviours. Let's use this builder to rewrite the two tests from the beginning of the post.
public shouldDisplayMessageIfNoPosts() {
const repository = new FakePostRepository().withPosts([]);
const frontPage = new BlogFrontPageBuilder().withPostRepository(repository).build();
Expect(frontPage.postList).toBe("<p>There are no posts to display</p>");
}
public shouldDisplayOnePostCorrectly() {
const repository = new FakePostRepository().withPosts([
{
title: "Bla bla, a blog post",
contents: "Some interesting points here"
}
]);
const frontPage = new BlogFrontPageBuilder().withPostRepository(repository).build();
Expect(frontPage.postList).toBe(`<article>
<header>Bla bla, a blog post</header>
<section>Some interesting points here</section>
</article>`);
}
Our tests now aren't referencing new BlogFrontPage
directly, so if we want to add a new parameter to the constructor, we don't need to update hundreds of tests that don't care about that new parameter, we only need to update our builder to fix our unit tests - and updating the builder is simple and easy:
class BlogFrontPageBuilder {
private postRepository: IPostRepository = new FakePostRepository();
private userAuthenticator: IUserAuthenticator = new FakeUserAuthenticator();
public withPostRepository(postRepository: IPostRepository) {
this.postRepository = postRepository;
return this;
}
public withUserAuthenticator(userAuthenticator: IUserAuthenticator) {
this.userAuthenticator = userAuthenticator;
return this;
}
public build() {
return new BlogFrontPage(this.postRepository, this.userAuthenticator);
}
}