TDD, simple functions, and React components
This is part of my studies on how to build sustainable and consistent software. In this post, we will talk about the thinking behind the testing driven development and how to apply this knowledge to simple functions, web accessibility, and React components, mostly with Jest and React Testing Library.
Automated tests are a big part of software development. It gives us, developers, confidence to ship code to be there, but we increase the confidence that the software will be up and running and working appropriately.
I began my software career in the Ruby community writing tests from the first day I learned the language. The Ruby (and Rails) community was always strong in the testing automation area. It helped shape my mindset on how to write good software.
So using Ruby and Rails, I did a lot of backend stuff like background jobs, data structure modeling, API building, and so on. In this scope, the user is always one: the developer user. If building an API, the user would be the developer that's consuming the API. If building the models, the user would be the developer that will use this model.
Now doing a lof of frontend stuff too, after 1 intense year of building PWAs using mostly React and Redux, at first some thoughts came to my mind:
- TDD is impossible when building UI stuff. How do I know if it is a div or span?
- Testing can be "complex". Should I shallow or should I mount? Test everything? Ensure every div should be the right place?
So I started re-thinking about these testing practices and how to make it productive.
TDD is possible. If I'm wondering if I should expect a div or a span, I'm probably testing the wrong thing. Remember: tests should give us the confidence to ship, not necessarily to cover every bit or implementation details. We will dive into this topic later!
I want to build tests that:
- Ensure the software works appropriately
- Give the confidence to ship code to production
- Make us think about software design
And tests that make software:
- Easy to maintain
- Easy to refactor
Testing Driven Development
TDD shouldn't be complex. It is just a process of 3 steps:
- Make a test
- Make it run
- Make it right
We start writing a simple test to cover how we expect the software works. Then we make the first implementation of the code (class, function, script, etc). Now the software is behaving. It works as expected. Time to make it right. Time to make it better.
The goal is a clean code that works. We solve the "that works" problem first and then make the code clean.
It is pretty simple. And it should be. I didn't say it is easy. But it is simple, straightforward, just 3 steps. Every time you exercise this process of writing tests first, code after, and then refactoring, you feel more confident.
One good technique when writing your tests first is to think about use cases and simulate how it should be used (as a function, component, or used by a real user).
Functions
Let's apply this TDD thing into simple functions.
Some time ago I was implementing a draft feature for a real estate registration flow. Part of the feature was to show a modal if the user had a not finished real estate. The function we will implement is the one that answers if the user has at least one real estate draft.
So first step: writing the test! Let's think of the use cases of this function. It always responds a boolean: true or false.
- Has no unsaved real estate draft:
false
- Has at least one unsaved real estate draft:
true
Let's write the tests that represent this behavior:
describe('hasRealEstateDraft', () => {
describe('with real estate drafts', () => {
it('returns true', () => {
const realEstateDrafts = [
{
address: 'São Paulo',
status: 'UNSAVED',
},
];
expect(hasRealEstateDraft(realEstateDrafts)).toBeTruthy();
});
});
describe('with not drafts', () => {
it('returns false', () => {
expect(hasRealEstateDraft([])).toBeFalsy();
});
});
});
We wrote the tests. But when running it, it shows go red: 2 broken tests because we do not have the function implemented yet.
Second step: make it run! In this case, it is pretty simple. We need to receive this array object and return if it has or hasn't at least one real estate draft.
const hasRealEstateDraft = (realEstateDrafts) => realEstateDrafts.length > 0;
Great! Simple function. Simple tests. We could go to step 3: make it right! But in this case, our function is really simple and we've already got it right.
But now we need the function to get the real estate drafts and pass it to the hasRealEstateDraft
.
Which use case we can think of?
- An empty list of real estates
- Only saved real estates
- Only unsaved real estates
- Mixed: save and unsaved real estates
Let's write the tests to represent it:
describe('getRealEstateDrafts', () => {
describe('with an empty list', () => {
it('returns an empty list', () => {
const realEstates = [];
expect(getRealEstateDrafts(realEstates)).toMatchObject([]);
});
});
describe('with only unsaved real estates', () => {
it('returns the drafts', () => {
const realEstates = [
{
address: 'São Paulo',
status: 'UNSAVED',
},
{
address: 'Tokyo',
status: 'UNSAVED',
},
];
expect(getRealEstateDrafts(realEstates)).toMatchObject(realEstates);
});
});
describe('with only saved real estates', () => {
it('returns an empty list', () => {
const realEstates = [
{
address: 'São Paulo',
status: 'SAVED',
},
{
address: 'Tokyo',
status: 'SAVED',
},
];
expect(getRealEstateDrafts(realEstates)).toMatchObject([]);
});
});
describe('with saved and unsaved real estates', () => {
it('returns the drafts', () => {
const realEstates = [
{
address: 'São Paulo',
status: 'SAVED',
},
{
address: 'Tokyo',
status: 'UNSAVED',
},
];
expect(getRealEstateDrafts(realEstates)).toMatchObject([
{
address: 'Tokyo',
status: 'UNSAVED',
},
]);
});
});
});
Great! We run the tests. It doesn't work.. yet! Now implement the function.
const getRealEstatesDrafts = (realEstates) => {
const unsavedRealEstates = realEstates.filter(
(realEstate) => realEstate.status === 'UNSAVED',
);
return unsavedRealEstates;
};
We simply filter by the real estate status and return it. Great, the tests are passing, the bar is green! And the software is behaving, but we can make it better: step 3!
What about extracting the anonymous function within the filter
function and make the 'UNSAVED'
be represented by an enum?
const STATUS = {
UNSAVED: 'UNSAVED',
SAVED: 'SAVED',
};
const byUnsaved = (realEstate) => realEstate.status === STATUS.UNSAVED;
const getRealEstatesDrafts = (realEstates) => realEstates.filter(byUnsaved);
The tests are still passing and we have a better solution.
One thing to have in mind here: I isolated the data source from the logic. What does it mean? We get the data from local storage (data source), but we test only the functions responsible to the logic to get drafts and see if it has at least one draft. The functions with the logic, we ensure that it works and it is clean code.
If we get the localStorage
inside our functions, it becomes hard to test. So we separate the responsibility and make the tests easy to write. Pure functions are easier to maintain and simpler to write tests.
React Components
Now let's talk about React components. Back to the introduction, we talked about writing tests that test implementation details. And now we will see how we can make it better, more sustainable, and have more confidence.
A couple of days ago I was planning to build the new onboarding information for the real estate owner. It is basically a bunch of pages with the same design, but it changes the icon, title, and description of the pages.
I wanted to build just one component: Content
and pass the information needed to render the correct icon, title, and description. I would pass businessContext
and step
as props and it would render the correct content to the onboarding page.
We don't want to know if we will render a div or paragraph tag. Our test needs to ensure that for a given business context and step, the correct content will be there. So I came with these use cases:
- The first step of the rental business context
- Last step of the rental business context
- The first step of the sales business context
- Last step of the sales business context
Let's see the tests:
describe('Content', () => {
describe('in the rental context', () => {
const defaultProps = {
businessContext: BUSINESS_CONTEXT.RENTAL,
};
it('renders the title and description for the first step', () => {
const step = 0;
const { getByText } = render(<Content {...defaultProps} step={step} />);
expect(getByText('the first step title')).toBeInTheDocument();
expect(getByText('the first step description')).toBeInTheDocument();
});
it('renders the title and description for the forth step', () => {
const step = 3;
const { getByText } = render(<Content {...defaultProps} step={step} />);
expect(getByText('the last step title')).toBeInTheDocument();
expect(getByText('the last step description')).toBeInTheDocument();
});
});
describe('in the sales context', () => {
const defaultProps = {
businessContext: BUSINESS_CONTEXT.SALE,
};
it('renders the title and description for the first step', () => {
const step = 0;
const { getByText } = render(<Content {...defaultProps} step={step} />);
expect(getByText('the first step title')).toBeInTheDocument();
expect(getByText('the first step description')).toBeInTheDocument();
});
it('renders the title and description for the last step', () => {
const step = 6;
const { getByText } = render(<Content {...defaultProps} step={step} />);
expect(getByText('the last step title')).toBeInTheDocument();
expect(getByText('the last step description')).toBeInTheDocument();
});
});
});
We have one describe
block for each business context and an it
block for each step. I also created an accessibility test to ensure the component we are building is accessible.
it('has not accessibility violations', async () => {
const props = {
businessContext: BUSINESS_CONTEXT.SALE,
step: 0,
};
const { container } = render(<Content {...props} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Now we need to make it run! Basically, the UI part of this component is just the icon, the title, and the description. Something like:
<Fragment>
<Icon />
<h1>{title}</h1>
<p>{description}</p>
</Fragment>
We just need to build the logic to get all these correct data. As I have the businessContext
and the step
in this component, I wanted to just do something like
content[businessContext][step];
And it gets the correct content. So I built a data structure to work that way.
const onboardingStepsContent = {
alugar: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
vender: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
};
It's just an object with the first keys as the business context data and for each business context, it has keys that represent each step of the onboarding. And our component would be:
const Content = ({ businessContext, step }) => {
const onboardingStepsContent = {
alugar: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
vender: {
0: {
Icon: Home,
title: 'first step title',
description: 'first step description',
},
// ...
},
};
const { Icon, title, description } =
onboardingStepsContent[businessContext][step];
return (
<Fragment>
<Icon />
<h1>{title}</h1>
<p>{description}</p>
</Fragment>
);
};
It works! Now let's make it better. I wanted to make the get content more resilient. What if it receives a step that doesn't exist for example? These are the use cases:
- The first step of the rental business context
- Last step of the rental business context
- The first step of the sales business context
- Last step of the sales business context
- Inexistent step of the rental business context
- Inexistent step of the sales business context
Let's see the tests:
describe('getOnboardingStepContent', () => {
describe('when it receives existent businessContext and step', () => {
it('returns the correct content for the step in "alugar" businessContext', () => {
const businessContext = 'alugar';
const step = 0;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject(
{
Icon: Home,
title: 'first step title',
description: 'first step description',
},
);
});
it('returns the correct content for the step in "vender" businessContext', () => {
const businessContext = 'vender';
const step = 5;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject(
{
Icon: ContractSign,
title: 'last step title',
description: 'last step description',
},
);
});
});
describe('when it receives inexistent step for a given businessContext', () => {
it('returns the first step of "alugar" businessContext', () => {
const businessContext = 'alugar';
const step = 7;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject(
{
Icon: Home,
title: 'first step title',
description: 'first step description',
},
);
});
it('returns the first step of "vender" businessContext', () => {
const businessContext = 'vender';
const step = 10;
expect(getOnboardingStepContent({ businessContext, step })).toMatchObject(
{
Icon: Home,
title: 'first step title',
description: 'first step description',
},
);
});
});
});
Great! Now let's build our getOnboardingStepContent
function to handle this logic.
const getOnboardingStepContent = ({ businessContext, step }) => {
const content = onboardingStepsContent[businessContext][step];
return content ? content : onboardingStepsContent[businessContext][0];
};
We try to get content. If we have it, just return it. If we don't have it, return the first step of the onboarding.
Neat! But we can improve it. What about using the ||
operator? No need to assign to a variable, no need to use a ternary.
const getOnboardingStepContent = ({ businessContext, step }) =>
onboardingStepsContent[businessContext][step] ||
onboardingStepsContent[businessContext][0];
If it finds the content, just return it. If it didn't find, return the first step of the given business context.
Now our component is only UI.
const Content = ({ businessContext, step }) => {
const { Icon, title, description } = getOnboardingStepContent({
businessContext,
step,
});
return (
<Fragment>
<Icon />
<h1>{title}</h1>
<p>{description}</p>
</Fragment>
);
};
Final thoughts
I like to think deeply about the tests I'm writing. And I think all developers should too. It does need to give us the confidence to ship more code and have a bigger impact on the market we are working on.
Like all code, when we write smelly and bad tests, it influences other developers to follow the "pattern". It gets worse in bigger companies. It scales badly. But we are always able to stop, reflect on the status quo, and take action to make it better.
I shared some resources I found interesting reading and learning. If you want to get a great introduction to TDD, I really recommend TDD by example, a book from Kent Beck.
I will write more about tests, TDD, and React. And how we can make our software more consistent and feel safe when shipping code to production.
Dependencies
- jest-axe: jest matchers for testing accessibility
- testing-library/react-testing-library: testing utilities to help test react
- testing-library/jest-dom: jest matchers to test the state of the DOM
Resources
- Beginner JavaScript Course
- React for Beginners Course
- Advanced React Course
- ES6 Course
- The Road to learn React
- JavaScript Fundamentals Before Learning React
- Reintroducing React: V16 and Beyond
- Advanced React Patterns With Hooks
- Practical Redux
- JavaScript Course by OneMonth
- Test Driven Development by example book by Kent Beck
- Testable Javascript book by Mark Ethan Trostler
- Blog post source code
- Testing React applications with jest, jest-axe, and react-testing-library
- Modern React testing, part 3: Jest and React Testing Library
- What we found when we tested tools on the world’s least-accessible webpage
- Testing Implementation Details
- Learn React by building an App