[譯] 測試 React & Redux 應用良心指南

jonjia發表於2018-03-05

測試 React & Redux 應用良心指南

[譯] 測試 React & Redux 應用良心指南

前端只是一層薄薄的靜態頁面的時代已經一去不復返了。現代 web 應用程式變得越來越複雜,邏輯也持續從後端向前端轉移。然而,當涉及到測試時,許多人都保持著過時的心態。如果你使用的是 React 和 Redux,但是由於某些原因對測試你的程式碼不感興趣,我將在這裡向你展示如何以及為什麼我們每天都這樣做。

注意:我將使用 JestEnzyme。它們是測試 React & Redux 應用最流行的工具。我猜你已經用過或者能熟練使用它們了。

單元測試和整合測試簡單對比

React & Redux 應用構建在三個基本的構建塊上:actions、reducers 和 components。是獨立測試它們(單元測試),還是一起測試(整合測試)取決於你。整合測試會覆蓋到整個功能,可以把它想成一個黑盒子,而單元測試專注於特定的構建塊。從我的經驗來看,整合測試非常適用於容易增長但相對簡單的應用。另一方面,單元測試更適用於邏輯複雜的應用。儘管大多數應用都適合第一種情況,但我將從單元測試開始更好地解釋應用層。

我們將構建(並測試)什麼

這裡有一個可用的 應用。當你第一次進入頁面的時候,不會顯示圖片。你可以通過點選按鈕來獲取一張圖片。我使用了免費的 Dog API。現在讓我們寫一些測試。可以檢視我的 原始碼

單元測試:Action 建立函式

為了展示一隻狗的圖片,我們首先要獲取它,如果你不熟悉 thunk,別擔心。Thunk 是一箇中介軟體,它可以給我們返回一個函式,而不是 action 物件。我們可以用它根據 HTTP 請求結果來 dispatch 對應的成功的 action 或者失敗的 action。

我們要測試從 API 成功取回的資料是否 dispatch 了成功的 action,並且將資料一起傳遞。為了做到這一點,我們將使用 redux-mock-store

注意:我使用 axios 來作為客戶端請求工具,用 axios-mock-adapter 來 mock 實際 API 的請求。你可以自由選擇適合你的工具。

import configureMockStore from 'redux-mock-store';
import { FETCH_DOG_REQUEST, FETCH_DOG_SUCCESS } from '../../constants/actionTypes';
import fetchDog from './fetchDog';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';

describe('fetchDog action', () => {

  let store;
  let httpMock;

  const flushAllPromises = () => new Promise(resolve => setImmediate(resolve));

  beforeEach(() => {
    httpMock = new MockAdapter(axios);
    const mockStore = configureMockStore();
    store = mockStore({});
  });

  it('fetches a dog', async () => {
    // given
    httpMock.onGet('https://dog.ceo/api/breeds/image/random').reply(200, {
      status: 'success',
      message: 'https://dog.ceo/api/img/someDog.jpg',
    });
    // when
    fetchDog()(store.dispatch);
    await flushAllPromises();
    // then
    expect(store.getActions()).toEqual(
      [
        { type: FETCH_DOG_REQUEST },
        { payload: { url: 'https://dog.ceo/api/img/someDog.jpg' }, type: FETCH_DOG_SUCCESS }
      ]);
  })
});
複製程式碼

一開始,讓我們在 beforeEach() 中進行 mock store 和模擬的 http 客戶端的初始化。在測試中,我們為請求指定結果。之後,執行我們的 action 建立函式。因為我們使用了 thunk,因此它會返回一個函式,我們把 store 的 dispatch 方法傳給這個函式。在進行任何斷言之前,請求需要變為 resolved,因此我們要確保沒有 pending 的 Promise。

  const flushAllPromises = () => new Promise(resolve => setImmediate(resolve));
複製程式碼

這行程式碼會把所有的 promise 放到一個單獨的事件迴圈中。window.setImmediate 是用來在瀏覽器已經完成了比如事件和顯示更新等其他操作後,結束這些長時間執行的操作,並立即執行它的回撥函式。 在這個例子中,掛起的 HTTP 請求就是我們要完成的操作。此外,由於這不是一個標準的瀏覽器特性,所以你不應該在正式程式碼中使用它。

單元測試:Reducers

我認為 reducers 是應用程式的核心。如果你開發功能豐富、複雜的系統,這部分就會變得很複雜。如果你引入了一個 bug,以後可能很難查詢。這就是為什麼測試 reducers 非常重要。我們正在構建的應用非常簡單,但我希望你能獲取到圖片。

每個 reducer 都會在應用啟動時被呼叫,因此需要一個初始狀態。放任你的初始狀態為 undefined 會讓你在元件中寫好多校驗程式碼。

  it('returns initial state', () => {
    expect(dogReducer(undefined, {})).toEqual({url: ''});
  });
複製程式碼

這段程式碼很直接,我們使用 undefined 的狀態執行 reducer,並檢查它是否會返回帶有初始值的狀態。

我們還必須保證那個 reducer 能正確的響應成功的請求,並獲取到圖片的 URL。

it('sets up fetched dog url', () => {
    // given
    const beforeState = {url: ''};
    const action = {type: FETCH_DOG_SUCCESS, payload: {url: 'https://dog.ceo/api/img/someDog.jpg'}};
    // when
    const afterState = dogReducer(beforeState, action);
    // then
    expect(afterState).toEqual({url: 'https://dog.ceo/api/img/someDog.jpg'});
  });
複製程式碼

Reducers 應該是純函式,沒有副作用。這會讓測試它們變得非常簡單。提供一個之前的狀態,觸發一個 action,然後驗證輸出狀態是否正確。

單元測試:Components

在我們開始之前,讓我們先談談元件有哪些方面值得測試。我們顯然無法測試元件是否好看。但是,我們絕對應該測試某些條件性的元素是否能成功顯示;或者對元件執行某些操作(不是 redux 中的 action),通過元件 props 傳遞的方法是否會被呼叫。

在我們的系統中,我們完全依賴 redux 管理應用的狀態,因此我們所有的元件都是無狀態的。

注意:如果你在尋找優雅的 Enzyme 斷言庫,可以檢視 enzyme-matchers

元件的結構很簡單。我們有 DogApp 根元件和用來獲取並顯示狗的圖片的 RandomDog 元件。 RandomDog 元件的 props 如下:

  static propTypes = {
    dogUrl: PropTypes.string,
    fetchDog: PropTypes.func,
  };
複製程式碼

Enzymes 可以讓我們用兩種方式來渲染一個元件。Shallow Rendering 意味著只有根元件會被渲染。如果你把 shallow rendered 元件的文字列印出來,你會發現所有子元件都沒有被渲染。Shallow rendering 非常適合單獨測試元件,並且從 Enzyme 3 開始(Enzyme 2 中也是可選的),它會呼叫生命週期的方法,比如 componentDidMount()。我們稍後再介紹第二種方法。

現在我們來寫 RandomDog 元件的測試用例。

首先,我們要確保沒有圖片 URL 時,要顯示佔位符,而且不應該顯示圖片。

  it('should render a placeholder', () => {
    const wrapper = shallow(<RandomDog />);
    expect(wrapper.find('.dog-placeholder').exists()).toBe(true);
    expect(wrapper.find('.dog-image').exists()).toBe(false);
  });
複製程式碼

其次,在提供圖片 URL 時,圖片應該替換佔位符顯示出來。

  it('should render actual dog image', () => {
    const wrapper = shallow(<RandomDog dogUrl="http://somedogurl.dog" />);
    expect(wrapper.find('.dog-placeholder').exists()).toBe(false);
    expect(wrapper.find('img[src="http://somedogurl.dog"]').exists()).toBe(true);
  });
複製程式碼

最後,點選獲取狗的圖片按鈕,應該會執行 fetchDog() 方法。

  it('should execute fetchDog', () => {
    const fetchDog = jest.fn();
    const wrapper = shallow(<RandomDog fetchDog={fetchDog}/>);
    wrapper.find('.dog-button').simulate('click');
    expect(fetchDog).toHaveBeenCalledTimes(1);
  });
複製程式碼

注意:在這個例子中,我使用了元素和類選擇器。如果你發現它很脆弱並重構了程式碼,可以考慮切換到 custom attributes

只有單元測試,沒有整合測試

我用一些陳詞濫調來說明單元測試的問題。

[譯] 測試 React & Redux 應用良心指南

雖然單元測試是個很好的工具,但它並不能保證我們正確連線了所有的元件,或者 reducer 訂閱了正確的 action。這是 bug 容易發生的位置,這就是為什麼我們需要整合測試。

是的,有些人認為由於上述原因,單元測試是沒用的,但我認為他們沒有面對過一個足夠複雜的系統來發現單元測試的價值。

整合測試

我們現在將它們捆綁在一起並放在一個黑盒子中,而不是單獨和詳細地測試構建塊。我們不再關心內部是如何工作的,或是元件內部究竟發生了什麼。 這就是為什麼整合測試非常有彈性和方便重構的原因。你可以切換整個底層機制而無需更新測試。

在整合測試中,我們不再需要 mock store。讓我們使用真實的吧。

import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import reducers from './reducers/index';

export default function setupStore(initialState) {
  return createStore(reducers, {...initialState}, applyMiddleware(thunk));
}
複製程式碼

就是這樣。現在,我們有一個功能齊全的 store,是時候開始第一個測試了。我們使用 Enzyme 的 mount 來(實現掛載型別的渲染)。Mount 非常適合整合測試,因為它會渲染整個底層元件樹。

正如我們在單元測試中所做的那樣,我們要檢查應用啟動時是否沒有顯示影象。但是現在我沒有將空的影象 URL 作為元件的 prop 傳遞,而是將其包裝在 Provider 中,傳遞了我們建立的 store。

  it('should render a placeholder when no dog image is fetched', () => {
    let wrapper = mount(<Provider store={store}><App /></Provider>);
    expect(wrapper.find('div.dog-placeholder').text()).toEqual('No dog loaded yet. Get some!');
    expect(wrapper.find('img.dog-image').exists()).toBe(false);
  });
複製程式碼

沒有什麼特別的是吧?我們來看第二個測試用例。

  it('should fetch and render a dog', async () => {
    httpMock.onGet('https://dog.ceo/api/breeds/image/random').reply(200, {
      status: 'success',
      message: 'https://dog.ceo/api/img/someDog.jpg'
    });

    const wrapper = mount(<Provider store={store}><App /></Provider>);
    wrapper.find('.dog-button').simulate('click');

    await flushAllPromises();
    wrapper.update();

    expect(wrapper.find('img[src="https://dog.ceo/api/img/someDog.jpg"]').exists()).toBe(true);
  });
複製程式碼

很容易對吧?這個測試描述了我們和元件之間的真實互動。它涵蓋了單元測試所做的每個方面,甚至更多。現在我們可以說構建塊不僅能夠單獨執行,而且能夠以正確的方式結合起來。

哦,如果你對 Enzyme 很熟悉,還想知道我為什麼呼叫 wrapper.update(),這就是原因。簡而言之:這是 Enzyme 3 的一個 bug。也許在你閱讀這篇文章時,它會被修復。

快照測試簡介

Jest 提供了一種確保程式碼更改不會改變元件的 render()方法輸出的方法。雖然編寫快照測試非常簡單快捷,但它們並不具有描述性,也無法通過測試驅動開發過程。我看到的唯一使用案例是,當你對其他人的未經測試的遺留程式碼進行一些更改時,你並不想整理這些程式碼,更不希望因為修改它而受到指責。

那麼我們應該使用什麼型別的測試?

只需要從整合測試開始。你很可能覺得不會在你的專案中實施一個單元測試。這意味著你的複雜性不會在構建塊之間劃分,這樣非常好。你會節省很多時間。另一方面,有些系統會利用單元測試的能力。兩者都有用武之地。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章