前端自動化測試初探(一)

慢熱型急性子發表於2018-12-22

第一次嘗試將學習過的知識通過文章的方式記錄下來。在寫文章的過程中,發現自己更多的不足以及測試的重要性。本篇文章主要是記錄使用Jest + Enzyme進行React技術棧單元測試所需要掌握的基本知識以及環境搭建。

常用術語

  • 根據測試手段劃分
    • 黑盒:完全不考慮程式的內部結構以及工作過程,僅關注程式的功能是否都正常。
    • 白盒:已知程式內部邏輯結構,通過測試來證明程式內部能按照預定要求正確工作。
    • 灰盒:介於黑盒與白盒之間的一種測試,關注輸出對於輸入的正確性,同時也關注內部表現,但這種關注不象白盒那樣詳細、完整。
  • 根據專項劃分
    • 功能:測試程式是否滿足使用者提出的表面需求。
    • 效能:測試程式的工作效率。
    • 安全:測試程式是否能保護使用者的資訊、確保資訊不被輕易盜取。
  • 根據測試點劃分
    • 相容性:測試程式在不同平臺下的表現。
    • 易用性:測試程式是否友好,滿足使用者的使用習慣。
    • UI元素:頁面佈局是否一致、美觀。

為什麼要測試

  • 作為現有程式碼行為的描述。
  • 提升專案的健壯性、可靠性。
  • 減少專案迭代重構帶來的風險。
  • 促使開發者寫可測試的程式碼。
  • 依賴的元件如果有修改,受影響的元件能在測試中發現錯誤。
  • 降低人力測試的成本、提高測試的效率。
  • ......

前端測試金字塔

測試框架、斷言庫(chai、expect.js、should.js、Sinon.JS等)、工具有很多,以下僅列出一些比較常見的或是本人正在使用的測試框架/工具。

React單元測試

  • 技術選型

    • Jest

      • Jest是Facebook開源的前端測試框架,內建JSDOM、快照、Mock功能以及測試程式碼覆蓋率等。
    • Enzyme

      • Enzyme是Airbnb開源的React測試工具,對官方的測試工具庫進行二次封裝,並通過模仿jQuery的方式來操作dom,具有可以輕鬆斷言,操縱和遍歷React Components的輸出的優點。
  • 環境搭建

    • 安裝 Jest

      npm install --save-dev jest
      複製程式碼
    • 安裝 Enzyme

      npm install --save-dev enzyme jest-enzyme
      
      // react介面卡需要與react版本想對應 參考: https://airbnb.io/enzyme/
      npm install --save-dev enzyme-adapter-react-16
      
      // 如果使用的是16.4及以上版本的react,還可以通過安裝jest-environment-enzyme來設定jest的環境
      npm install --save-dev jest-environment-enzyme
      複製程式碼
    • 安裝 Babel

      npm install --save-dev babel-jest babel-core
      
      npm install --save-dev babel-preset-env
      
      npm install --save-dev babel-preset-react
      
      // 無所不能stage-0
      npm install --save-dev babel-preset-stage-0
      
      // 按需載入外掛
      npm install --save-dev babel-plugin-transform-runtime
      複製程式碼
    • 修改 package.json

      // package.json
      {
        "scripts": {
          "test": "jest"
        }
      }
      複製程式碼
    • 安裝其他需要用到的庫

      // 安裝jquery來操作dom
      npm install --save jquery
      複製程式碼
    • Jest 配置

      更多關於Jest的配置請查閱jestjs.io/docs/zh-Han…

      // jest.config.js
      module.exports = {
        setupFiles: ['./jest/setup.js'], // 配置測試環境,這些指令碼將在執行測試程式碼本身之前立即在測試環境中執行。
        setupTestFrameworkScriptFile: 'jest-enzyme', // 配置測試框架
        testEnvironment: 'enzyme', // 使用jest-environment-enzyme時所需的配置
        testEnvironmentOptions: {
          enzymeAdapter: 'react16', // react介面卡的版本
        },
        testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/src/'], // 忽略的目錄
        transform: { // 編譯配置
          '^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
          '^.+\\.(css|scss)$': '<rootDir>/jest/cssTransform.js',
          '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '<rootDir>/jest/fileTransform.js',
        },
      };
      複製程式碼
    • 使用Jest測試一個Function

      // add.js
      const add = (a, b) => a + b;
      export default add;
      複製程式碼
      // __tests__/add-test.js
      import add from '../add';
      
      describe('add() test:', () => {
        it('1+2=3', () => {
          expect(add(1, 2)).toBe(3); // 斷言是通過的,但是如果我們傳入的是string型別呢?
        });
      });
      複製程式碼
      // 執行Jest
      npm test
      
      // 或
      jest add-test.js --verbose
      複製程式碼
    • 快照測試

      如果想確保UI不會意外更改,快照測試就是一個非常有用的工具。

      // 安裝react、react-dom以及react-test-renderer
      npm install --save react react-dom react-test-renderer
      複製程式碼
      // components/Banner.js
      import React from 'react';
      
      const Banner = ({ src }) => (
        <div>
          <img src={src} alt="banner" />
        </div>
      );
      
      export default Banner;
      複製程式碼
      // __tests__/components/Banner-test.js
      import React from 'react';
      import renderer from 'react-test-renderer';
      import Banner from '../../components/Banner';
      
      describe('<Banner />', () => {
        it('renders correctly', () => {
          const tree = renderer.create(<Banner />).toJSON();
          expect(tree).toMatchSnapshot();
        });
      });
      複製程式碼
    • JSDOM(JS實現的無頭瀏覽器)

      jsdom最強大的能力是它可以在jsdom中執行指令碼。這些指令碼可以修改頁面內容並訪問jsdom實現的所有Web平臺API。

      // handleBtn.js
      const $ = require('jquery');
      
      $('#btn').click(() => $('#text').text('click on the button'));
      複製程式碼
      // handleBtn-test.js
      describe('JSDOM test', () => {
        it('click on the button', () => {
          // initialization document
          document.body.innerHTML = '<div id="btn"><span id="text"></span></div>';
      
          const $ = require('jquery');
      
          require('../handleBtn');
      
          // simulation button click
          $('#btn').click();
      
          // the text is updated as expected
          expect($('#text').text()).toEqual('click on the button');
        });
      });
      複製程式碼
    • Mock模組

      在需要Mock的模組目錄下新建一個__mocks__目錄,然後新建一樣的檔名,最後在測試程式碼中新增上jest.mock('../moduleName'),即可實現模組的Mock。

      // request.js
      const http = require('http');
      
      export default function request(url) {
        return new Promise(resolve => {
          // 這是一個HTTP請求的例子, 用來從API獲取使用者資訊
          // This module is being mocked in __mocks__/request.js
          http.get({ path: url }, response => {
            let data = '';
            response.on('data', _data => {
              data += _data;
            });
            response.on('end', () => resolve(data));
          });
        });
      }
      
      複製程式碼
      // __mocks__/request.js
      const users = {
        4: { name: 'Mark' },
        5: { name: 'Paul' },
      };
      
      export default function request(url) {
        return new Promise((resolve, reject) => {
          const userID = parseInt(url.substr('/users/'.length), 10);
          process.nextTick(() => (users[userID] ? resolve(users[userID]) : reject(new Error(`User with ${userID} not found.`))));
        });
      }
      複製程式碼
      // __tests__/request.js
      jest.mock('../request.js');
      import request from '../request';
      
      describe('mock request.js', () => {
        it('works with async/await', async () => {
          expect.assertions(2); // 呼叫2個斷言
      
          // 正確返回的斷言
          const res = await request('/users/4');
          expect(res).toEqual({ name: 'Mark' });
      
          // 錯誤返回的斷言
          await expect(request('/users/41')).rejects.toThrow('User with 41 not found.');
        });
      });
      複製程式碼
    • 測試元件節點

      • shallow:淺渲染,將元件作為一個單元進行測試,並確保您的測試不會間接斷言子元件的行為。支援互動模擬以及元件內部函式測試
      • render:靜態渲染,將React元件渲染成靜態的HTML字串,然後使用Cheerio這個庫解析這段字串,並返回一個Cheerio的例項物件,可以用來分析元件的html結構。可用於子元件的判斷。
      • mount:完全渲染,完整DOM渲染非常適用於您擁有可能與DOM API互動或需要測試包含在更高階元件中的元件的用例。依賴jsdom庫,本質上是一個完全用JS實現的無頭瀏覽器。支援互動模擬以及元件內部函式測試
      // components/List.js
      import React, { Component } from 'react';
      
      export default class List extends Component {
        constructor(props) {
          super(props);
      
          this.state = {
            list: [1],
          };
        }
      
        render() {
          const { list } = this.state;
          return (
            <div>
              {list.map(item => (
                <p key={item}>{item}</p>
              ))}
            </div>
          );
        }
      }
      複製程式碼
      // __tests__/components/List-test.js
      import React from 'react';
      import { shallow, render, mount } from 'enzyme';
      import List from '../../components/List';
      
      describe('<List />', () => {
        it('shallow:render <List /> component', () => {
          const wrapper = shallow(<List />);
          expect(wrapper.find('div').length).toBe(1);
        });
      
        it('render:render <List /> component', () => {
          const wrapper = render(<List />);
          expect(wrapper.html()).toBe('<p>1</p>');
        });
      
        it('mount:allows us to setState', () => {
          const wrapper = mount(<List />);
          wrapper.setState({
            list: [1, 2, 3],
          });
          expect(wrapper.find('p').length).toBe(3);
        });
      });
      複製程式碼
    • 測試元件內部函式

      // components/TodoList.js
      import React, { Component } from 'react';
      
      export default class TodoList extends Component {
        constructor(props) {
          super(props);
      
          this.state = {
            list: [],
          };
        }
      
        handleBtn = () => {
          const { list } = this.state;
          this.setState({
            list: list.length ? [...list, list.length] : [0],
          });
        };
      
        render() {
          const { list } = this.state;
          return (
            <div>
              {list.map(item => (
                <p key={item}>{item}</p>
              ))}
              <button type="button" onClick={() => this.handleBtn}>
                add item
              </button>
            </div>
          );
        }
      }
      複製程式碼
      // __tests__/components/TodoList-test.js
      import React from 'react';
      import { shallow } from 'enzyme';
      import TodoList from '../../components/TodoList';
      
      describe('<TodoList />', () => {
        it('calls component handleBtn', () => {
          const wrapper = shallow(<TodoList />);
      
          // 建立模擬函式
          const spyHandleBtn = jest.spyOn(wrapper.instance(), 'handleBtn');
      
          // list的預設長度是0
          expect(wrapper.state('list').length).toBe(0);
      
          // 首次handelBtn
          wrapper.instance().handleBtn();
          expect(wrapper.state('list').length).toBe(1);
      
          // 模擬按鈕點選
          wrapper.find('button').simulate('click');
          expect(wrapper.state('list').length).toBe(2);
      
          // 總共執行handleBtn函式兩次
          expect(spyHandleBtn).toHaveBeenCalledTimes(2);
      
          // 恢復mockFn
          spyHandleBtn.mockRestore();
        });
      });
      複製程式碼
    • 測試程式碼覆蓋率

      • 語句覆蓋率(statement coverage):是否測試用例的每個語句都執行了
      • 分支覆蓋率(branch coverage):是否測試用例的每個if程式碼塊都執行了
      • 函式覆蓋率(function coverage):是否測試用例的每一個函式都呼叫了
      • 行覆蓋率(line coverage):是否測試用例的每一行都執行了
      // jest.config.js
      module.exports = {
        collectCoverage: true, // 收集覆蓋率資訊
        coverageThreshold: { // 設定覆蓋率最低閾值
          global: {
            branches: 50,
            functions: 50,
            lines: 50,
            statements: 50,
          },
          './firstTest/components': {
            branches: 100,
          },
        },
      };
      複製程式碼

redux單元測試

  • 安裝

    • redux-thunk :一個用於管理redux副作用(Side Effect,例如非同步獲取資料)的庫
    • redux-saga : redux副作用管理更容易,執行更高效,測試更簡單,在處理故障時更容易。
    • fetch-mock :模擬fetch請求
    • node-fetch :fetch-mock依賴node-fetch
    • redux-mock-store : 用於測試Redux非同步操作建立器和中介軟體的模擬儲存。主要用於測試與操作相關的邏輯,而不是與reducer相關的邏輯。
    • redux-actions-assertions 用於測試redux actions的斷言庫
    npm install --save redux-thunk
    
    npm install --save redux-saga
    
    npm install --save-dev fetch-mock redux-mock-store redux-actions-assertions
    
    npm install -g node-fetch
    複製程式碼
  • 測試同步action

    // actions/todoActions.js
    export const addTodo = text => ({ type: 'ADD_TODO', text });
    
    export const delTodo = text => ({ type: 'DEL_TODO', text });
    複製程式碼
    // __tests__/actions/todoActions-test.js
    import * as actions from '../../actions/todoActions';
    
    describe('actions', () => {
      it('addTodo', () => {
        const text = 'hello redux';
        const expectedAction = {
          type: 'ADD_TODO',
          text,
        };
        expect(actions.addTodo(text)).toEqual(expectedAction);
      });
    
      it('delTodo', () => {
        const text = 'hello jest';
        const expectedAction = {
          type: 'DEL_TODO',
          text,
        };
        expect(actions.delTodo(text)).toEqual(expectedAction);
      });
    });
    複製程式碼
  • 測試基於redux-thunk的非同步action

    // actions/fetchActions.js
    export const fetchTodosRequest = () => ({ type: 'FETCH_TODOS_REQUEST' });
    
    export const fetchTodosSuccess = data => ({
      type: 'FETCH_TODOS_SUCCESS',
      data,
    });
    
    export const fetchTodosFailure = data => ({
      type: 'FETCH_TODOS_FAILURE',
      data,
    });
    
    export function fetchTodos() {
      return dispatch => {
        dispatch(fetchTodosRequest());
        return fetch('http://example.com/todos')
          .then(res => res.json())
          .then(body => dispatch(fetchTodosSuccess(body)))
          .catch(ex => dispatch(fetchTodosFailure(ex)));
      };
    }
    複製程式碼
    // __tests__/actions/fetchActions-test.js
    import configureMockStore from 'redux-mock-store';
    import thunk from 'redux-thunk';
    import fetchMock from 'fetch-mock';
    import * as actions from '../../actions/fetchActions';
    
    const middlewares = [thunk];
    const mockStore = configureMockStore(middlewares);
    
    describe('fetchActions', () => {
      afterEach(() => {
        fetchMock.restore();
      });
    
      it('在獲取todos之後建立FETCH_TODOS_SUCCESS', async () => {
        fetchMock.getOnce('/todos', {
          body: { todos: ['do something'] },
          headers: { 'content-type': 'application/json' },
        });
    
        // 所期盼的action執行記錄:FETCH_TODOS_REQUEST -> FETCH_TODOS_SUCCESS
        const expectedActions = [
          { type: 'FETCH_TODOS_REQUEST' },
          { type: 'FETCH_TODOS_SUCCESS', data: { todos: ['do something'] } },
        ];
    
        const store = mockStore({ todos: [] });
    
        // 通過async/await來優化非同步操作的流程
        await store.dispatch(actions.fetchTodos());
    
        // 斷言actios是否正確執行
        expect(store.getActions()).toEqual(expectedActions);
      });
    
      it('在獲取todos之後建立FETCH_TODOS_FAILURE', async () => {
        fetchMock.getOnce('/todos', {
          throws: new TypeError('Failed to fetch'),
        });
    
        const expectedActions = [
          { type: 'FETCH_TODOS_REQUEST' },
          { type: 'FETCH_TODOS_FAILURE', data: new TypeError('Failed to fetch') },
        ];
        const store = mockStore({ todos: [] });
    
        await store.dispatch(actions.fetchTodos());
        expect(store.getActions()).toEqual(expectedActions);
      });
    });
    複製程式碼
  • 測試 Sagas

    有兩個主要的測試 Sagas 的方式:一步一步測試 saga generator function,或者執行整個 saga 並斷言 side effects。

    • 測試 Sagas Generator Function 中的純函式
    // sagas/uiSagas.js
    import { put, take } from 'redux-saga/effects';
    
    export const CHOOSE_COLOR = 'CHOOSE_COLOR';
    export const CHANGE_UI = 'CHANGE_UI';
    
    export const chooseColor = color => ({
      type: CHOOSE_COLOR,
      payload: {
        color,
      },
    });
    
    export const changeUI = color => ({
      type: CHANGE_UI,
      payload: {
        color,
      },
    });
    
    export function* changeColorSaga() {
      const action = yield take(CHOOSE_COLOR);
      yield put(changeUI(action.payload.color));
    }
    複製程式碼
    // __tests__/sagas/uiSagas-test.js
    import { put, take } from 'redux-saga/effects';
    import {
      changeColorSaga, CHOOSE_COLOR, chooseColor, changeUI,
    } from '../../sagas/uiSagas';
    
    describe('uiSagas', () => {
      it('changeColorSaga', () => {
        const gen = changeColorSaga();
    
        expect(gen.next().value).toEqual(take(CHOOSE_COLOR));
    
        const color = 'red';
        expect(gen.next(chooseColor(color)).value).toEqual(put(changeUI(color)));
      });
    });
    複製程式碼
    • 測試 Sagas Generator Function 中的 side effects(副作用)
    // sagas/fetchSagas.js
    import { put, call } from 'redux-saga/effects';
    
    export const fetchDatasSuccess = data => ({
      type: 'FETCH_DATAS_SUCCESS',
      data,
    });
    
    export const fetchDatasFailure = data => ({
      type: 'FETCH_DATAS_FAILURE',
      data,
    });
    
    export const myFetch = (...parmas) => fetch(...parmas).then(res => res.json());
    
    export function* fetchDatas() {
      try {
        const result = yield call(myFetch, '/datas');
        yield put(fetchDatasSuccess(result));
      } catch (error) {
        yield put(fetchDatasFailure(error));
      }
    }
    複製程式碼
    // __tests__/sagas/fetchSagas-test.js
    import { runSaga } from 'redux-saga';
    import { put, call } from 'redux-saga/effects';
    import fetchMock from 'fetch-mock';
    
    import {
      fetchDatas, fetchDatasSuccess, fetchDatasFailure, myFetch,
    } from '../../sagas/fetchSagas';
    
    describe('fetchSagas', () => {
      afterEach(() => {
        fetchMock.restore();
      });
    
      // 一步步generator function 並斷言 side effects
      it('fetchDatas success', async () => {
        const body = { text: 'success' };
        fetchMock.get('/datas', {
          body,
          headers: { 'content-type': 'application/json' },
        });
    
        const gen = fetchDatas();
    
        // 呼叫next().value來獲取被yield的effect,並拿它和期望返回的effect進行比對
        expect(gen.next().value).toEqual(call(myFetch, '/datas'));
    
        const result = await fetch('/datas').then(res => res.json());
        expect(result).toEqual(body);
    
        // 請求成功
        expect(gen.next(result).value).toEqual(put(fetchDatasSuccess(body)));
      });
    
      it('fetchDatas fail', () => {
        const gen = fetchDatas();
    
        expect(gen.next().value).toEqual(call(myFetch, '/datas'));
    
        // 模擬異常時的處理是否預期
        const throws = new TypeError('Failed to fetch');
        expect(gen.throw(throws).value).toEqual(put(fetchDatasFailure(throws)));
      });
    
      // 執行整個 saga 並斷言 side effects。(推薦方案)
      it('runSage success', async () => {
        const body = { text: 'success' };
        fetchMock.get('/datas', {
          body,
          headers: { 'content-type': 'application/json' },
        });
    
        const dispatched = [];
        await runSaga({
          dispatch: action => dispatched.push(action),
        }, fetchDatas).done;
        expect(dispatched).toEqual([fetchDatasSuccess(body)]);
      });
    
      it('runSage fail', async () => {
        const throws = new TypeError('Failed to fetch');
        fetchMock.get('/datas', {
          throws,
        });
    
        const dispatched = [];
        await runSaga({
          dispatch: action => dispatched.push(action),
        }, fetchDatas).done;
    
        expect(dispatched).toEqual([fetchDatasFailure(throws)]);
      });
    });
    複製程式碼
  • 測試 reducers

    // reducers/todos.js
    export default function todos(state = [], action) {
      switch (action.type) {
        case 'ADD_TODO':
          return [
            {
              text: action.text,
            },
            ...state,
          ];
    
        default:
          return state;
      }
    }
    複製程式碼
    // __tests__/reducers/todos-test.js
    import todos from '../../reducers/todos';
    
    describe('reducers', () => {
      it('should return the initial state', () => {
        expect(todos(undefined, {})).toEqual([]);
      });
    
      it('todos initial', () => {
        expect(todos([{ text: '1' }], {})).toEqual([{ text: '1' }]);
      });
    
      it('should handle ADD_TODO', () => {
        expect(todos([], { type: 'ADD_TODO', text: 'text' })).toEqual([
          {
            text: 'text',
          },
        ]);
      });
    });
    複製程式碼

參考資料

相關文章