React中的单元测试

·  阅读 2345

"编写软件是人类做的最难的事情" ———— Douglas Crockford
作为程序员,我们的工作中不仅仅是编写新代码,很多时候我们是在维护和调试别人的代码。可测试的代码更加容易测试,意味着它更加容易维护;已维护则意味着它能让人更加容易理解——更加容易理解,又会让测试变得更加容易。
随着前端业务的日渐复杂,前端工程中的单元测试愈发重要。如果有可测试的代码组成的测试,就可以帮助我们理解一些看似细小的变更所带来的的影响,以及可以保证自己的修改不会影响到其他的功能,从而能够更好的修复和更改代码。
本文将从单元测试角度来介绍一些相关的基本知识,进而去探索Jest和Enzyme在基于React开发的前端应用中的一些尝试。

什么是单元测试

单元测试通过对最小的可测试单元(通常为单个函数或小组)进行测试和验证,来保证代码的健壮性。
单元测试是开发者的第一道防线。单元测试不仅能强迫开发人员理解我们的代码,也能帮助我们记录和调试代码。好的单元测试用例甚至可以充当开发文档供开发者阅读。

什么不是单元测试

需要访问数据库的测试
需要网络通信的测试不是单元测试
需要调用文件系统的测试不是单元测试
需要对环境做特定配置(比如:编辑配置文件)才能运行的测试不是单元测试
--- 修改代码的艺术

基本概念

测试套件/用例聚合 (test suite/case aggregation)

单元测试框架中最重要的部分就是将测试聚合到测试套件和测试用例中。
测试套件和测试用例分散在很多文件中,而且每个测试文件通常只包括单个模块的测试。最好的方法就是将单个模块的所有测试整合到一个单独的测试套件中。这个测试套件包含多个测试用例,每个测试模块只测试模块的很小一部分功能。通过使用测试套件和测试用例级别的setupteardown函数,可以对测试前后的内容进行清理。

断言 (Assertion)

单元测试的核心就是断言,通过单元我们可以判断代码是否达到目的。
常用的有assert should expect等断言关键字。assert最为简单,相比之下expect更接近正常阅读的顺序。 对断言关键字有兴趣的话,可以看看chai。这个断言库很强大,提供了对多种断言关键字的支持。

依赖项 (Dependencies)

单元测试应该加载在所需测试的最小单元进行测试,任何额外的代码都有可能会影响测试或被测试代码。为了避免加载外部依赖,我们可以使用模(mock)、桩(stub)以及测试替身 (test double)。它们都试图尽量将被测试代码与其他代码隔离。

测试替身(test double)

测试替身描述的使用stub或mock模拟依赖对象进行测试。在同一时间,替身可以用stub表示,也可以用mock表示,以确保外部方法和api被调用,记录调用次数,捕获调用参数,并返回响应。
在方法被调用方面能够记录方法调用并捕获相关信息的测试替身,被称为间谍 (spy).

1. 模 (mock)

mock对象用于验证函数是否能够正确调用外部api。单元测试通过引入mock对象验证被测试函数是否传递正确的参数给外部对象。

2. 桩 (stub)

stub对象用于向被测试的函数返回所封装的值。stub对象不关心外部对象方法是如何调用的,它只是返回所选择的封装对象。

3. 间谍 (spy)

spy通常附加到真正的对象上,通过拦截一些方法的调用(有时甚至是带有特定参数的拦截方法调用),来返回封装过的响应内容或追踪方法被调用的次数。没有被拦截的方法则按正常流程对真正的对象进行处理。

代码覆盖率 (Code Coverage)

代码覆盖率是用来衡量测试完整性的一项指标,通常分为两部分:代码行的覆盖率(line coverage)和函数的覆盖率(function coverage)。理论上来说,“覆盖”的代码行数越多,测试就越完整。但是从我个人的角度来看:

单元测试的首要目的不是为了能够编写出大覆盖率的全部通过的测试代码,而是需要从使用者(调用者)的角度出发,尝试函数逻辑的各种可能性,进而辅助性增强代码质量。

什么是Jest

Jest是Facebook开发的一款单元测试框架: Jest不仅仅只适用于React,同时也提供了对于Node/Angular/Vue/Typescript等的支持。

Jest特点:

  1. 易用性:基于Jasmine,集成了expect断言和多种matchers
  2. 适应性: 模块化,易于扩展和配置
  3. 快照测试:通过对组件或数据生成快照,可以自动进行深比较
  4. 异步测试:支持callback promise async/await的测试
  5. mock系统:提供了一套强大的mock系统,支持自动或手动mock
  6. 静态分析结果生成:集成Istanbul,可以生成测试覆盖率报告

基本概念

Matchers

Jest中,通过expect断言结合matchers,可以帮助我们用多种方式来测试代码。更多内容可以参见Expect, 以下为一些基本的示例:

describe('common use of matchers', () => {
  it('two plus two equal four', () => {
    expect(2 + 2).toBe(4);
  });
  it('check value of an object', () => {
    const obj = { id: 1, name: 'test' };
    obj['name'] = 'nameChanged';
    expect(obj).toEqual({ id: 1, name: 'nameChanged' });
  });
  it('case of truthiness', () => {
    const n = null;
    expect(n).toBeNull();
    expect(n).toBeDefined();
    expect(n).not.toBeUndefined();
    expect(n).not.toBeTruthy();
    expect(n).toBeFalsy();
  });
  it('case of numbers', () => {
    const value = 2 + 1;
    expect(value).toBeGreaterThanOrEqual(3);
    expect(value).toBeGreaterThan(2);
    expect(value).toBeLessThan(4);
    expect(value).toBeLessThanOrEqual(3);
  });
  it('case of float numbers', () => {
    const value = 0.1 + 0.2;
    expect(value).toBeCloseTo(0.3);
  });
  it('case of array and iterables', () => {
    const fruits = ['apple', 'banana', 'cherry', 'pear', 'orange'];
    expect(fruits).toContain('banana');
    expect(new Set(fruits)).toContain('pear');
  });
  it('case of exceptions', () => {
    const loginWithoutToken = () => {
      throw new Error('You are not authorized');
    };
    expect(loginWithoutToken).toThrow();
    expect(loginWithoutToken).toThrow('You are not authorized');
  });
});
复制代码

Setup and Teardown

在单元测试的编写中,我们往往需要在测试开始前做一些准备工作,以及在测试结束运行后进行整理工作。Jest提供了相应的方法来帮助我们做这些工作。
如果想进行一次性设置,我们可以使用beforeAllafterAll来处理:

beforeAll(() => {
  //  预处理操作
});

afterAll(() => {
  //  整理工作
});
test('has foo', () => {
  expect(testObject.foo).toBeTruthy();
})
复制代码

如果想在每次测试前后都进行设置和清理,我们可以使用beforeEachafterEach:

beforeEach(() => {
  //  每次测试前的预处理工作
});

afterEach(() => {
  //  每次测试后的整理工作
});
test('has foo', () => {
  expect(testObject.foo).toBeTruthy();
})
复制代码

测试异步代码

Jest对于测试异步代码也提供了很好的支持,例如(以下为官网示例):
1.测试callback, 假设我们有一个fetchData(callback)的回调函数:

const helloCallback = (name: string, callback: (name: string) => void) => {
  setTimeout(() => {
    callback(`Hello ${name}`);
  }, 1000);
};

test('should get "Hello Jest"', done => {
  helloCallback('Jest', result => {
    expect(result).toBe('Hello Jest');
    done();
  });
});
复制代码

2.测试promise以及async\await:

const helloPromise = (name: string) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(`Hello ${name}`);
    }, 1000);
  });
};

test('should get "Hello World"', () => {
  expect.assertions(1);
  return helloPromise('Jest').then(data => {
    expect(data).toBe('Hello Jest');
  });
});

test('should get "Hello World"', async () => {
  expect.assertions(1);
  const data = await helloPromise('Jest');
  expect(data).toBe('Hello Jest');
});
复制代码

mock functions

Jest提供了很方便的模拟函数的方法,以下为mocking modules的示例代码,更多示例可以参考官网文档:

// users.js
import axios from 'axios';

class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data);
  }
}

export default Users;

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');
test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockResolvedValue(resp);
  return Users.all().then(data => expect(data).toEqual(users));
});
复制代码

什么是Enzyme

Enzyme是由Airbnb开源的一个React的JavaScript测试工具,是对官方测试工具库(react-addons-test-utils)的封装。它使用了cheerio库来解析虚拟DOM,提供了类似于JQuery的API来操作虚拟DOM,可以方便我们在单元测试中判断、操纵和遍历React Components的输出。

三种渲染方法

shallow([options]) => ShallowWrapper

shallow方法是对官方的Shallow Rendering的封装。浅渲染只会渲染出组件的第一层DOM结构,其子组件不会被渲染,从而保证渲染的高效率和单元测试的高速度。

import { shallow } from 'enzyme';

describe('enzyme shallow rendering', () => {
  it('todoList has three todos', () => {
    const todoList = shallow(<App />);
    expect(todoList.find('.todo')).toHaveLength(3);
  });
});
复制代码

mount(node[, options]) => ReactWrapper

mount方法会将React Components渲染为真实的DOM节点,适合于需要测试使用DOM API的组件的场景。测试如果在同样的DOM环境下进行,有可能会互相影响,这时候可以使用Enzyme提供的unmount方法来进行清理。

import { mount } from 'enzyme';

describe('enzyme full rendering', () => {
  it('todoList has none todos done', () => {
    const todoList = mount(<TodoList />);
    expect(todoList.find('.todo-done')).toHaveLength(0);
  });
});
复制代码

render() => CheerioWrapper

render方法返回的是一个用CherrioWrapper包裹的React Components渲染成的静态HTML字符串。这个CherrioWrapper可以帮助我们去分析最终代码的HTML代码结构。

import { render } from 'enzyme';

describe('enzyme static rendering', () => {
  it('no done todo items', () => {
    const todoList = render(<TodoList />);
    expect(todoList.find('.todo-done')).toHaveLength(0);
    expect(todoList.html()).toContain(<div className="todo" />);
  });
});
复制代码

选择器与模拟事件

无论哪种渲染方法,返回的wrapper都有一个find方法,它接受一个selector参数并返回一个类型相同的wrapper对象。类似的还有:at last first等方法可以选择具体位置的子组件,simulate方法可以在组件上模拟某种事件。 Enzyme中的Selectors类似CSS选择器,如果需要支持复杂的CSS选择器,则需要从react-dom中引入findDOMNode方法。

//  class selector
wrapper.find('.bar')
//  tag selector
wrapper.find('div')
//  id selector
wrapper.find('#bar')
//  component display name 
wrapper.find('Foo')
//  property selector
const wrapper = shallow(<Foo />)
wrapper.find({ prop: 'value] }))
复制代码

测试组件状态

Enzyme提供了类似setStatesetProps之类的方法,可以用来模拟state和props的变化。类似的还有setContext等等。注意setState方法只能在root instance上使用。

//  set state
interface IState {
  name: string;
}
class Foo extends React.Component<any, IState> {
  state = { name: 'foo' };
  render() {
    const { name } = this.state;
    return <div className={name}>{name}</div>;
  }
}
const wrapper = shallow(<Foo />);
expect(wrapper.find('.foo')).toHaveLength(1);
expect(wrapper.find('.bar')).toHaveLength(0);
wrapper.setState({ name: 'bar' });
expect(wrapper.find('.foo')).toHaveLength(0);
expect(wrapper.find('.bar')).toHaveLength(1);
复制代码
//  set props
interface IProps {
  name: string;
}
function Foo({ name }: IProps) {
  return <div className={name} />;
}
const wrapper = shallow(<Foo name="foo" />);
expect(wrapper.find('.foo')).toHaveLength(1);
expect(wrapper.find('.bar')).toHaveLength(0);
wrapper.setProps({ name: 'bar' });
expect(wrapper.find('.foo')).toHaveLength(0);
expect(wrapper.find('.bar')).toHaveLength(1);
复制代码

实例讲解

配置与初始测试

由于我们的项目都是基于umi去开发的,而且umi框架下也已经集成了Jest,所以示例也基于Jest来创建。代码可见:Test React App with Jest and Enzyme 。在这里,我将演示如何去配置其他依赖,以及进行初步的测试编写。
打开src\pages\__tests__\index.test.tsx, 可以看到umi中默认使用了react-test-renderer作为DOM测试工具,并且已经有了第一段测试代码:

describe('Page: index', () => {
  it('Render correctly', () => {
    const wrapper: ReactTestRenderer = renderer.create(<Index />);
    expect(wrapper.root.children.length).toBe(1);
    const outerLayer = wrapper.root.children[0] as ReactTestInstance;
    expect(outerLayer.type).toBe('div');
    expect(outerLayer.children.length).toBe(2);
  });
})
复制代码

添加依赖

然后我们开始需要添加Enzyme,在React 16.x中,我们还需要Enzyme-Adapter-16,此外我们还需要添加对应的typescript的类型定义依赖:

yarn add -D enzyme @types/enzyme enzyme-adapter-react-16 @types/enzyme-adapter-react-16
复制代码

然后在之前打开的文件中添加以下代码:

import { configure, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
//  配置enzyme的adapter
configure({ adapter: new Adapter() });
复制代码

用Enzyme重写测试

describe('Page: index', () => {
  it('Render correctly', () => {
    const wrapper = mount(<Index />);
    expect(wrapper.children()).toHaveLength(1);
    const outerLayer = wrapper.childAt(0);
    expect(outerLayer.type()).toBe('div');
    expect(outerLayer.children()).toHaveLength(2);
  });
});
复制代码

运行umi test,然后控制台中就会看到以下信息:

测试React Hooks

然后打开index.tsx, 并引入useState, 然后在function顶部添加如下代码:

const [myState, setMyState] = useState('Welcome to Umi');
const changeState = () => setMyState('Welcome to Jest and Enzyme');
复制代码

然后将以下代码:

<a href="https://umijs.org/guide/getting-started.html">
  Getting Started
</a>
复制代码

替换为:

<div id="intro">{myState}</div>
<button onClick={changeState}>Change</button
复制代码

然后增加如下的测试代码:

it('case of use state', () => {
  const wrapper = shallow(<Index />);
  expect(wrapper.find('#intro').text()).toBe('Welcome to Umi');
  wrapper.find('button').simulate('click');
  expect(wrapper.find('#intro').text()).toBe('Welcome to Jest and Enzyme');
})
复制代码

运行umi test, 可以发现我们的测试已经生效了。

增加快照测试

之前讲过了Jest是支持快照测试的。现在给Index添加快照。首先我们要添加如下依赖:

yarn add -D enzyme-to-json @types/enzyme-to-json
复制代码

然后在在测试用例中增加如下代码:

  it('matches snapshot', () => {
    const wrapper = shallow(<Index />);
    expect(toJson(wrapper)).toMatchSnapshot();
  });
复制代码

再运行umi test,我们可以看到snapshot已经生成了:

Todo List示例

接下来写一个相对完整的示例——todo list,该示例整合了redux,主要实现以下功能:

  1. 输入todo内容,点击创建按钮提交
  2. 展示创建的todo列表
  3. 点击todo,则删除该条

具体可以参考src\pages\todoDemo\index.tsx, 测试代码如下:

describe('<TodoList />', () => {
  it('matches snapshot', () => {
    const todos: Array<todo> = [];
    const wrapper = shallow(<TodoList todos={todos} />);
    expect(toJson(wrapper)).toMatchSnapshot();
  });
  it('calls setState after input change', () => {
    const wrapper = shallow(<TodoList todos={[]} />);
    wrapper.find('input').simulate('change', { target: { value: 'Add Todo' } });
    expect(wrapper.state('input')).toEqual('Add Todo');
  });
  it('calls addTodo with submit button click', () => {
    const addTodo = jest.fn();
    const todos: Array<todo> = [];
    const wrapper = shallow(<TodoList todos={todos} addTodo={addTodo} />);
    wrapper.find('input').simulate('change', { target: { value: 'Add Todo' } });
    wrapper.find('.todo-add').simulate('click');
    expect(addTodo).toHaveBeenCalledWith('Add Todo');
  });
  it('calls removeTodo with todo item click', () => {
    const removeTodo = jest.fn();
    const todos: Array<todo> = [{ text: 'Learn Jest' }, { text: 'Learn RxJS' }];
    const wrapper = shallow(<TodoList todos={todos} removeTodo={removeTodo} />);
    wrapper
      .find('li')
      .at(0)
      .simulate('click');
    expect(removeTodo).toHaveBeenCalledWith(0);
  });
})
复制代码

生命周期示例

基于Jest和Enzyme,我们也可以很方便的去监听生命周期的变化:

import React from 'react';
import { shallow } from 'enzyme';

const orderCallback = jest.fn();

interface LifecycleState {
  currentLifeCycle: string;
}

class Lifecycle extends React.Component<any, LifecycleState> {
  static getDerivedStateFromProps() {
    orderCallback('getDerivedStateFromProps');
    return { currentLifeCycle: 'getDerivedStateFromProps' };
  }

  constructor(props: any) {
    super(props);
    this.state = { currentLifeCycle: 'constructor' };
    orderCallback('constructor');
  }

  componentDidMount() {
    orderCallback('componentDidMount');
    this.setState({
      currentLifeCycle: 'componentDidMount',
    });
  }

  componentDidUpdate() {
    orderCallback('componentDidUpdate');
  }

  render() {
    orderCallback('render');
    return <div>{this.state.currentLifeCycle}</div>;
  }
}

describe('React Lifecycle', () => {
  beforeEach(() => {
    orderCallback.mockReset();
  });

  it('renders in correct order', () => {
    const _ = shallow(<Lifecycle />);
    expect(orderCallback.mock.calls[0][0]).toBe('constructor');
    expect(orderCallback.mock.calls[1][0]).toBe('getDerivedStateFromProps');
    expect(orderCallback.mock.calls[2][0]).toBe('render');
    expect(orderCallback.mock.calls[3][0]).toBe('componentDidMount');
    expect(orderCallback.mock.calls[4][0]).toBe('getDerivedStateFromProps');
    expect(orderCallback.mock.calls[5][0]).toBe('render');
    expect(orderCallback.mock.calls[6][0]).toBe('componentDidUpdate');
    expect(orderCallback.mock.calls.length).toBe(7);
  });

it('detect lify cycle methods', () => {
    const _ = shallow(<Lifecycle />);
    expect(Lifecycle.getDerivedStateFromProps.call.length).toBe(1);
    expect(Lifecycle.prototype.render.call.length).toBe(1);
    expect(Lifecycle.prototype.componentDidMount.call.length).toBe(1);
    expect(Lifecycle.getDerivedStateFromProps.call.length).toBe(1);
    expect(Lifecycle.prototype.render.call.length).toBe(1);
    expect(Lifecycle.prototype.componentDidUpdate.call.length).toBe(1);
  });
});
复制代码

在Antd中需要注意的问题

Antd的源码中已经有很完备的单元测试支撑了,有兴趣可以去研究一下,这里不做展开,只对之前踩过的坑分析一下:

  1. Form表单的提交事件:
    这是来自antd源码的一段测试代码,我修改了一下,增加了一个提交事件:
class Demo extends React.Component<FormComponentProps> {
  reset = () => {
    const { form } = this.props;
    form.resetFields();
  };

  onSubmit = () => {
    const { form } = this.props;
    form.resetFields();
    //  提交操作
  };

  render() {
    const {
      form: { getFieldDecorator },
    } = this.props;
    return (
      <Form onSubmit={this.onSubmit}>
        <Form.Item>{getFieldDecorator('input', { initialValue: '' })(<Input />)}</Form.Item>
        <Form.Item>
          {getFieldDecorator('textarea', { initialValue: '' })(<Input.TextArea />)}
        </Form.Item>
        <button type="button" onClick={this.reset}>
          reset
        </button>
        <button type="submit">submit</button>
      </Form>
    );
  }
}
复制代码

测试重置表单事件,我们只需要模拟重置按钮的click时间:

  it('click to reset', () => {
    const wrapper = mount(<FormDemo />);
    wrapper.find('input').simulate('change', { target: { value: '111' } });
    wrapper.find('textarea').simulate('change', { target: { value: '222' } });
    expect(wrapper.find('input').prop('value')).toBe('111');
    expect(wrapper.find('textarea').prop('value')).toBe('222');
    wrapper.find('button[type="button"]').simulate('click');
    expect(wrapper.find('input').prop('value')).toBe('');
    expect(wrapper.find('textarea').prop('value')).toBe('');
  });
复制代码

如果要测试表单的提交事件,则应该模拟表单的submit事件(除非该提交事件是绑定在button元素上,而且button的type为“button”)

  it('click to submit', () => {
    const wrapper = mount(<FormDemo />);
    wrapper.find('input').simulate('change', { target: { value: '111' } });
    wrapper.find('textarea').simulate('change', { target: { value: '222' } });
    expect(wrapper.find('input').prop('value')).toBe('111');
    expect(wrapper.find('textarea').prop('value')).toBe('222');
    wrapper.find('form').simulate('submit');
    expect(wrapper.find('input').prop('value')).toBe('');
    expect(wrapper.find('textarea').prop('value')).toBe('');
  });
复制代码
  1. 关于Input.Search

antd的InputInput.TextArea可以直接模拟onChange事件,但是Input.Search中的onSearch并不是DOM原生事件,所以我们需要这样去测试:

describe('antd event test', () => {
  it('test search event', () => {
    const mockSearch = jest.fn();
    const wrapper = mount(
      <div>
        <Search onSearch={mockSearch} />
      </div>,
    );
    const onSearch = wrapper.find(Search).props().onSearch;
    if (onSearch !== undefined) {
      onSearch(searchText);
      expect(mockSearch).toBeCalledWith(searchText);
    } else {
      expect(mockSearch).not.toBeCalled();
    }
  });
});
复制代码

测试覆盖率

最后,我们运行一下umi test --coverage, 就可以看到最后的覆盖率数据了。其中未测试的代码为mapDispatchToPropsmapStateToProps

参考资料

  1. Testable JavaScript Ensuring Reliable Code, Mark Trostler
  2. TypeScript-React-Starter
  3. 修改代码的艺术, Michael Feathers, 译者:刘未鹏
分类:
前端
标签:
分类:
前端
标签: