前端自动化测试初探(一)

栏目: 编程工具 · 发布时间: 5年前

内容简介:第一次尝试将学习过的知识通过文章的方式记录下来。在写文章的过程中,发现自己更多的不足以及测试的重要性。本篇文章主要是记录使用Jest + Enzyme进行React技术栈单元测试所需要掌握的基本知识以及环境搭建。测试框架、断言库(chai、expect.js、should.js、Sinon.JS等)、工具有很多,以下仅列出一些比较常见的或是本人正在使用的测试框架/工具。

第一次尝试将学习过的知识通过文章的方式记录下来。在写文章的过程中,发现自己更多的不足以及测试的重要性。本篇文章主要是记录使用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',
          },
        ]);
      });
    });
    复制代码

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

人人都是产品经理

人人都是产品经理

苏杰 / 电子工业出版社 / 2014-9-1 / CNY 55.00

《人人都是产品经理(纪念版)》为经典畅销书《人人都是产品经理》的内容升级版本。对于大量成长起来的优秀互联网产品经理,为数不少想投身产品工作的其他岗位从业者,以及更多有志从事这一职业的学生而言,这本书曾是他们记忆深刻的启蒙读物、思想基石和行动手册。作者以分享经历与体会为出发点,以“朋友间聊聊如何做产品”的语气,将自己数年产品工作过程中学到的思维方法与做事方式,及其它们对自己的帮助,系统性地梳理为用户......一起来看看 《人人都是产品经理》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具