Using Builders to Model Complex Test States

Last updated Aug 15th, 2024
Setting up test cases can be extremely challenging. Especially when running E2e tests. In this letter, we learn how to use builders to tame that complexity in a declarative, expressive way.

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:

  1. randomness (which sometimes works)
  2. 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.

Data Model Tree

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:

  1. first, the classroom and the student need to exist
  2. then, once this is true, I need to then link the student to the classroom using a student enrolment record
  3. then I’d create an assignment for the classroom, because it directly relied upon the existence of a classroom
  4. from here, I’d now assign the assignment to the student, linking together the enrolledStudent and the assignment, creating a studentAssignment record
  5. then, the student has to submit an assignment, creating an assignmentSubmission record
  6. 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.

Data Model Tree

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.

As a side note, you can join The Software Essentialist for an additional 15% off before I ship the Pattern-First phase of craftship. Use the code TESTING-MASTERY before August 19th to join us in the course & community.

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:

Data Model Tree

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:

Data Model Tree

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

Data Model Tree

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

PS: As reminder, you can join us in The Software Essentialist for an additional 15% off using the code TESTING-MASTERY before August 19th.



Stay in touch!



View more in Testing