TDD (Test Driven Development) Basics Tutorial with TypeScript/React

Photo by ABDALLA M on Unsplash

TDD (Test Driven Development) Basics Tutorial with TypeScript/React

Featured on Hashnode

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.

  1. Learn Jest
  2. Learn React Testing Library
  3. Learn the process of TDD
  4. 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.

  1. Write sumArr() function that returns the sum of the incoming array of int.
  2. Test sumArr() with jest.
  3. 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:

  1. Write test to ensure page title is shown
  2. Write test to ensure current number of reviews is shown
  3. 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.

  1. Calculate the average score from current reviews.
  2. 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