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

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

經常使用術語

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

爲何要測試

  • 做爲現有代碼行爲的描述。
  • 提高項目的健壯性、可靠性。
  • 減小項目迭代重構帶來的風險。
  • 促使開發者寫可測試的代碼。
  • 依賴的組件若是有修改,受影響的組件能在測試中發現錯誤。
  • 下降人力測試的成本、提升測試的效率。
  • ......

前端測試金字塔

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

React單元測試

  • 技術選型jquery

    • Jestios

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

      • Enzyme是Airbnb開源的React測試工具,對官方的測試工具庫進行二次封裝,並經過模仿jQuery的方式來操做dom,具備能夠輕鬆斷言,操縱和遍歷React Components的輸出的優勢。
  • 環境搭建github

    • 安裝 Jestnpm

      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',
          },
        ]);
      });
    });
    複製代碼

參考資料

相關文章
相關標籤/搜索