TL:DR; If our tests are defined by our requirements, we know our application is behaving in the agreed and accepted way when all our tests pass.
A story of two camps
Test Driven Development (TDD) isn't a new practice, but it's certainly a divisive one. There are people who swear that code should only be written to cause tests to pass, while others feel that TDD makes the delivery process more difficult, slowing down the team, while not offering any benefit.
While I see points from both camps, I feel that the naysayers probably haven't approached TDD in the most structured way. I suppose a good question here would be "why do I feel naysayers don't approach TDD in a structured way?" Well, because I'm a former naysayer who didn't approach TDD in a structured way!
Changing camps
I think it's fair to say most people learn to code before they learn to write tests for their code. Because of this, I think most people start their development journey in the "Boo TDD" camp (including me for a while!). Whenever I tried TDD, I came back to the same questions:
- How am I expected to know what classes/functions I'm going to be writing?
- How am I suppose know what tests to write for code that doesn't exist?
It wasn't until I changed my attitude to testing that I got the answers to my questions and TDD started to working for me.
What were these answers?
In my User Driven Testing post, I outlined why I started treating tests as users of our app. With this shift in mindset, I decided two things:
- We don't need to know what code we will write before we write our tests
- The tests we need to write were defined the moment we decided how we want our app to function
Was this post written by the Sphinx?
Okay, so what does "The tests we need to write were defined the moment we decided how we want our app to function" mean? Let's work through an example (you can find the source code on my GitHub here).
Let's say we want to build a venue capacity tracker (not a very realistic app to build, but role with me for a second).
We are given the following requirements:
- The "Capacity Counter" heading must be present at all times
- The user must be able to input a maximum number of people who can enter the venue
- The user must click a "Start" button before they can begin recording venue capacity
- If the user enters a non-numeric value for the capacity, a relevant error message should pop up when the user clicks "Start"
- If the user enters a number below 1 for the capacity, a relevant error message should pop up when the user clicks "Start"
- If the user enters a positive numeric for the capacity, the user should be taken to a page to record capacity when they click "Start"
- The user should be told what the current visitor count is
- The user must be able to add or remove one person from the venue capacity at a time
- The user should not be able to reduce the visitor count to below 0
- The user should not be able to increase the visitor count above the capacity
The first test before the rest
Before we've even started thinking about writing our code, we can actually start writing our tests. For example, if we want to write a test to verify our first two requirements, we can write something like this:
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from '../src/containers/App';
describe('App', () => {
test('App loads correctly', () => {
render(<App />);
const header = screen.getByRole("heading", { name: /Capacity Counter/i });
expect(header).toBeVisible();
const instructions = screen.getByText(/Please provide your venue's capacity./i);
expect(instructions).toBeVisible();
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
expect(capacityInput).toBeVisible();
});
});
With this one test, we've managed to verify our first two requirements. However, it's all well and good verifying the correct content appears on a page, but most apps out there allow users to interact with them to a certain degree.
Are we supposed to just look at a static page?
Let's write some tests for our first bit of behaviour our app will have - inputting our venue's capacity. The first test we want to look at is preventing non-numeric or a negative numbers. We can combined them into a test.each
since we want the app to behave in the same way based on these scenarios. We can also write a test to verify the "happy path" - entering a valid capacity.
import React from 'react';
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import App from '../src/containers/App';
describe('User can set capacity', () => {
test.each`
input | testDesc
${"abc"} | ${"a non-numeric"}
${"0"} | ${"0 for the"}
${"-20"} | ${"a negative number"}
`('User cannot enter $testDesc capacity', ({ input }) => {
render(<App />);
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
user.type(capacityInput, input);
const startBtn = screen.getByRole("button", { name: /Start/i });
user.click(startBtn);
expect(startBtn).toBeVisible();
const errorMsg = screen.getByText(/The capacity you have supplied isn't valid. Please enter a positive number./i)
expect(errorMsg).toBeVisible();
});
test('User can enter a valid numeric capacity', () => {
render(<App />);
const setCapacityInstructions = screen.getByText(/Please provide your venue's capacity./i);
expect(setCapacityInstructions).toBeInTheDocument();
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
user.type(capacityInput, "25");
const startBtn = screen.getByRole("button", { name: /Start/i });
user.click(startBtn);
expect(setCapacityInstructions).not.toBeInTheDocument();
expect(capacityInput).not.toBeInTheDocument();
expect(startBtn).not.toBeInTheDocument();
const updateVisitorsInstructions = screen
.getByText(/Update the venue's current visitor count with the buttons below./i)
expect(updateVisitorsInstructions).toBeVisible();
});
});
With these 4 tests, we're in a position to confidently say that our app will meet the first 3 points in our requirements. When we come to update our app in future, provided these tests still pass, we know that our app is still meeting its requirements.
Testing our limits
Before you get to the code part of it, I'd like to run you through the last of the tests I wrote for our requirements. We wanted to write tests to: verify we can't set the visitor count below 0; verify we can update the visitor count; and verify we can't set the visitor count above our venue's capacity. We can write these tests like so:
import React from 'react';
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import App from '../src/containers/App';
const clickButtonRepeatedly = async (element: HTMLElement, count: number) => {
for(let i = 0; i < count; i += 1) {
user.click(element);
}
}
describe('User can update visitors', () => {
test('User cannot set visitor count below 0', () => {
render(<App />);
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
user.type(capacityInput, "25");
const startBtn = screen.getByRole("button", { name: /Start/i });
user.click(startBtn);
const visitorCount = screen.getByText(/Your venue currently has 0 visitors/i);
expect(visitorCount).toBeVisible();
const removeVisitorBtn = screen.getByRole("button", { name: /Remove Visitor/i });
user.click(removeVisitorBtn);
expect(visitorCount).toHaveTextContent(/Your venue currently has 0 visitors/i);
});
test('User can update capacity', () => {
render(<App />);
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
user.type(capacityInput, "25");
const startBtn = screen.getByRole("button", { name: /Start/i });
user.click(startBtn);
const visitorCount = screen.getByText(/Your venue currently has 0 visitors/i);
const addVisitorBtn = screen.getByRole("button", { name: /Add Visitor/i });
clickButtonRepeatedly(addVisitorBtn, 10);
expect(visitorCount).toHaveTextContent(/Your venue currently has 10 visitors/i);
const removeVisitorBtn = screen.getByRole("button", { name: /Remove Visitor/i });
clickButtonRepeatedly(removeVisitorBtn, 3);
expect(visitorCount).toHaveTextContent(/Your venue currently has 7 visitors/i);
});
test('User cannot set visitor count above capacity', () => {
render(<App />);
const capacityInput = screen.getByRole("textbox", { name: /Capacity/i });
user.type(capacityInput, "25");
const startBtn = screen.getByRole("button", { name: /Start/i });
user.click(startBtn);
const visitorCount = screen.getByText(/Your venue currently has 0 visitors/i);
const addVisitorBtn = screen.getByRole("button", { name: /Add Visitor/i });
clickButtonRepeatedly(addVisitorBtn, 25);
expect(visitorCount).toHaveTextContent(/Your venue currently has 25 visitors/i);
user.click(addVisitorBtn);
expect(visitorCount).toHaveTextContent(/Your venue currently has 25 visitors/i);
});
});
Focusing on what functionality we have in our app makes writing our tests easier. It also gives us more confidence in our application before we've even started writing the code for it.
ℹ️ Note: You'll notice that I created a util function called
clickButtonRepeatedly
to save us having to manually click our buttons 25 times. I'd definitely encourage you to write util functions for any test functions you find repeating. Just make sure to test them!
Conclusion
Did you notice how when I said "Before you get to the code part of it", I didn't say "we"? That's because we're not going to look at my code! Instead, I'd like you to clone the repository where I'm hosting these tests and write the code get the tests passing as I've written them.
If you find that easy, you can try meeting these additional requirements we defined in a TDD approach:
- When the venue is above 80% capacity, a warning must appear informing the user how many more people are allowed in
- At 100% capacity, a severe warning must appear informing the user no more people are allowed in
The tests you write may not be unit tests, but they should still cover the branching pathways of your code.
By writing our tests from this perspective, we've seen if our tests are defined by our requirements, we know our application is behaving in the agreed and accepted way when all our tests pass.
Top comments (0)