Using Builders to Model Complex Test States
In a recent letter, I explained that edge cases can be an absolute nightmare to deal with when testing testing features.
To deal with this, we learned that there are 2 general techniques at your disposal:
- randomness (which sometimes works)
- resetting and constructing the test state (which always works)
Admittedly, #1 is super easy, and that’s why it only sometimes works for simple cases.
But #2… well this is hard, but it always works.
In this letter, I’ll gift you the gift of how to use #2 — you’ll learn how to use my favourite testing pattern — the builder pattern.
But first, why exactly is it so hard to set up test states?
Why is it so hard to set up test states?
To illustrate, let’s return to the School domain.
And then let’s imagine we wanted to test the create class room feature.
Feature: Create Class Room
As an administrator
I want to create a class
So that I can add students to it
Scenario: Successfully create a class room
Given I want to create a class room named "Math"
When I send a request to create a class room
Then the class room should be created successfully
Recall that it’s easy to test success scenario because to set up the test state, all we have to do is wipe out the database or use randomness to make our tests idempotent again.
For example, we don’t want to trigger the unique constraint on the name field (because you can’t have two classes named “Math”).
But what about the following scenario? How would you test this?
Feature: Create Class Room
...
Scenario: Classroom already exists
Given a classroom already exists
When I send a request to create a class room of the same name
Then the class room should not be created
Ah, the classroom already exists…
Well, of course, like always, you’d start by representing it in the executable specification, of course.
test("Classroom already exists", ({ given, when, then }) => {
let classroomName = "Science";
let requestBody: any = {
name: classroomName
};
let response: any = {};
given("a classroom already exists", () => {
// ??
});
when("I send a request to create a class room of the same name", async () => {
response = await request(app).post("/classes").send(requestBody);
});
then("the class room should not be created", () => {
expect(response.status).toBe(500);
expect(response.body.success).toBeFalsy();
expect(response.body.error).toBe("ServerError");
});
});
But how do you model the fact that the classroom already exists?
Do we just call the create classroom API twice?
Do you write a function to “create the classroom” beforehand?
It seems reasonable, but when you follow that line of thinking and working to extremes, it actually results in a lot of confusing code prone to duplication.
Honestly, this is something I used to find extremely difficult.
We want to know how to compose the database test state properly. I prefer to hold a root level understanding, when I can, so let me introduce you to what I believe is a foundational concept — a first principle, if you will.
It may very well have a different name, but I call it The Data Model Tree.
The Data Model Tree
In a vital lesson on composition in The Software Essentialist, I explain that your application is a web of objects (or a tree).
We see this when we link dependencies and bootstrap our applications.
But the phenomenon actually extends far beyond just how we connect classes and functions.
The Data Model Tree is the relationship between all of your data models in your database.
And they too, come together to form a tree in their sequence of creation.
You have to reconstruct the tree to test scenarios
Here’s what makes setting up your database for your tests so complex: you have to re-create the entire tree.
Wait, what?
Look: say I want to test a feature in our School domain called gradeAssignment. To write this test, all of the side-effects of grade assignment to have already happened.
To begin this process, I’d first ask: “what is the state of the data models at the point I want to grade an assignment?”
Well, in order for a teacher to grade an assignment, it would need to have first been assigned to a student, right?
And to get all the way up to this point in the sequence of creation, it’d mean:
- first, the classroom and the student need to exist
- then, once this is true, I need to then link the student to the classroom using a student enrolment record
- then I’d create an assignment for the classroom, because it directly relied upon the existence of a classroom
- from here, I’d now assign the assignment to the student, linking together the enrolledStudent and the assignment, creating a studentAssignment record
- then, the student has to submit an assignment, creating an assignmentSubmission record
- then, and ONLY then, can I, the teacher, grade the assignment, creating a grade record
OH MY GOD.
So your head doesn’t explode: look.
Here’s the sequence of data models required to run this test.
Sound like a lot of work?
I’m making it out to sound worse than it is.
It’s not that bad.
But you know how I talk about aligning your actions with the nature of reality?
Yeah, well, this is where data comes from.
It emerges in little chunks and pieces like this.
The plus side: This is about as hard as it gets. Everything is so much easier (especially aggregate design in domain-driven design) when you understand this and think about your data as if you’re building bits and pieces of trees with each API call and operation.
You have to reconstruct the tree in reverse
Listen, I know you know how trees work.
I’ve been in leetcode hell, myself 😂
In case you forgot, we’ve got a root, leaf nodes, intermediate nodes, branches, and traversals.
“Why are you triggering my PTSD right now, Khalil. Are you that sick and twisted?”
Not intentionally..
Look. Here’s why I bring up trees:
Each test state has at least one Target Data Model in focus, and to prepare your database state for the test, you have to create the entire structure of the tree in reverse, from that Target Data Model.
Pause on that one and read it again.
It can be confusing, but it’s the key.
To illustrate, let’s see some examples.
Example #1: If I was testing “assign student to a class” (ie: to create an enrolment), the Target Data Model is “EnrolledStudent” means I first need to have:
- created the student and the class
Therefore, your tree setup should look like this:
Example #2: If I were testing “createAssignment”, the Target Data Model is “Assignment”, which that means I need to have first:
- created a classroom
And thus, like this:
In this case, you don’t need to create students or anything else because you don’t need to create a student to get up the tree to the classroom.
Can you see how an understanding of the data model is required to do this work?
Another example.
Example #3: If I were testing “grading an assignment, the Target Data Model is “GradedAssignment”, which means I need to have first:
- created the student and the class
- enrolled the student to the class
- created the assignment
- created a student assignment
- created a student assignment submission
That’s the most complex one.
But if you get this, you are golden, my friend.
If you don’t get it yet, that’s cool. There’s a massive difference between knowledge & experience. You need practice. We do a lot of that in the course.
“That’s cool and all, Khalil. But how do you ACTUALLY set this up for a test? It seems like it’s going to be some sort of complex graph and trees traversal stuff.”
It’s actually very straightforward if you follow a few design principles and patterns.
I’ll show ya the pattern first.
Let’s see the tremendous Builder Pattern.
What are builders?
As I said, The Builder Pattern is one of my favourites.
Builders help you construct values, implement relationships, and set up your test states in an extremely readable, expressive, declarative and domain-driven way. And that’s what I’m all about — the link between TDD, DDD, great design and great DX.
So, for example, let’s say we wanted to create a student as a pre-condition for a test.
While we can design these to construct just pure objects, we’re going to need to design our builders to manipulate the database for us. Usage of such a builder that sets up the database state might look like this from the outside.
const studentBuilder = new StudentBuilder();
await studentBuilder
.withName('Jackson')
.withRandomEmail()
.build();
And on the inside, it might look like the following.
class StudentBuilder {
private props: Partial<StudentProps>;
constructor() {
this.props = { name: '', email: '' }
}
withName(name) {
this.props.name = name;
return this;
}
withRandomEmail() {
this.props.email = faker.internet.email();
return this;
}
async build() {
const student = await prisma.student.create({
data: {
name: this.props.name as string,
email: this.props.email as string
},
});
return student;
}
}
Pretty nifty, eh?
It’s the chaining that I like the most.
How to design builders? (practical tips)
This letter is running pretty long again, so let’s get practical.
What I’ve found is this: the best way to build builders is to follow the laws of The Data Model Tree, and to work in reverse starting from target data model, constructing the test state using the SPECIFIC keywords: build, from, and and with.
Here’s what I mean.
Demonstration
Let’s do “assign a student to an assignment”.
This is a real tricky one.
But it’s easy if we focus on one layer of abstraction at a time.
First layer? The acceptance test.
Feature: Assign an assignment to a student
As a teacher
I want to assign a student to an assignment
So that the student can achieve learning objectives
Scenario: Assign a student to an assignment
Given there is an existing student enrolled to a class
And an assignment exists for the class
When I assign the student the assignment
Then the student should be assigned to the assignment
What needs to first exist in order to do this?
- the student must first be enrolled to the class
- the assignment must first exist
So we need to model those, in the executable specification.
test("Assign a student to an assignment", ({
given,
when,
and,
then,
}) => {
let requestBody: any = {};
let response: any = {};
beforeAll(async () => {
await resetDatabase();
});
given("there is an existing student enrolled to a class", async () => {
//
});
and("an assignment exists for the class", async () => {
//
});
...
})
And now, for each of these pre-condition clauses, the question is: “what’s the Target Data Model?”
Well let’s see. Let’s do the first one.
For the Given
, it appears we’re dealing with an enrollment right? “a student is enrolled to a class”. Yep. And what’s the relationship?
-
an enrolment
-
comes from a student
- with a name
- with an email
-
and from a classroom
- with a name
-
Makes sense, right?
How would you model that using a Builder?
Like this.
given("there is an existing student enrolled to a class", async () => {
const enrollmentResult = await studentEnrollmentBuilder
.fromClassroom(classroomBuilder.withClassName("Math"))
.and(studentBuilder.withName('Johnny').withEmail('student@example.com'))
.build();
student = enrollmentResult.student;
});
And putting it all together?
test("Assign a student to an assignment", ({
given,
when,
and,
then,
}) => {
let requestBody: any = {};
let response: any = {};
let student: Student;
let assignment: Assignment;
beforeAll(async () => {
await resetDatabase();
});
given("there is an existing student enrolled to a class", async () => {
const enrollmentResult = await studentEnrollmentBuilder
.fromClassroom(classroomBuilder.withClassName("Math"))
.and(studentBuilder.withName('Johnny').withEmail('johnny@example.com'))
.build();
student = enrollmentResult.student;
});
and("an assignment exists for the class", async () => {
assignment = await assignmentBuilder
.fromClassroom(classroomBuilder.withClassName("Math"))
.build();
});
...
})
Boom 💥
Notice the following:
- I use the word with to express fields/values
- I use the word from and and to express relationships
- I use the word build to construct it all
- I focus on the end result, the thing I really want to build — which is the enrolment, and I assume that it will work.
That’s the magic, my friend.
And if you like that, we can continue to improve it like so:
test("Assign a student to an assignment", ({
given,
when,
and,
then,
}) => {
let requestBody: any = {};
let response: any = {};
let student: Student;
let assignment: Assignment;
beforeAll(async () => {
await resetDatabase();
});
given("there is an existing student enrolled to a class", async () => {
const enrollmentResult = await anEnrolledStudent()
.from(aClassRoom().withClassName("Math"))
.and(aStudent().withName('Johnny').withEmail('johnny@example.com'))
.build();
student = enrollmentResult.student;
});
and("an assignment exists for the class", async () => {
assignment = await anAssignment()
.from(aClassRoom().withClassName("Math"))
.build();
});
...
})
Wonderful, wonderful stuff.
I’ll break this down more in future letters.
Happy testing!
And as always, To Mastery
Khalil
Stay in touch!
Join 15000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Testing