In the world of software development, unit testing is a vital practice that ensures our code behaves as expected. It's the safety net that catches bugs before they sneak into the final product. But understanding the theory is one thing, applying it is another. That's why in this article, we're going to roll up our sleeves and dive into the practical side of unit testing.
For this first article (yes, I'm thinking of creating more steps later 👀) we are going to see my EditableTypography
component. Let's see it working to understand better:
Cool, isn't it?! You can even put your hands on it by accessing my project
Stack and source code
- React
- Typescript
- Vitest
- testing-library
Great! Let's see the source code (by the way, the project is open source):
// ...more code
const EditableTypography = ({
label,
tag,
className,
updateText, // on update text function
}: EditableTypographyTypes): JSX.Element => {
const [isEdit, setEdit] = useState(label === '');
const [newText, setNewText] = useState(label);
const handleResetState = () => {
setEdit(false);
setNewText(label);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const key = event.key;
const isEsc = key === 'Escape';
const isEnter = key === 'Enter';
if (!newText) return;
if (isEsc) {
handleResetState();
return;
}
if (!isEnter) {
return;
}
updateText(newText);
setEdit(false);
};
return (
<>
{isEdit ? (
<Styles.TextInput // styling with styled-components
// this will be used later to select the input on the test
data-testid="editable__input"
label=""
value={newText}
placeholder="Edit me"
onKeyDown={onKeyDown}
onChange={(event) => setNewText(event.target.value)}
title="Press Esc or click outside to cancel"
/>
) : (
<Styles.Typography
onClick={() => setEdit(true)}
// this will be used later to select the Typography block on the test
// I'm using diff selectors to teaching purposes
title="Click to edit"
>
<Typography
tag={tag}
label={label || 'editing...'}
className={className}
/>
</Styles.Typography>
)}
</>
);
};
OBS: I'm removing some pieces of the code to make it clear. It uses a Typography component and applies the Open-Closed Principle (from SOLID) by extending Typography functionalities instead of including the edit mode to Typography responsibilities.
Defining the test cases
There are two alternatives to define the cases we must test: imperatively
and declaratively
.
The first one is by detecting the conditions/logic in the code and the another is focused on the component behavior (usually defined with/by the UX team).
Both approaches have pros and cons and might complement each other depending on the case, but the declarative approach tends to create more descriptive tests in components case. In this article, let's go deeper into the declarative one, but also there is a section about imperative.
Imperative approach (short explanation)
On the imperative approach, I usually split out the source code to get what test. Generally, splitting the component code into two pieces before defining the tests:
- nodes conditions* (every condition in the node block)
- helpers/functions* conditions (every condition out of the node block)
*conditions are every logic in our code that can create different behaviors. It is represented by logic operators such as if, else if, &&, ||, >, <, ===
, and so on. Once the idea of the unit test is to ensure our code behaves as expected, we must guarantee that the logic created is working as we want it to.
Imperative approach - Nodes conditions
Focusing on the node block, there are 2 operators:
- ternary operator (
?:
): to validate which node is rendered by theisEdit
flag; - OR operator (
||
): to show a fallback ("editing..."
) while thelabel
is""
(it is used to handle async operations)
return (
<>
{isEdit ? ( // ternary operator
<Styles.TextInput // styling with styled-components
data-testid="editable__input"
label=""
value={newText}
placeholder="Edit me"
onKeyDown={onKeyDown}
onChange={(event) => setNewText(event.target.value)}
title="Press Esc or click outside to cancel"
/>
) : (
<Styles.Typography onClick={() => setEdit(true)} title="Click to edit">
<Typography
tag={tag}
label={label || 'editing...'} // OR operator
className={className}
/>
</Styles.Typography>
)}
</>
);
Even the ternary and OR operator have two cases for cover:
-
isEdit
can betrue
orfalse
-
label
can be""
or different of""
So, in the code block, there are 4 cases to cover. Does it mean 4 test cases are required to cover the code block? Not necessarily, once more than one condition can be executed together. In this case, for example:
- If the
isEdit
istrue
, thelabel
is not rendered (1 condition); - if the
isEdit
isfalse
, thelabel
can be""
or diff of""
(mode 2 conditions, once they are executed together)
Resuming, we could create 3 test cases to cover the four conditions.
The same should be applied to the helper/function block, but now we will define the cases using the declarative
approach and from there create the tests.
Enjoying it? If so, don't forget to give a ❤️ and follow me to keep updated. Then, I'll continue creating more content like this
Declarative approach
On the declarative approach, the test cases are created based on the component behaviors (usually, defined before it being created and with/by the Ux team). Those behaviors can be divided into 3 categories:
- Empty state: does it have props that can start "empty" (without value)?
- Failure state: can it get an error?
- Happy state: how should it behave?
In the EditableTypography
case, there is no Failure
state, but there are even Empty
and Happy
states. So, let's define them:
Empty state behavior:
- (case1) On the first render, the component should show a placeholder "Edit me" as the
label
is empty;
- (case2) On the first edit, the component should show a fallback text "editing..." while
label
is empty;
Happy state behavior:
- (case3) When the
label
has a value and the component is not in the edit mode, the component should render thelabel
value;
- (case4) When it enters the edit mode, the component should close the edit mode if the user has clicked on the Esc button and not set the new value;
- (case5) When it enters the edit mode, the component should close the edit mode if the user has clicked on the enter button and has a value to be set;
OBS: It could also be used to use the TDD approach: where the tests are created before the development has started.
Cool! Once defined the component behaviors, it is possible to create the test cases. First, let's create the titles. It is a convention to start with "should" (it is not required, obviously) and I'll use it. We are going to use the behaviors defined so far to create the test titles:
it.todo('Should show a placeholder as the label is empty - first render') // case1
it.todo('Should show a fallback text "editing..." while label is empty - first render') // case2
it.todo('Should render label as it is not edit mode') // case3
it.todo('Should close the edit mode if the user has clicked on the Esc button and not set the new value') // case4
it.todo('Should close the edit mode if the user has clicked on the enter button and has a value to be set') // case5
Cases defined, let's create the tests (finally!!)!
It starts by importing and setting which is required for all tests.
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import EditableTypography from './EditableTypography';
const user = userEvent.setup(); // userEvent instance to simulate user events (such as clicks and input data)
TIP: For the test creation, you can also think that once there is an input (WHEN) it should have an output (THEN/ASSERTION). For example:
WHEN label is empty and it is first render (user has not set data) THEN it should render the placeholder
Which comes after WHEN is the component's props and mocks, which comes after THEN is what ensures it has happened as expected
case1:
// ...more code
it('Should show a placeholder as the label is empty - first render', () => {
// WHEN label is empty and it is first render (user has not set data)
const updateTextMock = vi.fn();
render(
<EditableTypography
label="" // label is empty
tag="h1"
className="testClass"
updateText={updateTextMock}
/>
);
// selecting the input on the page
const input = screen.getByTestId('editable__input') as HTMLInputElement;
// THEN it should render the placeholder (we ensure it by expecting input has the placeholder)
expect(input).toBeInTheDocument();
expect(input.placeholder).toBe('Edit me');
});
case2:
// ...more code
it('Should show a fallback text "editing..." while label is empty - first render', async () => {
// WHEN label is empty and user has updated the value
const updateTextMock = vi.fn();
render(
<EditableTypography
label="" // label is also empty
tag="h1"
className="testClass"
updateText={updateTextMock}
/>
);
const inputElement = screen.getByTestId('editable__input');
//typing New title on the input and clicking on enter btn
await user.type(inputElement, 'New Title{enter}');
// THEN it should call the setter function (updateTextMock)
// and show 'editing...' text on the scree
expect(updateTextMock).toHaveBeenCalledWith('New Title');
expect(screen.getByText('editing...')).toBeInTheDocument();
// OBS: as updateText can be async, it might not update the label immediately.
// Once the updateTextMock is only a mock without handling the state, it will not update the label,
// which is totally fine to the test once updateText logic is outside EditableTypography responsibilities.
});
case3:
// ...more code
it('Should render label as it is not edit mode', () => {
// WHEN label has value
const updateTextMock = vi.fn();
render(
<EditableTypography
label="Old title" // now it has a value
tag="h1"
className="testClass"
updateText={updateTextMock}
/>
);
// it is using queryByTestId instead of getByTestId
// because query* is recommend to case where the element is not in the page
// (and also get* does not work in this cases xP)
const inputElement = screen.queryByTestId('editable__input');
// THEN show the label value and do not show the input
expect(screen.getByText('Old title')).toBeInTheDocument();
expect(inputElement).not.toBeInTheDocument();
});
case4:
// ...more code
it('Should close the edit mode if the user has clicked on the Esc button and not set the new value', async () => {
// WHEN label has value and user has clicked on shift btn
const updateTextMock = vi.fn();
render(
<EditableTypography
label="Old title"
tag="h1"
className="testClass"
updateText={updateTextMock}
/>
);
const typographyElement = screen.getByTitle('Click to edit');
// it enters the edit mode
await user.click(typographyElement);
const inputElement = screen.getByTestId('editable__input');
// it adds "New Title" and tries to click on shift (shift btn does not do anything - only enter)
await user.type(inputElement, 'New Title{shift}');
// THEN the edit mode keeps on
expect(updateTextMock).not.toHaveBeenCalled();
expect(inputElement).toBeInTheDocument();
// WHEN label has value and user has clicked on esc btn
await user.type(inputElement, '{escape}');
// THEN the edit mode has closed and the old label value is showed
expect(updateTextMock).not.toHaveBeenCalled();
expect(inputElement).not.toBeInTheDocument();
expect(screen.getByText('Old title')).toBeInTheDocument();
});
case5:
// ...more code
it('Should close the edit mode if the user has clicked on the enter button and has a value to be set', async () => {
// WHEN label has value and user has updated the value
const updateTextMock = vi.fn();
render(
<EditableTypography
label="Old title"
tag="h1"
className="testClass"
updateText={updateTextMock}
/>
);
const typographyElement = screen.getByTitle('Click to edit');
// it enters the edit mode
await user.click(typographyElement);
const inputElement = screen.getByTestId('editable__input');
// it clears the input (once the there is Old title label)
await user.clear(inputElement);
// it adds "New Title" and enters
await user.type(inputElement, 'New Title{enter}');
// THEN the edit mode has closed and the new label value is shown
expect(inputElement).not.toBeInTheDocument();
expect(updateTextMock).toHaveBeenCalledWith('New Title');
});
Nice!! We did it!
Checking the local coverage report file
If the tester engine has coverage configured, it generates a report file with information regarding code coverage (it means, how much the test created validates that component's logic). It is helpful to ensure that the tests created are validating the logic in the component.
Generally, this file (there are different formats, such as .html) is in the ./coverage folder at the project's root.
What a great! We have got 100% code coverage (I might create a article regarding coverage in the future 👀).
Buy me a coffee ☕. Hope I have helped you somehow. 🤗
Next steps
In the next article (in progress), let's explore more behaviors to the EditableTypography
: click outside (clicking outside might close the edit mode) and focus/select(focus in the text field and select all label text when entered in the edit mode)
Follow me to let you know.
You can find the final file version here