React中的單元測試

"編寫軟件是人類作的最難的事情" ———— Douglas Crockford
做爲程序員,咱們的工做中不只僅是編寫新代碼,不少時候咱們是在維護和調試別人的代碼。可測試的代碼更加容易測試,意味着它更加容易維護;已維護則意味着它能讓人更加容易理解——更加容易理解,又會讓測試變得更加容易。
隨着前端業務的日漸複雜,前端工程中的單元測試愈發重要。若是有可測試的代碼組成的測試,就能夠幫助咱們理解一些看似細小的變動所帶來的的影響,以及能夠保證本身的修改不會影響到其餘的功能,從而可以更好的修復和更改代碼。
本文將從單元測試角度來介紹一些相關的基本知識,進而去探索Jest和Enzyme在基於React開發的前端應用中的一些嘗試。html

什麼是單元測試

單元測試經過對最小的可測試單元(一般爲單個函數或小組)進行測試和驗證,來保證代碼的健壯性。
單元測試是開發者的第一道防線。單元測試不只能強迫開發人員理解咱們的代碼,也能幫助咱們記錄和調試代碼。好的單元測試用例甚至能夠充當開發文檔供開發者閱讀。前端

什麼不是單元測試

須要訪問數據庫的測試
須要網絡通訊的測試不是單元測試
須要調用文件系統的測試不是單元測試
須要對環境作特定配置(好比:編輯配置文件)才能運行的測試不是單元測試
--- 修改代碼的藝術node

基本概念

測試套件/用例聚合 (test suite/case aggregation)

單元測試框架中最重要的部分就是將測試聚合到測試套件和測試用例中。
測試套件和測試用例分散在不少文件中,並且每一個測試文件一般只包括單個模塊的測試。最好的方法就是將單個模塊的全部測試整合到一個單獨的測試套件中。這個測試套件包含多個測試用例,每一個測試模塊只測試模塊的很小一部分功能。經過使用測試套件和測試用例級別的setupteardown函數,能夠對測試先後的內容進行清理。react

斷言 (Assertion)

單元測試的核心就是斷言,經過單元咱們能夠判斷代碼是否達到目的。
經常使用的有assert should expect等斷言關鍵字。assert最爲簡單,相比之下expect更接近正常閱讀的順序。 對斷言關鍵字有興趣的話,能夠看看chai。這個斷言庫很強大,提供了對多種斷言關鍵字的支持。ios

依賴項 (Dependencies)

單元測試應該加載在所需測試的最小單元進行測試,任何額外的代碼都有可能會影響測試或被測試代碼。爲了不加載外部依賴,咱們可使用模(mock)、樁(stub)以及測試替身 (test double)。它們都試圖儘可能將被測試代碼與其餘代碼隔離。git

測試替身(test double)

測試替身描述的使用stub或mock模擬依賴對象進行測試。在同一時間,替身能夠用stub表示,也能夠用mock表示,以確保外部方法和api被調用,記錄調用次數,捕獲調用參數,並返回響應。
在方法被調用方面可以記錄方法調用並捕獲相關信息的測試替身,被稱爲間諜 (spy).程序員

1. 模 (mock)

mock對象用於驗證函數是否可以正確調用外部api。單元測試經過引入mock對象驗證被測試函數是否傳遞正確的參數給外部對象。github

2. 樁 (stub)

stub對象用於向被測試的函數返回所封裝的值。stub對象不關心外部對象方法是如何調用的,它只是返回所選擇的封裝對象。typescript

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, 譯者:劉未鵬
相關文章
相關標籤/搜索