Katsumi Yoshida
WIDWGA

WIDWGA

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

Photo by ABDALLA M on Unsplash

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

Katsumi Yoshida's photo
Katsumi Yoshida
·Aug 28, 2022·

12 min read

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. Like running, waking up early, eating well, all is fundamental for our well-being and productivity. Life style affects how happily 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 the way of thinking. But after all of the practice, you'll find yourself more comfortable writing code.

In this post I will introduce you how to develop in 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 test first, thereafter implement your concern.

Why TDD? There are several benefits by doing so.

  • 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 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 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 writing tests, so you should practice it gradually.

These 4 steps are what I think are needed in learning TDD.

  1. Learn Jest
  2. Learn React Testing Library
  3. Learn the process of TDD
  4. Learn to "think" in TDD way

I'm gonna show you step by step. Clone the repository, and switch to starter branch.

git clone git@github.com:yozibak/tdd-react.git
git switch starter
yarn install

All 4 exercises have each own task. And you can check the answers for exercises by switching branches (git switch ex1/answer).

The project is simple application for product review page. Run yarn start and check how it looks like. Take a moment and learn what it's currently doing. If you've done, move on to first exercise.

Basics of Jest

First, try these tasks to get used to jest.

  1. Write sumArr() function that returns the sum of 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 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 haven'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 implementation. See 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 doesn't change. On the other hand, your implementation is what's changing alongside development. So to have test before changing is very helpful for development cycle.

Remember, Test -> Refactor is 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, 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 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 on 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 test suite throws error.

Fortunately, our repository already uses custom data hook (useReviews) to manage outer data. So we can just mock it out before each tests.

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 variable lets us manipulating mocked value later.
  let useReviews: jest.SpyInstance

  // CRA creates {resetMocks: true} as jest settings, 
  // So we need to mock before each test cases. (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 it solving 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 2 reviews to mocked object. So while rendering our App component receives 2 reviews from useReviews hooks. See the dom reflects it.

Now, 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 is 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 bind events. Handling data is other responsibility. So these are better to be separated and tested individually. Keep this in mind and implement new feature to our app.

Basics of TDD

We have 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 the things afterwards. And if there's no tests on that feature, the team could hardly touch it. Because we're all afraid of breaking things. What else is legacy?

Besides, we already have working feature (form submission) and we don't want break it while developing new feature. This is why we wrote tests for it beforehand. If the part you're working on has no tests, you might better starting with testing it.

Enough ranting. The requirements for 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 adding. 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 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

 
Share this