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); } }

Like my blog? Try my game!

Creature Chess is a lofi chess battler

It's free, you can play it from your browser, and a game takes 10 minutes

Play Creature Chess

Find my other work on Github @jameskmonger