June 21, 2017

使用 Enzyme 測試 React Component

最近正在認真的面對使用 React 開發,前陣子還尚未非常熟悉 Redux 的整個運作架構,幾乎是一邊寫功能,一邊看文件或其他資料,為了要求快速的讓東西能動,暫時打破了之前在使用 Rails 開發時先寫測試的原則,只有針對簡單的 reducers 這種 pure function 寫點簡單的測試,以確保 state 的變更是正確的

最近正在認真的面對使用 React 開發,前陣子還尚未非常熟悉 Redux 的整個運作架構,幾乎是一邊寫功能,一邊看文件或其他資料,為了要求快速的讓東西能動,暫時打破了之前在使用 Rails 開發時先寫測試的原則,只有針對簡單的 reducers 這種 pure function 寫點簡單的測試,以確保 state 的變更是正確的。對於 component 的測試沒有太大的概念,今天再認真的看了一下別人寫的程式,練習了一下,大致上有掌握基本寫 component 測試的一點點精精隨。之後就可以慢慢的再將測試帶進目前正在進行中的專案。

React Component 測試

目前使用的是 React 搭配 Enzyme 來寫測試,Enzyme 是一套 Airbnb 所開源的 React 測試 Library,提供不同的 render 方式,以及用起來具有 jQuery 風格的 Selector 。Enzyme 提供了三種 render 的方式可以使用分別是 shallowmount 以及 render。以下就針對這三種 render 方式用 todo app 來當作範例寫個簡單的測試。

import React, { Component, PropTypes } from 'react'
import classnames from 'classnames'
import TodoTextInput from './TodoTextInput'

class TodoItem extends Component {
  constructor(props, context) {
    super(props, context)
    this.state = {
      editing: false
    }
  }

  handleDoubleClick() {
    this.setState({ editing: true })
  }

  handleSave(id, text) {
    if (text.length === 0) {
      this.props.deleteTodo(id)
    } else {
      this.props.editTodo(id, text)
    }
    this.setState({ editing: false })
  }

  render() {
    const { todo, completeTodo, deleteTodo } = this.props

    let element
    if (this.state.editing) {
      element = (
        <TodoTextInput text={todo.text}
                       editing={this.state.editing}
                       onSave={(text) => this.handleSave(todo.id, text)} />
      )
    } else {
      element = (
        <div className="view">
          <input className="toggle"
                 type="checkbox"
                 checked={todo.completed}
                 onChange={() => completeTodo(todo.id)} />
          <label onDoubleClick={this.handleDoubleClick.bind(this)}>
            {todo.text}
          </label>
          <button className="destroy"
                  onClick={() => deleteTodo(todo.id)} />
        </div>
      )
    }

    return (
      <li className={classnames({
        completed: todo.completed,
        editing: this.state.editing
      })}>
        {element}
      </li>
    )
  }
}

TodoItem.propTypes = {
  todo: PropTypes.object.isRequired,
  editTodo: PropTypes.func.isRequired,
  deleteTodo: PropTypes.func.isRequired,
  completeTodo: PropTypes.func.isRequired
}

export default TodoItem

Shallow Rendering

以上就用 Todo 這個 render 的方式,如果要針對一個 component 做 unit test 的時候會很好用,像是要測試是否該出現的 component 都有出現,或者是丟一些 props 進去,看是否有正確的 render 出來。

以下測試的例子中,由於並沒有涉及跟其他 child component 的互動,只是要檢查一開始所預設的 props 值丟進 TodoItem 時,render 出來的畫面是否有正確的資訊,所以在此使用 shallow 這個 render 的方式即可。

import React from 'react';
import { expect } from 'chai';
import { shallow, mount } from 'enzyme';
import TodoItem from '../TodoItem';
import TodoTextInput from '../TodoTextInput';
import sinon from 'sinon'

function setup() {
  const props = {
    todo: {
      id: 0,
      text: 'Practice Enzyme',
      completed: false
    },
    completeTodo: sinon.spy()
  }
  
  return {
    props
  }
}

describe('components', () => {
  describe('TodoItem', () => {
    it('initial render should contain todo text', () => {
      const { props } = setup()

      let wrapper = shallow(<TodoItem {...props}/>);

      expect(wrapper.find('label').text()).equal('Practice Enzyme')
    })

    it('checked is false', () => {
      const { props } = setup()

      let wrapper = shallow(<TodoItem {...props}/>);

      expect(wrapper.find('input').props().checked).equal(false)
    })
  })
})

Full DOM Rendering

使用 mount 這種 render 方式時,會將該 component 以及其中包含的 child component 都完整的 render 出來。在以下的測試例子中 TodoItem 包含了一個 TodoTextInput 在其中,目的是要測試當點擊 label 時,TodoTextInput 這個 component 中的 input 是否有正確的出現,所以在此需要使用 mount 來 render。

另外在這邊還有使用到 sinon 這個 test framework,來 mock 一些複雜的 function,方便測試的進行。

import React from 'react'
import { expect } from 'chai'
import { shallow, mount } from 'enzyme'
import TodoItem from '../TodoItem';
import TodoTextInput from '../TodoTextInput';
import sinon from 'sinon'

function setup() {
  const props = {
    todo: {
      id: 0,
      text: 'Practice Enzyme',
      completed: false
    },
    completeTodo: sinon.spy()
  }
  
  return {
    props
  }
}

describe('components', () => {
  describe('TodoItem', () => {****
    it('should render new todo form', () => {
      const { props } = setup()

      let wrapper = mount(<TodoItem {...props}/>);

      wrapper.find('label').simulate('doubleClick');

      expect(wrapper.find('.new-todo').type()).to.equal('input')
    })    
  })
})

Static Rendered Markup

寫在最後

Enzyme 還有提供許多 API 可以使用,若是要寫較複雜的測試可以再去文件中看看。

這次在練習的過程中,因為記不起其他 Enzyme 的 API 要如何使用,只用一些 contains()text()find()等 API,讓我想起很久很久以前剛接觸 rspec 時,剛好在 Rails 新手村看到 Juanito Fatas 大大示範他都怎麼寫測試。過程中看到用他用 rspec 中簡單的 eq 就能完成大部分的測試,當晚就覺得收穫良多。之後也都慢慢的先導入簡單的測試,更加熟悉之後再去考慮利用其他 API 來提升測試的簡潔程度。


References