Appearance
Chapter 5 - Intro to Unit Testing While Refactoring a Bit
We will now delve into writing unit tests for our project. Unit tests serve as a critical aspect of ensuring the stability and reliability of our code. In this book, we will cover two main categories of unit tests:
- Unit tests for models, classes, structures, and interfaces (such as the API client and helpers)
- Unit tests for React components
Note: It's worth mentioning that there is a third category of tests, known as end-to-end (e2e) tests, but we will not be covering those in this book.
Our first step will be to write unit tests for our React components. We will start with the ItemsList component and while doing so, we will make some refactors to improve its implementation. The unit tests will validate the changes we make, ensuring that our code remains functional and free of bugs.
ItemComponent
Remember how in our ItemsList component we have a loop that creates <li> elements, one for each item in our items property? Let's extract the code for the <li> element and create a child component just for that. Let's start by adding a new file called Item.component.tsx under the src/components/items/children directory:
Paste the following code in the Item.component.tsx file:
tsx
// file: Item.component.tsx
import React from 'react'
// import reference to our interface
import { ItemInterface } from '../../../models/items/Item.interface'
// component props type:
type Props = {
testid: string
model: ItemInterface,
onItemSelect: (item: ItemInterface) => void
}
// example using class syntax
export class ItemComponent extends React.Component<Props> {
constructor(props: Props) {
super(props)
}
get cssClass () {
let css = 'item'
if (this.props.model?.selected) {
css += ' selected'
}
return css.trim()
}
handleItemClick (item: ItemInterface) {
this.props.onItemSelect(item)
}
render(): React.ReactNode {
const { model } = this.props
const testid = this.props.testid || 'not-set'
return (
<li data-testid={testid} className={this.cssClass} onClick={() => this.handleItemClick(model)}>
<div className="selected-indicator">*</div>
<div className="name">{model.name}</div>
</li>
)
}
}
Note: we added also a testid property that will bind to the data-testid property of the outer html DOM element of our component. This will make it easier to select the element during the unit tests or automation tests.
We just created a template for a single <li> element. We also enhanced this a bit by replacing the rendering of the name with binding { item.name } with two child <div> elements:
- one to display the Item name
- one that will show a star icon (we are just using a char here, but in the next chapters we'll be replacing this with real icons from the font library material-icons)
Then we added a computed property called cssClass that will return the string "item" or "item selected". We then bind this to the <li> className attribute, based on whether the model.selected property is true or false: <li className={this.cssClass} onClick={() => this.handleItemClick(model)}>
This will have the effect to render the <li> element in two possible ways:
- <li class="item"> (when not selected)
- <li class="item selected"> (when selected)
We also bind to the click event with onClick binding and in the local onClick handler we just invoke the parent handler through the prop onItemSelect and pass it the model as the argument (props.model). We will then handle this in the parent component (ItemsList component as before).
App.css
Let's also replace the content of the file App.css with this quick-and-dirty css:
css
/* file: App.css */
.App {
padding: 20px;
}
ul {
padding-inline-start: 0;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 0px;
}
li.item {
padding: 5px;
outline: solid 1px #eee;
display: flex;
align-items: center;
height: 30px;
cursor: pointer;
transition: background-color 0.3s ease;
}
li.item .name {
margin-left: 6px;
}
li.item .selected-indicator {
font-size: 2em;
line-height: 0.5em;
margin: 10px 8px 0 8px;
color: lightgray;
}
li.item.selected .selected-indicator {
color: skyblue;
}
li.item:hover {
background-color: #eee;
}
Note: the css above is just a quick-and-dirty bit of styling so we can make our list look a bit prettier for now. In later chapters we'll introduce TailwindCSS and keep working with that instead of writing our own css.
Within the src/App.tsx you went to restore/uncomment the line we commented out or removed in earlier chapters:
tsx
// file: src/App.tsx
import './App.css' // <-- restore this import
...
Install npm dependencies for unit tests
Let's install Vitest and other npm libraries we need to be able to run the unit tests:
bash
npm i -D vitest jsdom @testing-library/react @testing-library/user-event @types/jest
Configuration
Now we need to configure a few things to be able to run unit tests.
tsconfig.json file
Add "vite/client" and "vitest/globals" to tsconfig.json compilerOptions types:
json
// file: my-react-project/tsconfig.json
...
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"react",
"vite/client",
"vitest/globals"
]
...
vite.config.js files
Add "test" section with the following settings to the vite.config.js files:
json
// file: my-react-project/vite.config.js (and any other vite.config.xyz.js file)
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react()
],
test: {
globals: true,
environment: 'jsdom',
exclude: [
'node_modules'
]
}
})
package.json
Within the package.json file, add the following command shortcuts within the script section:
json
...
"scripts": {
...
"test": "vitest run",
"test-watch": "npm run test -- --watch"
}
...
ItemComponent Unit Tests
Now, let's add our first unit tests for our newly created component.
Within the same directory where our Item.component.tsx is located, add two new files:
- one called Item.rendering.test.tsx
- one called Item.behavior.test.tsx
Your directory structure will look now ike this:
NOTE: Jest has become quite old at this point and hard to work with epecially in Vite as it requires a lot of dependencies and setup. I strongly suggest to use Vitest[1] and added a bonus chapter at the end of the book with instructions on how to do this. Furthermore, additional unit tests will add in the more advanced chapters will be using Vitest.
Item.rendering.test.tsx
Open the file Item.rendering.test.tsx and paste the following code in it:
tsx
// file: Item.rendering.test.tsx
import { render, screen, prettyDOM } from '@testing-library/react'
// import reference to our interface
import { ItemInterface } from '../../../models/items/Item.interface'
// import reference to your Item component:
import { ItemComponent } from './Item.component'
describe('Item.component: rendering' , () => {
it('renders an Item text correctly', () => {
const testid = 'unit-test-item'
const model: ItemInterface = {
id: 1,
name: 'Unit test item 1',
selected: false
}
// render component
render(<ItemComponent testid={testid} model={model} onItemSelect={() => {}} />)
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// get element children
const children = liElement.children
expect(children).toHaveLength(2)
expect(children.item(1)?.innerHTML).toContain('Unit test item 1')
})
it('renders an Item indicator correctly', () => {
const testid = 'unit-test-item'
const model: ItemInterface = {
id: 1,
name: 'Unit test item 2',
selected: false
}
// render component
render(<ItemComponent testid={testid} model={model} onItemSelect={() => {}} />)
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// get element children
const children = liElement.children
expect(children).toHaveLength(2)
expect(children.item(0)?.innerHTML).toEqual('*')
})
// we'll add more here in a second
})
...
Note: we are leveraging here the React testing-library[2], make sure you install the necessary dependencies (see the repository for the book code on GitHub).
We test that the component renders the data model properties as expected. For now, we are checking if the entire text rendered by the component contains the model.name and also that there is an element rendering the *. This is not very precise as our component later might render additional labels and our test might match these instead resulting in possible false positives.
Note: These example are just to get you started. Later you can look at more precise ways to test what our component has rendered or even trigger events on them.
Run our unit tests from the terminal with this command:
bash
npm run test
It should run the unit tests and print the results on the terminal, similar to this:
bash
> vitest run
RUN v0.23.4 /my-react-project/
✓ src/components/items/children/Item.rendering.test.tsx (2)
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 08:45:41
Duration 829ms (transform 269ms, setup 0ms, collect 132ms, tests 15ms)
Let's add two more tests within the same file to check that the component has the expected CSS classes. Test to check that it has the class "selected" when item.selected is true, and that does NOT have the css class "selected" when item.selected is false:
tsx
// file: Item.rendering.test.tsx
...
describe('Item.component: rendering' , () => {
...
it('has expected css class when selected is true', () => {
const testid = 'unit-test-item'
const model: ItemInterface = {
id: 1,
name: 'Unit test item 3',
selected: true
}
// render component
render(<ItemComponent testid={testid} model={model} onItemSelect={() => {}} />)
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// check that the element class attribute has the expected value
expect(liElement.className).toContain('selected')
})
it('has expected css class when selected is false', () => {
const testid = 'unit-test-item'
const model: ItemInterface = {
id: 1,
name: 'Unit test item 3',
selected: false
}
// render component
render(<ItemComponent testid={testid} model={model} onItemSelect={() => {}} />)
// get element reference by testid
const liElement = screen.getByTestId(testid)
// test
expect(liElement).not.toBeNull()
// check that the element class attribute does not contain 'selected'
expect(liElement.className).not.toContain('selected')
})
})
Item.behavior.test.tsx
We can also test the behavior of our component by programmatifcally triggering the onClick event. Let's open the file Item.behavior.test.tsx and paste the following code in it:
tsx
// file: Item.behavior.test.tsx
import { render, fireEvent, prettyDOM } from '@testing-library/react'
// import reference to our interface
import { ItemInterface } from '../../../models/items/Item.interface'
// import reference to your Item component:
import { ItemComponent } from './Item.component'
describe('Item.component: behavior' , () => {
// test our component click event
it('click event invokes onItemSelect handler as expected', () => {
const model: ItemInterface = {
id: 1,
name: 'Unit test item 1',
selected: false
}
// create a spy function with vitest.fn()
const onItemSelect = vitest.fn()
const testid = 'unit-test-item'
// render our component
const { container } = render(<ItemComponent testid={testid} model={model} onItemSelect={onItemSelect} />)
// get a reference to the <li> element
const liElement = container.firstChild as HTMLElement
// fire click
fireEvent.click(liElement)
// check test result
expect(onItemSelect).toHaveBeenCalledTimes(1)
})
})
Save and check the test results and make sure all pass (if you had stopped it, run npm run test
again).
ItemsList component
Now we can finally modify our ItemsList.component.tsx to consume our newly created Item component. Import a reference to ItemComponent, then replace the return section within the items.map to use our component instead of the <li> element:
tsx
// file: ItemsList.component.tsx
import React from 'react'
// import reference to our interface
import { ItemInterface } from '../../models/items/Item.interface'
// import reference to your Item component:
import { ItemComponent } from './children/Item.component'
// if using class syntax:
type Props = {
items: ItemInterface[],
onItemSelect: (item: ItemInterface) => void
}
export class ItemsListComponent extends React.Component<Props> {
constructor(props: Props) {
super(props)
}
handleItemClick (item: ItemInterface) {
this.props.onItemSelect(item)
}
render(): React.ReactNode {
const { items } = this.props
return (
<div>
<h3>Items:</h3>
<ul>
{
items.map((item: any, index: number) => {
// remove this line:
// return <li key={index} onClick={() => this.handleItemClick(item)}>{item.name}</li>
// replace with this line that replaces <li> with <ItemComponent>:
return <ItemComponent testid={`item-${ item.id }`} key={index} model={item} onItemSelect={() => this.handleItemClick(item)}></ItemComponent>
})
}
</ul>
</div>
)
}
}
// if using function syntax:
type Props = {
items: ItemInterface[],
onItemSelect: (item: ItemInterface) => void
}
export const ItemsListComponent: React.FC<Props> = (props) => {
const handleItemClick = (item: ItemInterface) => {
props.onItemSelect(item)
}
return (
<div>
<h3>Items:</h3>
<ul>
{
props.items.map((item, index) => {
// remove this return block:
// return (
// <li key={index}
// onClick={() => handleItemClick(item)}>
// {item.name} [{ String(item.selected) }] {/* output item.selected next to the name */}
// </li>
// )
// add this return block:
return (
<ItemComponent testid={`item-${ item.id }`} key={index} model={item} onItemSelect={() => handleItemClick(item)}></ItemComponent>
)
})
}
</ul>
</div>
)
}
In the web browser, the list should now render similar to this (here we are showing it after we clicked on the 2nd item element and is now selected):
Chapter 5 Recap
What We Learned
- How to write unit tests against a component
- How to test that components render specific DOM elements, or have specific text, or attributes like CSS classes, etc.
- How to test events on our components by programmatically triggering them with fireEvent (from React Testing Library)
- How to re-factor parts of a component to create a child component and use unit tests to validate our changes
Observations
- We did not test our ItemsList.component.tsx or more advanced behaviors
Based on these observations, there are a few improvements that you could make:
Improvements
- Add additional unit tests for ItemsList.component.tsx as well