Recently the creator of Ruby on Rails David Heinnmeier Hansson announced that TypeScript was being dropped from Turbo 8. This decision was not a unanimously popular one to say the least however it did spark an interesting discussion on the utility of TypeScript in enterprise level applications. Does it create more overhead for developers than it's worth? Does it make more sense to use strong types in some applications than others? Is it a good or a bad developer experience? Then in a fairly timely manner OfferZen Origins released the TypeScript Documentary where a lot of the people who originally worked on TypeScript back in 2010 explained how the language was built and the problems they initially wanted to solve. For someone who is relatively new to development (starting in 2019 when TypeScript was already standardised) these debates and discussions have been nothing short of facinating.
The Problem
While I am sold on the benefits of TypeScript and if given a choice would prefer to use it I also see the merits to some of the criticisms. There are instances where you can be in TypeScript purgatory trying to get rid of the red line of death under your code and make the compiler happy because of a small type mismatch. The issue I will aim to address here is keeping the compiler happy during testing. In enterprise level applications (and even personal applications) types can start to get very complex especially when they start to have relations to each other. It's not uncommon to need to re-create large pieces of data to test something quite isolated and simple.
For the rest of this article here are the types we will be working with and looking to re-create:
export type User = {
id: number;
username: string;
bio: string;
grade: Grade;
comments: Comment[];
likes: Like[];
};
export type Like = {
id: number;
userId: number;
commentId: number;
};
export type Comment = {
id: number;
userId: number;
text: string;
likes: Like[];
comments: Comment[];
};
export type Grade = {
belt: Belt;
stripes: Stripes;
};
export type Stripes = 0 | 1 | 2 | 3 | 4;
export type Belt = "white" | "blue" | "pruple" | "brown" | "black";
To continue the point about complex types and testing, say we have a React component we wish to test. The Component accepts the User type as a prop but out of the 10+ props passed through we only really care about the bio, or the grade. To satisfy the type compiler (and indeed React in general) we still need to pass in a full object in the correct shape for the test to even run. There are many ways to go about this from using factory functions to simply mocking and re-using the data in each test suite. But these solutions have their limitations.
While factory functions are a fantastic pattern which we will have a use for later in this post it is far from optimal here. Consider the following solution:
// Factory function with defaults
const createUser= ({
id = 1,
username = "John-d",
bio = "I am a generic user",
grade = { belt: "white", stripes: 0 },
comments = [],
likes = [],
}: {
id?: number;
username?: string;
bio?: string;
grade?: Grade;
comments?: Comment[];
likes?: Like[];
}): User => ({ id, username, bio, grade, comments, likes });
It isn't terrible, in fact its a very viable solution that will work well with a lot of tests
const userWithUpdatedGrade = createUser({
grade:
{belt: "blue",
stripes: 2}
});
will equal:
{
id: 1,
username: "I am a generic user",
bio: '',
grade: {
belt: 'blue',
stripes: 2,
},
comments: [],
likes: [],
}
It will work and satisfy the compiler however once objects start getting more complex (think of adding likes and comments or any number of nested objects) the code overhead will start to get higher and we will need to write additional functions to account for comments needing to have the right userID while being passed into the initial user object. This is doable and I can see arguments for swearing by the factory pattern however I propose an alternative.
What has worked well for me in my own projects as well as in previous workplaces has been 'The Builder Pattern'
What is The Builder Pattern?
The builder pattern is frequently used in Java and follows several OOP design principals. The idea is that there are a set of classes that come together to build an object in the shape that we as the developer desire. Think about the above example and how Comments and likes relate to each other as well as the User type. If we wanted to build an object that included a users comments as well as replies to said comments it might look something like this:
So that is the abstract concept of it, we "build" instances of each of these objects through classes and use them as we see fit, but how is it implemented? For starters we need something called a 'product class' which will represent the type of object we are trying to create.
export class UserInstance {
id: number;
username: string;
bio: string;
grade: Grade;
comments: Comment[];
likes: Like[];
constructor(
username: string = "JohnDoe",
id: number = 1,
bio: string = "I am who I am",
grade: Grade = { belt: "white", stripes: 0 },
comments: Comment[] = [],
likes: Like[] = []
) {
this.id = id;
this.username = username;
this.bio = bio;
this.grade = grade;
this.comments = comments;
this.likes = likes;
}
}
Straight off the bat the above class has type satisfaction from the User type and provides default parameters for our generic user, but we're not done yet. While this class by itself could be used throughout our app we have not solved the same problem that was present with the factory function pattern. Enter the builder class:
export class UserBuilder {
user: User;
constructor(userName: string) {
this.user = new UserInstance(userName);
}
setId(id: number) {
this.user.id = id;
return this;
}
setBio(bio: string) {
this.user.bio = bio;
return this;
}
setGrade(grade: Grade) {
this.user.grade = grade;
return this;
}
setComments(comments: Comment[]) {
this.user.comments = comments;
return this;
}
setLikes(likes: Like[]) {
this.user.likes = likes;
return this;
}
build() {
return this.user;
}
}
Now to instantiate a new user with type satisfaction its a simple matter of const user = new User("John-Danaher").build()
1. So this is all fantastic but it sure is a lot of code and we havent even addressed the comment or like types yet! Why should we go to all of this trouble with these Builder classes? Why indeed...
Its clean and readable
While the Builder pattern has a decent amount of code overhead once your builders are ready for use using them is very simple, readable and dare I say elegant.
Imagine a component accepts our user class and uses the "belt" object to render certain colors on the screen based off the belt color and stripes. The component accepts the whole user class and has other things happening but for the same of this test we just want to test the belt.
<>
<ProfileView user={user} />
</>
Using the builder pattern with our tests the code will look like this:
it("renders the belt component as purple with three children
when the belt object is purple with 3 stripes",
()=> {
const userWithBelt = new UserBuilder("John-Danaher")
.setGrade({belt:"purple", stripes: 3})
.build()
render(<ProfileView user={userWithBelt} />)
const beltClass = screen.getByTestId("belt-component")
expect(beltClass).toHaveStyle('background-color: purple')
expect(beltclass.children()).to.have.length(3)
})
The ProfileView component has immediate type satisfaction with the builder class (no red lines of doom under your code) but on top of this the whole test reads as easy as a childrens book. Any other developer coming into this file will be able to immediately see whats being tested and how and adding additional tests will be just as easy.
Very complex types are easily instantiated under the hood.
One thing we haven't dealt with yet in our code is the comment and like classes, so lets instantiate those now.
For the Comments:
export class CommentBuilder {
comment: Comment;
constructor(text: string) {
this.comment = new CommentInstance(text);
}
setId(setId: number){
this.comment.id= id;
return this;
}
setUserId(userId: number){
this.comment.userId= userId;
return this;
}
setLikes(likes: Like[]) {
this.comment.likes = likes;
return this;
}
setComments(comments: Comment[]) {
this.comment.comments = comments;
return this;
}
build() {
return this.comment;
}
}
export class CommentInstance {
userId: number;
id: number;
text: string;
likes: Like[];
comments: Comment[];
constructor(
text: string = "Generic comment",
userId: number = 1,
id: number = 1,
likes: Like[] = [],
comments: Comment[] = []
) {
this.text = text;
this.userId = userId;
this.id = id;
this.likes = likes;
this.comments = comments;
}
}
For the comments another set of builder classes makes sense because there are a few different properties involved with the object, for the likes on the other hand since its a very small object our friend the factory function will suffice:
export const likeFactory = ({
id = 1,
userId = 1,
commentId = 1,
}): {
id?: number;
userId?: number;
commentId?: number;
} => ({
id,
userId,
commentId,
});
So now if we need to test a user with comments it is a simple matter of using multiple builders in the same test file:
it("displays users comments in the comments section" ,()=>{
const comments = [
new CommentBuilder("first comment")
.setLikes([likeFactory({})])
.build(),
new CommentBuilder("second comment").build(),
new CommentBuilder("comment also").build()
]
const user = new UserBuilder("John-Danaher")
.setComments(comments)
.build()
render(<UserComments user={user}/>)
expect(....)
})
In a few easy to read lines of code we have created a complex user object for testing purposes. Another benefit is that..
Due to its use of methods it is highly customizable
Test cases are often simple and don't care about a good portion of what is on the page. Imagine if we wanted to test the quantity of comments in a users profile or verify that the correct text is appearing in the correct place? Our pattern as it is (and indeed the factory pattern) does provide a way to do this but it will be rather verbose:
const userWithComments: User = new UserBuilder("active-user")
.setId(1)
.setComments([
new CommentBuilder(1)
.setId(1)
.setLikes([
likeFactory(1, 1, 1),
likeFactory(2, 1, 1),
likeFactory(3, 1, 1),
])
.build(),
new CommentBuilder(2).setId(2).build(),
new CommentBuilder(2)
.setId(3)
.setComments([
new CommentBuilder(3)
.setLikes([likeFactory(4, 1, 3), likeFactory(5, 1, 3)])
.setId(2)
.build(),
new CommentBuilder(4).setId(2).build(),
])
.build(),
])
.build();
This is where the beauty of the constructor patterns use of methods come in. Who says the method has to accept just one parameter type? Maybe we want to customize the methods for ease of use? Lets look at the setComments
method on the user class and see what we can change to more easily cover more test instances:
setComments(comments: Comment[] | string[] | number) {
// User wants a set amount of comments but does not care about the content
if (typeof comments === "number") {
// Create an array from the number passed in
const commentArray: Comment[] = Array.from(
Array(comments),
(_, index) => index + 1
// for each element in the array create a new comment
).map((_, i) => new CommentBuilder(this.user.id).setId(i).build());
this.user.comments = commentArray;
return this;
}
// User cares about the text of the comments but nothing else
if (!!comments.every((c) => typeof c === "string")) {
this.user.comments = comments.map((comment) =>
new CommentBuilder(this.user.id).setText(comment).build()
);
return this;
}
// lets make sure the comments have the correct user id
this.user.comments = comments.map((comment) => ({
...comment,
userId: this.user.id,
}));
return this;
}
So now if we just want to test the number of comments a user has made:
it("only 10 comments are displyed per page", () => {
const user = new UserBuilder
.setComments(21)
.build()
render(<Profile user={user} />);
const commentsWrapper = document.getElementById("comments-wrapper")
expect(commentsWrapper.children.length).toBe(10)
})
it("displays the correct comment text on screen", () => {
const user = new UserBuilder
.setComments(["comment 1", "comment 2"])
.build()
render(<Profile user={user} />);
expect(screen.getByText("comment 1").toBeInTheDocument()
expect(screen.getByText("comment 2").toBeInTheDocument()
})
Now the above is just something I have came up with while writing this and isn't something that I have put into practice, perhaps my code could be cleaner or there is a better way of acheiving what I am trying to acheive but the point stands: the methods can be customized quite easily and as long as the return type still satisfied the compiler it is safe to experiment around. Another benefit of the builder pattern is that..
As the application grows and types change the patterns can remain constant:
Applications grow, different data requirements come up for our users and schemas can change dramatically over time. This is something that the builder pattern and its use in testing scales for very well. Say a new property is added to the user type, a property is removed and one is changed. Getting rid of the myriad of errors in your tests and compiler is just a matter of editing the product class to add, edit or omit the nessesary fields and suddenly we're back to having type satisfaction across our app. Of course it won't always be this simple as there is no telling what direction schema changes will go in over time but by enlarge using the pattern correctly will be adhering to DRY principals and thus be easy to edit over time. Another thing to keep in mind about bulder pattern is that...
You are not limited to creating builders for data mocking:
Any object that you might need to re use in your app can be re created in the builder pattern. I've used it in personal projects as dummy data before I'd hooked up my app to the database. One use I have found for it that could be useful at a bigger scale is to mock context values for testing purposes. By way of a breif example:
it("renders with the context correctly", () => {
const funct=jest.fn()
const data = new ContextBuilder().setInitialState("foo").setFunct(funct).build();
render(
<Context.Provider value={data}>
<Navbar />
</Context.Provider>
);
expect(screen.getByText("foo")).toBeInTheDocument()
const btn = screen.getByRole('button')
fireEvent.click(btn)
expect(funct).toHaveBeenCalled()
We now have an easily customizable context which we can change test by test and reap all of the readability and DRY benefits that come with the Builder Pattern.
I am sure there are many other uses for the pattern. You could use it in nextJS to mock the next/navigation
hooks not to mention it has utility whether you're using a strongly typed language or not.
Wrapping up
I will close with one final point about the builder pattern and go back to the beginning of the post where the downsides of TypeScript were mentioned. I have found that the act of creating builder pattern classes forces you to understand and interface with the common types in your app on a deeper level than you otherwise would. I have heard it said that both strong types and testing make you as a developer think on a deeper level about your code, how its implemented and what can go wrong with it. The builder pattern is yet another example of a way to funnel the developer towards thinking more about type saftey and reusability. While writing your code you may already have it in your mind "this is how I will test this" or "these are the types that I should have this component expect" but if you know you have builder classes in your back pocket for those types that you use with testing it is yet another layer of consistency and overall for my money will make the developer experience easier and more of a joy.
-
This was a very basic rundown of the builder pattern and two of its associated classes and there are more concepts to it, for further reading on the pattern the following article on refactoring.guru provides a comprehensive rundown. ↩
Top comments (0)