TDD (Test Driven Development) Basics Tutorial with TypeScript/React
You might have heard of TDD. But you haven't tried. Then you should do it.
TDD lets you write better code. This is one of the methods every developer must acquire.
We all need habits to do things better. Running, waking up early, and eating well, all are fundamental for our well-being and productivity. Lifestyle affects how happy we can spend our lives.
For us, coding matters. We need to establish good habits in coding. We all need to practice it. It's TDD.
Though TDD is tough at first. It requires you to change your way of thinking. But after all of the practice, you'll find yourself more comfortable writing code.
In this post, I will introduce you to how to develop in a TDD way, with a small React project. Now let's get Test-Driven.
What is TDD? Why is it Recommended?
Test Driven Development is a method of development. TDD encourages you to write the test first, and thereafter implement your concern.
Why TDD? It's got the following benefits:
- Immediate feedback while developing, resulting less debugging hours.
- Testable code structure leads to better decoupling.
- Other developers will understand your code more easily. Tests are "live documents".
- You can have a good grasp on what you are going to implement.
TDD is rather beneficial in the long run. You might not get immediate benefits and even find it frustrating (since you have to write 2x the amount of code), though TDD finally pays off. That's why it is highly recommended.
How to develop in TDD
TDD is great but can be tough at first. Some people might find it difficult even to write tests, so you should practice it gradually.
These 4 steps are what I think are necessary for learning TDD.
- Learn Jest
- Learn React Testing Library
- Learn the process of TDD
- Learn to "think" in the TDD way
I'm gonna show you step by step. Clone the repository, and switch to the starter
branch.
git clone git@github.com:yozibak/tdd-react.git
git switch starter
yarn install
All 4 exercises have their own tasks. And you can check the answers for exercises by switching branches (git switch ex1/answer
).
The project is a simple application for a product review page. Run yarn start
and check what it looks like. Take a moment and learn what it's currently doing. If you've done, move on to the first exercise.
Basics of Jest
First, try these tasks to get used to jest
.
- Write sumArr() function that returns the sum of the incoming array of int.
- Test sumArr() with jest.
- Change implementation of sumArr(), assuring result running test.
You might have written something like this.
// module/domain.ts
export const sumArr = (arr: number[]) => {
let sum = 0
for (const num of arr) {
sum += num
}
return sum
}
How you implement sumArr()
doesn't matter here. Now let's write the test for sumArr()
.
// module/domain.test.ts
import { sumArr } from "./domain"
test("sumArr retuns sum of numbers", () => {
const arr = [1,2,3]
const result = sumArr(arr)
expect(result).toBe(6)
})
Run yarn test
. OK. It passes.
Now, notice Jest hasn't exited yet. If you change some files and save now, jest runs tests again, and notifies you've broken something or not. This is greatly helpful when you refactor code.
So, why not refactor the sumArr()
we've written? It can be more elegant. While running jest, make changes to the implementation. See the test runs again. Did it pass? Great!
This is it. This is how cool it is to have tests. Tests are "invariant", which means, you don't have to change as long as requirements don't change. On the other hand, your implementation is what's changing alongside development.
So, having tests before changing code is very helpful for your development cycle.
Remember, Test -> Refactor
is a very important factor in TDD.
Basics of React Testing Library
We need to test not only pure functions but React components. We use React Testing Library
to assure the component's render result.
Our tasks here:
- Write test to ensure page title is shown
- Write test to ensure current number of reviews is shown
- Write test to ensure form submission is properly reflecting input values
First, the page title is static content, so you need to just render it. And just find what you expect.
import React from 'react'
import { render } from '@testing-library/react'
import App from './App'
describe("UI test", () => {
it("should show page title", () => {
// arrange
render(<App />)
// assert
const title = screen.getByText(/Product Review Form/)
expect(title).toBeInTheDocument()
})
})
Run yarn test
to see if it passes. Well, this is warming up.
In the previous test, I actually omitted some important setups. This example app uses "pseudo" API to call data. So it runs perfectly in the testing environment. But in the real-world project that actually calls to API via fetch
, axios
or AWS things, you need to resolve those outer dependencies. Or the test suite throws an error.
Fortunately, our repository already uses a custom data hook (useReviews
) to manage outer data. So we can just mock it out before each test.
import React from 'react'
import { act, render, screen } from '@testing-library/react'
import App from './App'
import * as review from './hooks/reviews'
import userEvent from '@testing-library/user-event'
describe("UI test", () => {
// Assume we are having these data on hook's state.
const mockReviews:Review[] = [
{
title: 'test',
score: 0,
comment: 'hi there'
}
]
// By mocking functions, we don't have to actually call the API.
// Instead just check the "happening" for this function during tests.
const submitFn = jest.fn()
// This is what we want `useReviews` to return in the tests.
const mockUseReview: ReturnType<typeof review.useReviews> = {
loading: false,
reviews: mockReviews,
submitReview: submitFn
}
// Having mock instance as a variable lets us manipulate mocked value later.
let useReviews: jest.SpyInstance
// CRA creates {resetMocks: true} as jest settings,
// So we need to mock before each test case. (or could change jest config)
beforeEach(() => {
useReviews = jest.spyOn(review, 'useReviews').mockReturnValue(mockUseReview)
submitFn.mockResolvedValue(true)
})
it("should show page title", () => {
...
})
})
By mocking dependencies, we can test the component with different "situations". Let's see if it solves the second task.
describe("UI test", () => {
...
it("should show page title", () => {
...
})
it("should show num of current reviews", () => {
// arrange
render(<App />)
// assert
screen.getByText(/Current reviews: 1/)
// arrange
mockUseReview.reviews = [...mockReviews, { title: 'review2', score: 30, comment: '...'}]
render(<App />)
// assert
screen.getByText(/Current reviews: 2/)
})
})
In the second render, we provided mocked object 2 reviews. So while rendering our App
component receives 2 reviews from the useReviews
hook. See the DOM reflecting it.
Now, the third one. Make sure the form properly submits input values.
describe("..."() => {
...
it("should submit input values properly", async () => {
// We don't have `Window` in jsdom (i.e. testing environment).
// We make it mock and later check the calls on it
const windowAlert = jest.fn()
window.alert = windowAlert
render(<App />)
// You can have multiple ways to access the target element.
// ByPlaceHolder and ByLabelText are useful for forms
const titleInput = screen.getByLabelText('Title')
const scoreInput = screen.getByLabelText('Score')
const commentInput = screen.getByLabelText('Comment')
// Wrap act when it is state-affecting actions. Or it throws warning.
// This behavior depends on your userEvent library version,
// so you might have to google around, just beware
await act(async () => {
userEvent.type(titleInput, 'Very Good')
userEvent.type(scoreInput, '90')
userEvent.type(commentInput, 'I liked it!')
userEvent.click(screen.getByRole('button', {name: /submit/i}))
})
// assert
expect(submitFn).toHaveBeenCalledTimes(1)
expect(submitFn).toHaveBeenCalledWith({
title: 'Very Good',
score: 90,
comment: 'I liked it!',
})
expect(windowAlert).toHaveBeenCalledWith(`Successfully submitted. Thank you!`)
expect(titleInput).toHaveValue('')
expect(scoreInput).toHaveValue(0)
expect(commentInput).toHaveValue('')
})
})
We acted like we do on the actual UI, though it doesn't call the API, nor change reviews
state within the data hooks (useReviews
).
This sort of separation is very common in testing. Otherwise, it becomes very tedious to test the specific "situation". Testability means mostly separation of responsibilities. UI component only renders data and binds events. Handling data is another responsibility. So these are better to be separated and tested individually. Keep this in mind and implement new features in our app.
Basics of TDD
We have the form on the UI, but there's no validation on it. Our boss notices that and told us to implement it. Well, it's not that difficult.
But remember, this "easy patch" is the beginning of legacy code. The code for this new feature might mess up things afterward. And if there are no tests on that feature, the team could hardly touch it. Because we're all afraid of breaking things. What else is the legacy code?
Besides, we already have a working feature (form submission) and we don't want to break it while developing the new feature. This is why we wrote tests for it beforehand. If the part you're working on has no tests, you'd better start with testing it.
Enough ranting. The requirements for the validation are:
- title < 50 characters
- score 0 ~ 100 pts
- comment < 400 characters
Start off with writing 'failing tests' for these requirements. The first step of TDD.
// App.test.tsx
describe("UI test", () => {
...
it("should submit input values properly", async () => {
...
})
// `describe` can be nested. use it when separating requirement for multiple cases
describe("validate input character length on form submission", () => {
let windowAlert: jest.Mock
beforeEach(() => {
windowAlert = jest.fn()
window.alert = windowAlert
})
// repeatable act can be extracted like this
const findInput = () => ({
title: screen.getByLabelText('Title'),
score: screen.getByLabelText('Score'),
comment: screen.getByLabelText('Comment'),
})
const payload:Review = {
title: 'title',
score: 0,
comment: 'comment'
}
// ReturnType<typeof Fn> is very useful when extracting interface.
const submitForm = async (form: ReturnType<typeof findInput>, payload: Review) => {
await act(async () => {
userEvent.type(form.title, payload.title)
userEvent.type(form.score, payload.score.toString())
userEvent.type(form.comment, payload.comment)
userEvent.click(screen.getByRole('button', {name: /submit/i}))
})
}
// These cases are a little repetitive, so you might want to extract them as testing hook.
// I'd consider when 4>times of repeat. Juest left as it is for demonstration.
it("should accept < 50 characters on title", async () => {
// arrange
render(<App />)
const form = findInput()
// act
await submitForm(form, {
...payload,
title: 'X'.repeat(51) // > 50
})
// assert
expect(windowAlert).toHaveBeenCalledWith(`Please enter title in less than 50 characters`)
expect(submitFn).not.toHaveBeenCalled()
})
it("should accept points between 0 ~ 100 on score", async () => {
// arrange
render(<App />)
const form = findInput()
// act
await submitForm(form, {
...payload,
score: 120 // > 100
})
// assert
expect(windowAlert).toHaveBeenCalledWith(`Please enter score less than or equal to 100`)
expect(submitFn).not.toHaveBeenCalled()
})
it("should accept < 400 characters on comment", async () => {
// arrange
render(<App />)
const form = findInput()
// act
await submitForm(form, {
...payload,
comment: 'X'.repeat(401) // > 400
})
// assert
expect(windowAlert).toHaveBeenCalledWith(`Please enter comment in less than 400 characters`)
expect(submitFn).not.toHaveBeenCalled()
})
})
})
Now run tests, see the tests fail. Is it ok? Yes. Because that's what we are going to implement.
By testing first, you might grabbed the picture of what you're implementing. Writing tests is a good way of sketching before implementation. Now let's make those pictures into real.
// App.ts
function App() {
...
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
const isValid = validateForm(formState)
if (!isValid) {
return false
}
const res = await submitReview(formState)
if (res) {
window.alert('Successfully submitted. Thank you!')
setFormState(initialState)
}
}
...
}
// module/domain.ts
export const validateForm = (payload: Review) => {
if (payload.title.length > 50) {
window.alert(`Please enter title in less than 50 characters`)
return false
} else if (payload.score > 100) {
window.alert(`Please enter score less than or equal to 100`)
return false
} else if (payload.comment.length > 400 ) {
window.alert(`Please enter comment in less than 400 characters`)
return false
}
return true
}
See tests pass. Well, we managed to implement new feature without breaking anything! Congratulation.
As a notice, we could've put validateForm
logic into onSubmit
, but we haven't. It would make component messy, and since this logic is requirements from our business owner, it should be marked as "domain" logic.
If you want more strict separation of concern, refactor it like validateForm
returns error code and showing message is delegated to another function. You can do so as long as our test doesn't fail!
So, this is the process of TDD. Test(Fail) -> Implement -> Test(Pass) -> Refactor
. I think the challenge is to imagine "how it should be" before writing actual implementation. And the implementation should be done in a testable way.
Think in a TDD way
TDD requires you to think different onto your implementation. It must be testable. That means, well-separated. How should it be separated? Take a look at the last task of ours:
Show average review score on top of questionnaire form.
There are two things occurring in this feature.
- Calculate the average score from current reviews.
- Show it on UI.
These are separate responsibilities, aren't they? And each one seems testable enough.
For the first one, ask yourself like this: where the review data goes? Yeah, useReviews
hook. It is obviously responsible for this task. And we can test it as we set specific data within useReviews
and see the result.
For the second one we can provide the specific value (average score of reviews) into App
component and check if it renders the average score properly.
Write test for the first responsibility. In this case we are going to test the hook so use renderHook
from RTL.
// hooks/reviews.test.ts
import { act, renderHook, waitFor } from '@testing-library/react'
import { useReviews } from './reviews'
import API from '../module/api'
describe("useReviews", () => {
const mockReviews = <Review[]>[
{
title: 'Not recommended',
score: 20,
comment: 'It just stinks.'
},
{
title: 'Superb',
score: 100,
comment: 'Very satisfied. Easy to use.'
}
]
// Though we don't actually need this (PseudoApi is already a mock),
// in the real projects you mostly have to spy/mock api like this
beforeEach(() => {
jest.spyOn(API, 'submit').mockResolvedValue({
status: 200,
message: 'successfully submitted'
})
jest.spyOn(API, 'fetch').mockResolvedValue({
status: 200,
items: mockReviews
})
})
it("should calculate average based on current reviews' score", async () => {
// arrange
const { result } = renderHook(() => useReviews())
await waitFor(() => expect(result.current.reviews).toBe(mockReviews))
// assert
expect(result.current.averageScore).toBe(60)
// act
await act(async () => {
await result.current.submitReview({
title: 'new review',
score: 80,
comment: 'foo'
})
})
// assert
expect(result.current.averageScore).toBe(66)
})
})
Obviously, test fails for now. Implement the average feature on useReviews
.
// module/domain.ts
// How to calculate average value is depending on business concern.
// If they've chosen to ceil/floor the average, then it's domain logic,
// and should be consistantly used across the app.
export const avg = (arr: number[]) => {
return Math.floor(sumArr(arr) / arr.length)
}
// hooks/reviews.ts
import { useEffect, useState } from "react"
import { avg } from '../module/domain'
import API from '../module/api'
export const useReviews = () => {
...
const [averageScore, setAverageScore] = useState<number>()
useEffect(() => {
setAverageScore(avg(reviews.map(r => r.score)))
}, [reviews])
return {
loading,
reviews,
averageScore,
submitReview,
}
}
Did this passed tests? Good. Which means The average score changes even after adding new review.
Show it on UI.
// App.test.tsx
describe("UI test", () => {
...
it("should show average score", () => {
render(<App />)
const avg = screen.getByText(/Avg. Score: 60/) // derived from mock values
expect(avg).toBeInTheDocument()
})
})
// App.tsx
function App() {
const { loading, reviews, averageScore, submitReview } = useReviews()
...
if (loading) return <div>Loading...</div>
return (
<div className="App">
<div className="page-title">
Product Review Form
</div>
<div>
Please submit your review. <br />
Current reviews: {reviews.length}<br />
Avg. Score: {averageScore}
</div>
...
</div>
);
}
Great. We did implement the new feature, but in a testable, clean way. This is how TDD works.
Conclusion
Testing is not only about checking if it works. It's more beneficial for developers to build a clean, scalable, and reusable architecture.
That's all for our exercise. This post is just a demonstration, so I recommend you to practice TDD in your own projects now. It's also good to search and try some "TDD Kata".
repository: https://github.com/yozibak/tdd-react