深入淺出 testing-library

發表於2024-02-11

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:佳嵐

The more your tests resemble the way your software is used, the more confidence they can give you.
您的測試越接近軟體的使用方式,它們就越能給您帶來信心。

什麼是 testing-library?

在瞭解 testing-library 前,我們可以看看使用原生方法是如何進行 React 元件測試的。

import Header from ".."
import client from 'react-dom/client'
import  { act } from 'react-dom/test-utils'

let container;
beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container)
});
afterEach(() => {
    document.body.removeChild(container);
    container = null;
})
test('test render', () => {
    act(() => {
        client.createRoot(container!).render(<Header />)
    });
    const button = container!.querySelector('button');
    const count = container!.querySelector("span[title='count']");

    act(() => {
        button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
        // or Simulate.click(button!)
    });
    expect(count?.textContent).toEqual('1')
})

在上面案例中,我們需要自行建立一個根容器來渲染 React 元件, 且我們必須及時清除該根容器,避免對其他測試用例產生影響。
在 Header 元件中,我們每次點選按鈕都會進行計數+1,當我們要進行點選時,需要自行建立事件例項,且由於合成事件的原因需要增加 bubbles 屬性,這使得對開發者也就有了些許能力要求。
除此之外,事件觸發必須包裹在 act 方法中,render 方法中相同。

什麼是 act ?

正常情況下,我們的單測程式碼是同步執行的,即程式碼執行完畢則單測完成。React 中的渲染並不是同步的,當我們進行事件觸發,導致了 rerender 後,並不能立馬獲取新的頁面結果,導致後續的斷言失敗。act 是由 React 官方在 react-dom/test-utils 中提供的一個方法,它能夠讓回撥函式呼叫後,立即執行 React 內部 pending 中的非同步佇列。

我們再使用 RTL 去實現程式碼,RTL 為我們簡化了 render 所需要的重複模板程式碼,模擬事件也不再需要包裹 act ,並提供了通用的查詢方法。

test('test by RTL', () => {
    const { getByRole, getByTitle } = render(<Header />);
    const button = getByRole("button")
    const count = getByTitle('count')
    fireEvent.click(button);
    expect(count.textContent).toEqual('1');
})

設計理念

testing-library 是以使用者為中心 的方式進行 UI 元件的測試。什麼是 以使用者為中心
即以使用者的角度方式去審視你的 UI 元件,使用者是不關心你的元件內部是如何實現的,只關心最終的功能效果是否正確。你不應該在 testing-library 中去測試元件的 props 與內部 state,生命週期等是否正確。
Why ?

組成架構

testing-library 的核心部分是 DOM Testing Library@testing-library/dom , 它提供了通用的DOM 查詢功能,如getByRole getByText 與事件行為的基本實現。

在此基礎上,再衍生出各自框架的專有包,如React Testing LibraryVue Testing Library ,對於不同的前端框架,其使用方法是基本一致的, 提供不同實現方式的 renderfireEvent 方法。
除了每個前端框架提供各自的 fireEvent 介面外,還額外提供了一個@testing-library/user-event的通用包,不依賴於所選框架實現,它能夠對使用者事件的真實模擬,下文會詳細說到。

除此之外,還針對 Jest 測試框架開發了一個斷言庫 @testing-library/jest-dom ,如我們平常經常使用的 expect().toBeInTheDocument() 就是該庫為我們實現的,其透過 jest 提供的自定義斷言器expect.extend(matchers) 將斷言庫注入到我們所使用的的 jest 上下文中

查詢方法

查詢作為testing-library的核心,它主要以使用者的角度去查詢,如根據元件展示的文字,Title 等資訊。

查詢內容

從查詢內容上來講它提供了8種型別

  1. ByRole
    透過可訪問性、語義化查詢元素, 如checkbox, menu, navigation , 對於實際場景並不常用。
  2. ByLabelText
    透過 label 找到 label 所對應的元素,通常在表單中使用,如下

    render(
      <label>
     username
     <input />
      </label>
    );
    const el = screen.getByLabelText('username');
    expect(el.tagName).toBe('INPUT')
  3. ByPlaceHolder
    透過佔位符查詢元素,當查詢表單元素等,但又沒有 label 標識的話,可以使用這個,但不推薦。
  4. ByText
    最常用的查詢方法,根據 textContent 進行查詢,通常可配合正則一起使用,如下

    render(<div>Text Content is: 1</div>);
    const el = screen.getByText(/Text Content is/);
    expect(el.textContent).toBe("Text Content is: 1");
  5. ByDisplayValue
    透過數值進行查詢包含該值的元素,如 input 與 select 會有 value 屬性

    const {container} = render(
     <div>
     <select>
         <option value="state">state</option>
         <option value="prop">prop</option>
     </select>
     </div>
    );
    const select = container.querySelector('select')!;
    select.value = 'state'
    const el = screen.getByDisplayValue('state');
    expect(el).toEqual(select)
  6. ByAltText
    根據 alt 屬性進行查詢,如<img alt=”img1” src=”xxx” />
  7. ByTitle
    根據 title 屬性進行查詢,同上
  8. ByTestId
    如果以上查詢方式都不容易查詢到節點,則最終可以考慮 testid,這種方式會侵入原始碼,但不會對頁面效果產生影響,透過在元素上新增 data-testid 屬性來查詢,查詢時相當於container.querySelector([data-testid="${yourId}"])

有這麼多方式,我該選哪個最好?

官方推薦優先使用使用者頁面可視的查詢方式,如 byRole, byText 等可以在頁面上看到的;其次是語義話查詢方式,byAltTextByTitle ,這是在頁面上基本看不到,但是易於機器讀懂的;最後才應該考慮byTestId 。如果 byTestId 也無法實現,那你只是使用原生的 querySelector 也沒什麼問題

查詢方式

testing-library 一共提供了三種查詢型別getByfindByqueryBy ,這三種型別定義了對查詢結果的處理方式。

透過 getBy 方式查詢, 當查詢不到元素時會直接丟擲一個錯誤, 則導致測試失敗。

我們看看下面這個案例,上面部分額外的顯式使用了斷言,其執行結果最終是一模一樣的。

但應該採用哪種寫法最好?

test("test getBy with assertion", () => {
    const { getByRole } = render(
        <div>
            <button>按鈕</button>
        </div>
    );
    expect(getByRole("list")).toBeInTheDocument();
});

test("test getBy", () => {
    const { getByRole } = render(
        <div>
            <button>按鈕</button>
        </div>
    );
    getByRole("list")
});

如果這段測試的意義是為了測試元素是否存在,則最佳實踐應是採用使用顯式斷言的方式。

透過 queryBy 方式查詢,與 getBy 的唯一不同就是查詢不到元素時不會丟擲錯誤。

那麼什麼情況下該使用 queryBy 還是 findBy

事實上,絕大多數情況下應直接使用 getBy ,只在想測試元素不存在這種場景時使用 queryBy

test("test queryBy", () => {
    const { queryByRole } = render(
        <div>
            <button>按鈕</button>
        </div>
    );
    expect(queryByRole("list")).not.toBeInTheDocument()
});

透過 findBy 進行查詢,他與 getBy  一致,查詢不到時會丟擲錯誤,但是它能夠用來查詢非同步元素。

何為非同步元素?

在前面我們講過,setState 導致的非同步渲染,我們已經透過React提供的 act 方法解決了,對於某些場景,比如上傳檔案後,顯示已上傳的檔案列表,上傳檔案操作是非同步的,我們需要在一定時間後才能拿到檔案列表元素;又或者說 setTimeout 或者promise 中進行了setState 操作,渲染的元素也需要非同步獲取。

在講 findBy 之前,我們先了解下 waitFor , waitFor 也是testing-library 提供的一個非同步方法,它提供了一種對於不確定程式碼執行時間的處理方法。在使用時,必須使單測塊變為非同步的,否則就沒了使用意義,因此 waitFor 一般都與 await 一起使用。

使用方式如下:

test("test waitFor", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            setTimeout(() => {
                setText('text2')
            }, 300);
        }, [])
        return <span>{text}</span>
    }

    const { getByText } = render(<Foo />);
    await waitFor(() => {
        expect(getByText('text2')).toBeInTheDocument()
    })
})

其原理也很簡單,不斷的去執行傳入的回撥函式,直到回撥函式沒有丟擲錯誤或者超出最大等待時間。expect 斷言失敗,本質上也是丟擲個錯誤, 因此一般會把斷言寫在 waitFor 中。

waitFor 預設超時時間為1000ms,每50ms執行一次回撥。但在測試環境我們也不可能真去等1秒時間,其內部做了額外處理。

  1. 預設會優先採用jestfakeTimers 來略過時間等待,但這個前提是在執行 waitFor 前,需要進行 fakeTimers 的註冊,也就是執行 jest.useFakeTimers()
    但很多情況下我們是沒有使用 fakerTimers , 且 testing-library 是測試框架無關的,所以在其他情況下會使用MutationObserver 來作為重複執行 callback 的時機。
  2. 在迴圈開始前會新增一個超時時間的定時器 overallTimeoutTimer ,定時器回撥被呼叫則說明超時,直接 reject 掉。
  3. 當採用 fakeTimers 方案時,會在每次迴圈時透過 jest.advanceTimersByTime 等待一定的時間interval(並非真正的等待)
  4. checkCallback 方法中,會呼叫 callback ,並進行異常捕獲,如捕獲到異常則會進行下一次的迴圈,如果正常則在 onDone 方法中將 finished 置為 true ,並結束當前promise。
  5. 當使用 MutationObserver 方案時,會監聽 document DOM 節點的變化(包括其自己節點)。除此之外,由於 MutationObserver 是監聽DOM樹來實現的,某些場景會有限制,如 CSS 屬性的變化,因此還會啟用一個 setInterval 原始的定時器來做輔助執行,保證回撥一定會被執行。

    function waitFor(
      callback,
      {
     container = getDocument(),
     timeout = getConfig().asyncUtilTimeout,
     interval = 50,
     // 其他引數略
      },
    ) {
      return new Promise(async (resolve, reject) => {
     let lastError, intervalId, observer
     let finished = false
     let promiseStatus = 'idle'
         // 超時時間的timerid
     const overallTimeoutTimer = setTimeout(handleTimeout, timeout)
     const usingJestFakeTimers = jestFakeTimersAreEnabled()
     // 如果使用了jest的fakeTimers,則採用advanceTimersByTime快速略過時間
     if (usingJestFakeTimers) {
       const {unstable_advanceTimersWrapper: advanceTimersWrapper} = getConfig()
       checkCallback()
             // 不斷的等待一定時間後並檢測回撥是否透過檢測
       while (!finished) {
         await advanceTimersWrapper(async () => {
           jest.advanceTimersByTime(interval)
         })
                 // 呼叫callback並檢測是否跑錯
         checkCallback()
         if (finished) {
           break
         }
       }
     } 
     // 如果沒有使用fakeTimers,則退化為使用MutationObserver觀測元素變化時執行一遍
     else {
       intervalId = setInterval(checkRealTimersCallback, interval)
       const {MutationObserver} = getWindowFromNode(container)
       observer = new MutationObserver(checkRealTimersCallback)
       observer.observe(container, mutationObserverOptions)
       checkCallback()
     }
    
         function checkCallback() {
       if (promiseStatus === 'pending') return
       try {
         const result = callback()
                 // 處理callback為非同步函式的情況
         if (typeof result?.then === 'function') {
           promiseStatus = 'pending'
           result.then(
             resolvedValue => {
               promiseStatus = 'resolved'
               onDone(null, resolvedValue)
             },
             rejectedValue => {
               promiseStatus = 'rejected'
               lastError = rejectedValue
             },
           )
         } else {
           onDone(null, result)
         }
       } catch (error) {
         lastError = error
       }
     }
    
     function onDone(error, result) {
       finished = true
       clearTimeout(overallTimeoutTimer)
    
       if (!usingJestFakeTimers) {
         clearInterval(intervalId)
         observer.disconnect()
       }
    
       if (error) {
         reject(error)
       } else {
         resolve(result)
       }
     }
    }

    從原始碼中看其實現還是很巧妙的,額外還需要注意的點是,回撥函式是支援傳入 async 函式的,當傳入 async 函式時,會等待 promise 狀態改變後才會再次執行。

再回到 findBy , 它其實就是對 waitFor 的一個封裝,類似於下面這種程式碼。

await waitFor(() => getByXXX())

在使用時也必須加上 await 關鍵字,並且當你想要使用 findBy 時,請確保元素最終一定會存在,如果你想要測試元素是否存在,請使用waitFor + expect的形式保證其具有足夠的語義。

test("test findBy", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            setTimeout(() => {
                setText('text2')
            }, 300);
        }, [])
        return <span>{text}</span>
    }

    const { findByText } = render(<Foo />);
    const span = await findByText('text2');
    expect(span.nodeName).toBe('SPAN')
})

除此之外,所有查詢方法都是嚴格區分數量的,如果查詢結果數量返回大於1,即使是 queryBy 型別,也會報錯導致測試失敗,對於多個返回的,需要使用getAllBy, queryAllBy, findAllBy
貼一張文件上的區別圖

file

事件觸發

testing-library 提供了兩種觸發事件的方式,fireEventuserEvent

fireEvent

fireEvent是從 React Testing LIbrary 中引入的,其內部又是基於 DOM Testing Library 的 fireEventReact 做了一些相容性改動。
其使用方式非常方便, 有 fireEvent(node, event) 或者 fireEvent(node, eventProperties) 兩種使用方式

fireEvent.change(getByLabelText(/picture/i), {
  target: {
    files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
  },
});

fireEvent(getByLabelText(/picture/i), new Event('change', 
    {bubbles: true, cancelable: false})
);

在 DOM Testing Library 中 fireEvent 的實現, 也是透過 dispatchEvent 來做的

function fireEvent(element, event) {
  return getConfig().eventWrapper(() => {
    if (!event) {
      throw new Error(
        `Unable to fire an event - please provide an event object.`,
      )
    }
    if (!element) {
      throw new Error(
        `Unable to fire a "${event.type}" event - please provide a DOM element.`,
      )
    }
    return element.dispatchEvent(event)
  })
}

那我們看看 React Testing Library 中又做了啥,它其實是對 fireEvent 加了層 act 包裹,這也是我們能直接使用的原因, fireEvent 時切記不要再手動包裹 act

configureDTL({
  eventWrapper: cb => {
    let result
    act(() => {
      result = cb()
    })
    return result
  },
    // 略
})

我們實際開發中經常會時不時的遇到 act 的飄紅報錯,看到報錯提示我們不經意間就加了個 act上去,但這樣其實是沒用的。

file

比如下面這個案例:

test("test act warning", () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            Promise.resolve().then(() => setText('text2'))
        }, [])
        return <div>
                <span>{text}</span>
                <div>haha</div>
            </div>
    }

    const { getByText, debug } = render(<Foo />);
    const text = getByText('haha');
    expect(text).toBeInTheDocument()
    debug()
})

當我們程式碼中進行非同步請求時,並在測試完成後或者 act 執行完成後,再在回撥中進行 setState 則會導致報錯。

上面這段程式碼想要修復報錯,有很多方式,如可以在測試結束前進行等待來解決,或者直接乾脆把非同步請求的返回mock掉,不進行setState

最佳化後的程式碼

test("test act warning", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        const fn = 
        useEffect(() => {
            Promise.resolve().then(() => setText('text2'))
        }, [])
        return <div>
                <span>{text}</span>
                <div>haha</div>
            </div>
    }

    const { getByText, findByText } = render(<Foo />);
    const text = getByText('haha');
    expect(text).toBeInTheDocument()
        // 等待後再結束測試
    await findByText('text2')
})

又或者使用非同步的act , 在初次 render 時 手動包裹一層 act , act 是支援巢狀使用的。這在初始化元件時請求非同步資料很有用

await act(async () => render(<Foo />) )
const text = screen.getByText('text2');
expect(text).toBeInTheDocument()

如果我再加入個非同步任務,結果又如何?

useEffect(() => {
  Promise.resolve().then(() => setText('text2')).then(() => setText('text3'))
}, [])

答案是:tex3

userEvent

userEvent是 testing library 的單獨一個測試包,需要從@testing-library/user-event中引入。

fireEvent 不同的是,該包是完全以模擬使用者的真實行為去觸發事件的。

fireEvent是瀏覽器低階dispatchEventAPI 的輕量級包裝器,它允許開發人員觸發任何元素上的任何事件。問題在於,瀏覽器通常不僅僅為一次互動觸發一個事件。例如,當使用者在文字框中鍵入內容時,必須聚焦該元素,然後觸發鍵盤的輸入事件。userEvent 其實就是真實模擬了使用者使用時的互動方式。

下面是個簡單的輸入案例。

test("test userEvent", async () => {
    const onChange = jest.fn();
    const onFocus = jest.fn();
    const onClick = jest.fn();
    const { getByPlaceholderText } = render(
        <input
            placeholder="請輸入"
            type="textarea"
            onChange={onChange}
            onFocus={onFocus}
            onClick={onClick}
        />
    );
    const input = getByPlaceholderText("請輸入");
    await userEvent.type(input, 'hello');
    expect(onChange).toHaveBeenCalled();
    expect(onFocus).toHaveBeenCalled();
    expect(onClick).toHaveBeenCalled();
    expect(input).toHaveDisplayValue('hello')
})

需要注意的是,userEvent由於模擬了一系列操作,需要以非同步的形式呼叫才能獲取結果。

userEvent 還提供了很多其他的模擬操作,如複製貼上,模擬鍵盤打字,模擬檔案上傳等等使用者互動場景。

值得注意的是,testing-library 官方是推薦我們在大多數情況下應優先考慮使用 userEvent 而非 fireEvent 的,因為您的測試越接近軟體的使用方式,它們就越能給您帶來信心。

產品中的一些反模式

  1. 不要再在無用的地方加 cleanup 了**。

file

首先為何需要有 cleanup 清除函式?
在一個單測檔案中我們可能有多個test ,每個測試例項渲染自己的元件,但是window.document 只有一個,每次 render() 都會往body下新增一個div作為根容器,我們保證測試自己的元件時 body 下是無子節點的避免影響。cleanup 會對當前所有掛載的React根元件進行unmount, 並移除對應的元素。

function cleanup() {
  mountedRootEntries.forEach(({root, container}) => {
    act(() => {
      root.unmount()
    })
    if (container.parentNode === document.body) {
      document.body.removeChild(container)
    }
  })
  mountedRootEntries.length = 0
  mountedContainers.clear()
}

為何不需要再 cleanup 了?RTL中自動幫我們調了。

何時才需要 cleanup

同一個測試塊中,render 了多次使其掛載了多個元件根節點, 也就是在test程式碼塊內進行呼叫。

file

  1. 錯誤的使用 waitFor
    waitFor 中語句要有拋錯的能力才有實際意義

    let el = null
    await waitFor(() => {
     el = document.querySelector('.xxx')
    })
    await waitFor(() => {
     fireEvent.click(el);
         expect(el).toBeDisabled();
    })
  2. fireEvent 包裹無意義的act

    // 錯誤的
    act(() => {
     fireEvent.click(el)
    })
    
    // 可能正確的方式
    await act(async () => {
     fireEvent.click(el)
    })

    tips: 更多的反模式參考一些常見的RTL錯誤

參考:

https://www.robinwieruch.de/react-testing-library/
https://testing-library.com/docs/queries/about#priority

最後

歡迎關注【袋鼠雲數棧UED團隊】\~\
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star