Merry Christmas Sale! đ Ends in 5 days.
Friends, what an incredible year it has been. If I know us well, we're always moving forward. We're following our curiosity. And most importantly, we're growing.
Never forget one of the most important success techniques there is: reflection. So, take a moment to look back on how far you've come this year. Go on and collect the lessons, get inspired, and prepare for a prosperous 2025.
Before December 29th, you can get 50% off of Testing Mastery using the discount code XMAS-50 and 10% off the entire suite of courses in the academy using the discount code TSE-XMAS.
Thank you, and may 2025 grant you the growth, love & power to create what matters most in your world
Enjoy your holidays. See you next year.
And always, To Mastery!
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 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. đ
View more in Testing