我們現在試著用 TDD 的方式來開發一個表單。
這個表單主要功能是發表文章,它接收標題 (title) 、內容 (content)、標籤 (tag),並且有一個按鈕可以點擊送出表單,並發出 HTTP request。
我們會經歷以下步驟,一步一步先寫測試再寫程式,重複亮紅燈再亮綠燈的循環過程:
昨天我們完成了步驟一、二,今天繼續往下進行。
現在我們要讓表單在 submit 後可以發送資料到後端 API。但在單元測試時,我們並不想真的發送 HTTP request,所以待會在測試檔案中,我們要 mock function,並驗證我們發送對的資料到後端。
讓我們到測試檔案 __tests__/post-editor.js
中,逐步加上測試:
'../api'
的 savePost
,並給它一個 alias mockSavePost
,方便辨別這是一個 mock 版本的 functiontests/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 />)
...
}
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}