iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 22
1
Modern Web

循序漸進學習 Javascript 測試系列 第 22

Day 22 測試 React 元件:使用 React Testing Library 體驗 Test Driven Development (TDD) - 2

  • 分享至 

  • xImage
  •  

用 TDD 實作表單開發

我們現在試著用 TDD 的方式來開發一個表單。

這個表單主要功能是發表文章,它接收標題 (title) 、內容 (content)、標籤 (tag),並且有一個按鈕可以點擊送出表單,並發出 HTTP  request。

我們會經歷以下步驟,一步一步先寫測試再寫程式,重複亮紅燈再亮綠燈的循環過程:

  • 步驟一:開發表單元件結構
  • 步驟二:開發 submit button 功能
  • 步驟三:表單 API Call

昨天我們完成了步驟一、二,今天繼續往下進行。

步驟三:表單 API Call

現在我們要讓表單在 submit 後可以發送資料到後端 API。但在單元測試時,我們並不想真的發送 HTTP request,所以待會在測試檔案中,我們要 mock function,並驗證我們發送對的資料到後端。

讓我們到測試檔案 __tests__/post-editor.js 中,逐步加上測試:

  • 先 mock '../api'savePost ,並給它一個 alias mockSavePost ,方便辨別這是一個 mock 版本的 function

tests/post-editor.js

import {savePost as mockSavePost} from '../api'
import {Editor} from '../post-editor'

jest.mock('../api')
  • 加上 mockSavePost.mockResolvedValueOnce() ,mock 呼叫 API 之後 resolve 的值。因為我們目前還不需要用到這個值,所以先不傳任何東西進去。另外,它會回傳一個 promise。

tests/post-editor.js

test('renders a form with title, content, tags, and a submit button', () => {
  mockSavePost.mockResolvedValueOnce()
  const {getByLabelText, getByText} = render(<Editor />)

  ...
}
  • 加上 assertions 驗證是否正確呼叫 API:
    • expect(mockSavePost).toHaveBeenCalledWith() 驗證是否有傳入對的 data。傳入的資料長這樣 {title: 'Test Title', content: 'Test content', tags: ['tag1', 'tag2']} ,而 data 的來源為 input value,因此我們用這樣 getByLabelText(/title/i).value = 'Test Title' 來設置。
    • expect(mockSavePost).toHaveBeenCalledTimes(1) 驗證被呼叫一次。

tests/post-editor.js

test('renders a form with title, content, tags, and a submit button', () => {
  mockSavePost.mockResolvedValueOnce()
  const {getByLabelText, getByText} = render(<Editor />)

  getByLabelText(/title/i).value = 'Test Title'
  getByLabelText(/content/i).value = 'Test content'
  getByLabelText(/tags/i).value = 'tag1, tag2'
  const submitButton = getByText(/submit/i)

  fireEvent.click(submitButton)

  expect(submitButton).toBeDisabled()

  expect(mockSavePost).toHaveBeenCalledWith({
    title: 'Test Title',
    content: 'Test content',
    tags: ['tag1', 'tag2'] // 注意:這個欄位我們想要接收的是 array
  })
  expect(mockSavePost).toHaveBeenCalledTimes(1)
})

回到 post-editor.js 檔案,我們在元件內加上 call API 的部分:

  • 引入 './api'savePost function。並在 handleSubmit 裡呼叫 savePost({title, content, tags})

post-editor.js

import{savePost} from './api'

function Editor() {
  const [isSaving, setIsSaving] = React.useState(false)
  function handleSubmit(e) {
    e.preventDefault()
    setIsSaving(true)
    savePost({
      title,
      content,
      tags
    })
  }
  ...
}
  • 為了取到 savePost({title, content, tags}) 裡面傳入的 data,分別在三個 input tag 加上 name 屬性,例如: name="title"
  • handleSubmit function 裡,我們可以用 e.target.elements.value 取到三個 input 的 value。

post-editor.js

function Editor() {
	...
	function handleSubmit(e) {
	    e.preventDefault()
	    const {title, content, tags} = e.target.elements
	    setIsSaving(true)
	    savePost({
	      title: title.value,
	      content: content.value,
				// 將用 ',' 區隔開的字串轉成 array 傳到 API
	      tags: tags.value.split(',').map(t => t.trim()),
	    })
	  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="title-input">Title</label>
      <input id="title-input" name="title" />

      <label htmlFor="content-input">Content</label>
      <textarea id="content-input" name="content" />

      <label htmlFor="tags-input">Tags</label>
      <input id="tags-input" name="tags" />

      <button type="submit" disabled={isSaving}>
        Submit
      </button>
    </form>
  )
}
  • 回到測試檔案 __tests__/post-editor.js 中,我們稍微重構一下。宣吿一個 fakePost ,裡面是我們的 mock data。

tests/post-editor.js

test('renders a form with title, content, tags, and a submit button', () => {
  mockSavePost.mockResolvedValueOnce()
  const {getByLabelText, getByText} = render(<Editor />)
	const fakePost = {
    title: 'Test Title',
    content: 'Test content',
    tags: ['tag1', 'tag2'] // 注意:這個欄位我們想要接收的是 array
  }
  getByLabelText(/title/i).value = fakePost.title
  getByLabelText(/content/i).value = fakePost.content
  getByLabelText(/tags/i).value = fakePost.tags.join(', ') // 注意:使用者輸入的是用 ',' 分割的字串
  const submitButton = getByText(/submit/i)

  fireEvent.click(submitButton)

  expect(submitButton).toBeDisabled()

  expect(mockSavePost).toHaveBeenCalledWith(fakePost)
  expect(mockSavePost).toHaveBeenCalledTimes(1)
})
  • mockSavePost 還需要 authorId 作為 API 所需的 data。另外,使用者資訊是由父元件傳入的 props。所以,我們在測試中加入 fakeUser ,並在 <Editor /> 加上 user={fakeUser} 屬性。

tests/post-editor.js

test('renders a form with title, content, tags, and a submit button', () => {
  mockSavePost.mockResolvedValueOnce()
  const fakeUser = {id: 'user-1'}
  const {getByLabelText, getByText} = render(<Editor user={fakeUser} />)

	...

  expect(mockSavePost).toHaveBeenCalledWith({
	  ...fakePost,
	  authorId: fakeUser.id,
	})
	
})

這時候測試 fail ❌。

我們回到 post-editor.js 檔案,將 user 的部分補上:

  • 傳入 {user} props,並在發送到 API 的 data 內多加上一個 authorId key。

post-editor.js

function Editor({user}) {
	...
	function handleSubmit(e) {
	    e.preventDefault()
    const {title, content, tags} = e.target.elements
    setIsSaving(true)
    savePost({
      title: title.value,
      content: content.value,
      tags: tags.value.split(',').map(t => t.trim()),
      authorId: user.id,
    })
	}
	...
}

現在測試又亮回綠燈 ✅

最後,因為我們 mock 了 api functions ,在任何測試跑完之後,要復原回原本的 api functions ,才不會污染其他的測試,必須保持每個單元測試都是獨立的。記得在測試檔案 __tests__/post-editor.js 中加上:

tests/post-editor.js

afterEach(() => {
  jest.clearAllMocks()
})

回頭重構一下 Editor 元件,將傳入 savePost function 的 data 抽出來為 newPost

post-editor.js

function Editor({user}) {
	...
	function handleSubmit(e) {
	    e.preventDefault()
    const {title, content, tags} = e.target.elements
    const newPost = {
      title: title.value,
      content: content.value,
      tags: tags.value.split(',').map(t => t.trim()),
      authorId: user.id,
    }
    setIsSaving(true)
    savePost(newPost)
	}
	...
}

測試一樣亮綠燈 ✅


目前的完整程式碼:

tests/post-editor.js

import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import {savePost as mockSavePost} from '../api'
import {Editor} from '../post-editor'

jest.mock('../api')

afterEach(() => {
  jest.clearAllMocks()
})

test('renders a form with title, content, tags, and a submit button', () => {
  mockSavePost.mockResolvedValueOnce()
  const fakeUser = {id: 'user-1'}
  const {getByLabelText, getByText} = render(<Editor user={fakeUser} />)
  const fakePost = {
    title: 'Test Title',
    content: 'Test content',
    tags: ['tag1', 'tag2'],
  }
  getByLabelText(/title/i).value = fakePost.title
  getByLabelText(/content/i).value = fakePost.content
  getByLabelText(/tags/i).value = fakePost.tags.join(', ')
  const submitButton = getByText(/submit/i)

  fireEvent.click(submitButton)

  expect(submitButton).toBeDisabled()

  expect(mockSavePost).toHaveBeenCalledWith({
    ...fakePost,
    authorId: fakeUser.id,
  })
  expect(mockSavePost).toHaveBeenCalledTimes(1)
})

post-editor.js

import React from 'react'
import {savePost} from './api'

function Editor({user}) {
  const [isSaving, setIsSaving] = React.useState(false)
  function handleSubmit(e) {
    e.preventDefault()
    const {title, content, tags} = e.target.elements
    const newPost = {
      title: title.value,
      content: content.value,
      tags: tags.value.split(',').map(t => t.trim()),
      authorId: user.id,
    }
    setIsSaving(true)
    savePost(newPost)
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="title-input">Title</label>
      <input id="title-input" name="title" />

      <label htmlFor="content-input">Content</label>
      <textarea id="content-input" name="content" />

      <label htmlFor="tags-input">Tags</label>
      <input id="tags-input" name="tags" />

      <button type="submit" disabled={isSaving}>
        Submit
      </button>
    </form>
  )
}

export {Editor}

上一篇
Day 21 測試 React 元件:使用 React Testing Library 體驗 Test Driven Development (TDD) - 1
下一篇
Day 23 測試 React 元件:使用 React Testing Library 體驗 Test Driven Development (TDD) - 3
系列文
循序漸進學習 Javascript 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言