自动化测试框架总结2

4.React中的TDD和单元测试

4.1 什么是TDD

TDD的开发流程:(Red-Green development)

1.编写测试用例;

2.运行测试,测试用例无法通过测试;

3.编写代码,使测试用例通过测试

4.优化代码,完成开发

5.重复上述步骤

TDD优势

1.长期减少回归bug;

2.代码质量良好(组织,可维护性)

3.测试覆盖率高,一般测试覆盖率在80%,90%,不能做到100%

4.错误测试代码不容易出现

接下来通过一个TodoList项目来了解TDD的流程

4.2 React环境中配置Jest

执行下面 命令

指定这个版本会有问题,npm install create-react-app@3.0.1 -g

npm install create-react-app
create-react-app jest-react
进入jest-react目录执行
npm install 

jest-react目录下面有个隐藏的git文件夹,我们可以使用git来管理代码。

通过下面的命令创建一个分支

git branch lesson1

git checkout lesson1 //切换到lesson1这个分支

npm run start  //启动项目

执行npm run eject命令之前执行下面命令,不然会报错

git add . 

gti commint -m 'add lock file'

其实脚手架已经集成了jest命令,执行npm run eject命令会把隐藏的配置项都弹射出来

jest有2个要求,有一个是通过git来管理,还有一个是要安装jest,这里已经都满足了。

jest的配置项但可以写在文件 jest.config.js中,也可以写在package.json文件中(该文件中有配置项叫'jest")

关于jest配置项的介绍后面再补充。

使用默认保存的格式化工具保存jsx形式的react代码,格式会有问题,

解决方法:右下角,把语言模式 JavaScript 改成 JavaScript React

4.3 Enzyme的配置

删除脚手架里面的冗余代码,只剩下App.js代码,index.js代码和App.test.js文件。

App.js代码如下

import React from 'react';

function App() {
  return (
    <div className="App">
      hello world2
    </div>
  );
}

export default App;

App.test.js文件代码如下:

import React from 'react';
import ReactDOM from 'react-dom';
import {
  render
} from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  // const {
  //   getByText
  // } = render( < App / > );
  // const linkElement = getByText(/learn react/i);
  // expect(linkElement).toBeInTheDocument();

  expect(2).toBe(2);

});
test('renders without crashing', () => {
  const div = document.createElement('div');
  ReactDOM.render( < App / > , div);
  ReactDOM.unmountComponentAtNode(div);
});


如下代码,可以测试渲染的组件的内容元素

import React from 'react';
import ReactDOM from 'react-dom';
import {
  render
} from '@testing-library/react';
import App from './App';

test('renders without crashing', () => {
  const div = document.createElement('div');
  ReactDOM.render(< App />, div);
  // ReactDOM.unmountComponentAtNode(div);
  //如果没有抛出异常的话,则测试用例通过

  // 如下:抛出异常的话,测试用例不通过,
  // throw new Error();
  // 测试渲染的元素内容,找到className="App"的标签
  const container = div.getElementsByClassName('App');
  console.log(container)
  // HTMLCollection { }
  expect(container.length).toBe(1);
});

前端单元测试中如果直接去写这种面向DOM的测试用例,是有局限性的,在做前端单元测试的时候,有的时候想要测试组件的state和prop状态是否正确,不仅要测试DOM的展开,还要测试组件里面的数据细节,直接通过DOM做测试就没办法实现我们对组件内部数据做测试的需求了。

面向DOM的测试用例有局限性,有时候要测试测试一个组件的prop和state(组件上的状态),DOM的测试只能让我们测试组件的渲染,所以airbnb公司的enzyme的引入就是为了解决这个问题。

enzyme其实是对 ReactDOM.render做了一些包装,提供了一些额外的方法供我们调用,是我们能够对组件进行更灵活的测试。

首先安装enzyme,可以去github上搜索airbnb公司的enzyme,(https://github.com/enzymejs/enzyme),查看相关介绍

npm i --save-dev enzyme enzyme-adapter-react-16

如何配置呢?在测试文件中添加下面的内容

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

将这段代码直接复制到App.test.js文件中去,这样测试用例中就可以使用enzyme了。

enzyme其实是对 ReactDOM.render做了一些包装,有了Enzyme,就不需要ReactDOM了,删除相关的代码

如下代码:

import React from 'react';
import App from './App';

import Enzyme, {
  shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  // const div = document.createElement('div');
  // ReactDOM.render(< App />, div);
  // const container = div.getElementsByClassName('App');
  // expect(container.length).toBe(1);

  //shallow是浅复制,或者浅渲染。App可能包含多个组件,shallow仅仅是对App组件进行完整的渲染,对于App内部的组件可能只有一个标记来代替
  //shallow只关注App这一组件,不关心下面的,只渲染这一层渲染速度比较快
  //适合于对一个组件做单元测试
  //如下shallow是用Enzyme生成的语法,所以可以用Enzyme里面的方法了,上面注释的代码就可以用下面简单的形式了
  const wrapper = shallow(< App />);
  //console.log(wrapper.find('.App').length);
  // 1,find函数里面是一个选择器,利用选择器就可以选择到div了
  expect(wrapper.find('.App').length).toBe(1);
});

接下来试试shallow的其他方法,修改App.js内容,给div添加title属性,如下代码:

import React from 'react';

function App() {
  return (
    <div className="App" title="Dell">
      hello world2
    </div>
  );
}

export default App;

如何测试呢?

import React from 'react';
import App from './App';

import Enzyme, {
  shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  const wrapper = shallow(< App />);
  // console.log(wrapper.find('.App').prop('title'));
  //"Dell"
  //测试用例通过
  expect(wrapper.find('.App').prop('title')).toBe("Dell");

  //测试用例通不过
  // expect(wrapper.find('.App').prop('name')).toBe("Dell");
  //如果有时候测试的时候发现测试用例没通过,但是不知道为什么,可以开启enzyme的调试模式
  // console.log(wrapper.debug()),可以将渲染的内容打印输出,如下

  console.log(wrapper.debug());
    
 {/* <div className="App" title="Dell" data-test="container">
  hello world2
    </div> */}
  //修改之后,,测试用例可以通过了
  expect(wrapper.find('.App').prop('title')).toBe("Dell");
});

上面的测试代码有个小问题,就是测试用例的代码和要测试代码耦合很高,上面是通过div的className属性来获取元素的,如果我们觉得div的className不太合适的,会对其进行修改,修改之后测试代码也要跟着修改,代码是耦合的,这样就会比较麻烦。可以用下面的方式来解决这个问题。给要测试的div添加一个专门的测试属性:

App.js代码如下:

import React from 'react';

function App() {
  return (
    <div className="App" title="Dell" data-test='container'>
      hello world2
    </div>
  );
}

export default App;

App.test.js代码如下:

import React from 'react';
import App from './App';

import Enzyme, {
  shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  const wrapper = shallow(< App />);
  console.log(wrapper.debug())
  expect(wrapper.find('[data-test="container"]').length).toBe(1);
  expect(wrapper.find('[data-test="container"]').prop('title')).toBe("Dell");
});

这样的过程就是测试代码和要测试代码解耦的过程。

Enzyme里面有jest-enzyme,下面链接里面可以看到一些API方法

https://github.com/FormidableLabs/enzyme-matchers/tree/master/packages/jest-enzyme

这个链接里面有很多可以使用的比较简单的API方法,如下所示,之前写的代码,可以简化成下面的形式,但是发现运行的时候报错了,toExist未定义

  const wrapper = shallow(< App />);
  expect(wrapper.find('[data-test="container"]').length).toBe(1);
  expect(wrapper.find('[data-test="container"]')).toExist();

这时需要安装Jest-enzyme

npm install jest-enzyme --save-dev

在使用的时候,还需要在package.json文件中引入jest-enzyme,如下所示,github连接里面也有介绍

"setupFilesAfterEnv": [

   "./node_modules/jest-enzyme/lib/index.js"

  ],

重启启动npm run test命令后,就可以使用这些简化语法的API了。

Jest-enzyme 里面是有很多匹配器的,可以使用这个里面的匹配器

import React from 'react';
import App from './App';

import Enzyme, {
  shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  const wrapper = shallow(< App />);
  console.log(wrapper.debug())
  // expect(wrapper.find('[data-test="container"]').length).toBe(1);
  //expect(wrapper.find('[data-test="container"]').prop('title')).toBe("Dell");

  //引入jest-enzyme之后,就可以使用下面的匹配器了,和上面相比,比较简单
  expect(wrapper.find('[data-test="container"]')).toExist();
  expect(wrapper.find('[data-test="container"]')).toHaveProp('title', 'Dell');

});

可以优化为下面的形式

import React from 'react';
// import {
//   render
// } from '@testing-library/react';
import App from './App';

import Enzyme, {
  shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  const wrapper = shallow(< App />);
  const continer = wrapper.find('[data-test="container"]');
  expect(continer).toExist();
  expect(continer).toHaveProp('title', 'Dell');
});

接下来我们看看mount方法

//mount和shallow是对应的,当App组件有子组件时候,mount会把子组件也渲染出来
//在做集成测试的时候,需要测试一堆组件的时候,使用mount比较合适
//单元测试适合用shallow,集成测试适合用mount

import React from 'react';
import App from './App';

import Enzyme, {
  mount
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

test('renders without crashing', () => {
  const wrapper = mount(< App />);
  console.log(wrapper.debug())
  // < App >
  // <div className="App" title="Dell" data-test="container">
  //   hello world2
  //   </div>
  // </App >
  const continer = wrapper.find('[data-test="container"]');
  expect(continer).toExist();
  expect(continer).toHaveProp('title', 'Dell');
});

其实这里组件测试也可以使用toMatchSnapshot() 匹配器,这个匹配器适用于测试组件内容不发生改变的组件,当组件内容更新之后,再更新snapshot。

expect(wrapper).toMatchSnapshot();

4.4 利用TDD的方式来开发项目

接下来通过一个TodoList的实例来理解TDD的开发流程

4.4.1 利用TDD的方式开发Header组件

在项目的src目录下新建containers文件夹,在containers文件夹下面新建TodoList文件夹,在其目录下新建index.js文件,index.js文件代码如下

import React from 'react';

function TodoList() {
    return (
        <div>TodoList</div>
    );
}

export default TodoList;

App.js文件代码如下:

import React from 'react';
import TodoList from './containers/TodoList'

function App() {
  return (
    <div>
      <TodoList />
    </div>
  );
}

export default App;

一般测试文件会在同级目录下面建立,一般我们会在containers/TodoList同级目录下建立__ tests __文件夹,然后其目录下建立unit文件夹,表示是该组件的单元测试文件代码。

在TodoList文件夹下面建立components文件夹,文件目录下放与TodoList相关的代码,在这个目录下面新建Header.js文件,代码如下

import React, { Component } from 'react';

class Header extends Component {
    render() {
        return (<div>Header</div>);
    }
}

export default Header;

在unit目录下建立Header.js文件,表示是Header.js文件的测试用例。

Header组件实现了输入文本框的内容,按下回车键后,可以追加到代办列表中

TDD的开发流程是写测试用例,测试用例是失败的,然后写代码让测试用例通过,最后再优化代码的过程。

首先来开发测试用例,Header组件里面有一个input组件,Header组件的测试代码如下:

__ tests __文件夹下面的Header.js文件代码如下:

import React from 'react';
import Header from '../../components/Header'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('header组件包含一个input框', () => {
    //单元测试适合用shallow
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.length).toBe(1);
});

执行npm run test命令发现测试用例没有通过,然后我们去写源代码让测试用例通过,这就是TDD的开发流程

修改components目录下面的Header.js文件代码,代码如下,这样我们编写的测试用例就可以通过了。

import React, { Component } from 'react';

class Header extends Component {
    render() {
        return (
            <div>
                <input data-test='input' />
            </div>);
    }
}

export default Header;

第2个测试用例是往input框里面输入内容时,input框里面的内容会发生变化。下面代码的第2个测试用例执行会报错,

import React from 'react';
import Header from '../../components/Header'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('header组件包含一个input框', () => {
    //单元测试适合用shallow
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.length).toBe(1);
});

test('header组件 input框 内容,初始化应该为空', () => {
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.prop('value')).toEqual('');
});


Header是一个受控组件,所以其value值是输入值来决定的,修改Header.js文件内容,使测试用例通过

import React, { Component } from 'react';

class Header extends Component {
    constructor(props) {
        super(props);
        this.state = {
            value: ''
        }
    }
    render() {
        const { value } = this.state;
        return (
            <div>
                <input data-test='input' value={value} />
            </div>);
    }
}

export default Header;

第3个测试用例是往input框里面输入内容时,input框里面的内容会发生变化,如下的测试用例,第3个测试用例未通过,

import React from 'react';
import Header from '../../components/Header'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('header组件包含一个input框', () => {
    //单元测试适合用shallow
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.length).toBe(1);
});

test('header组件 input框 内容,初始化应该为空', () => {
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.prop('value')).toEqual('');
});

//测试用例没通过
test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
    //模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    const userInput = '今天要学习Jest';
    inputElme.simulate('change', {
        target: { value: userInput }
    })
    expect(wrapper.state('value')).toEqual(userInput);
});

修改Header.js文件,添加Input控件的onChange方法,使测试用例通过。

import React, { Component } from 'react';

class Header extends Component {
    constructor(props) {
        super(props);
        this.handleInputChange = this.handleInputChange.bind(this);
        this.state = {
            value: ''
        }
    }

    handleInputChange(e) {
        this.setState({
            value: e.target.value
        })
    }

    render() {
        const { value } = this.state;
        return (
            <div>
                <input data-test='input' value={value} onChange={this.handleInputChange} />
            </div>);
    }
}

export default Header;

我们不仅可以测试state的内容,还可以测试input标签的属性等等。如下的代码:

test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
    //模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    const userInput = '今天要学习Jest';
    //对用户的动作做测试,单元测试里面一般是面向数据的测试,一般是这种测试
    inputElme.simulate('change', {
        target: { value: userInput }
    })
    expect(wrapper.state('value')).toEqual(userInput);

    //当用户操作后,对组件的DOM属性做测试,继承测试中一般是对DOM属性做测试
    //组件属性发生变化之后,一般都要重新获取,要不然取到的还是老的组件
    // const newInputElme = wrapper.find("[data-test='input']");
    // expect(newInputElme.prop('value')).toBe(userInput);
});

接下来编写其他的测试用例,当在input框里面输入内容时候,点击回车,我们希望把input框里面的内容存入到最外层的TodoList组件中,这个测试用例怎么写呢?如下代码

test('header组件 input框 输入回车时,如果input 无内容,无操作', () => {
    //当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({
        value: ''
    })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })
    expect(fn).not.toHaveBeenCalled();
});

//这个测试用例没有通过,因为代码没写,回车的时候什么都没有执行
test('header组件 input框 输入回车时,如果input 无内容,函数应该被调用', () => {
    //当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({ value: '学习React' })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })
    expect(fn).toHaveBeenCalled();
});

接着我们补充没有实现的功能,让测试用例通过,Header.js代码如下:

import React, { Component } from 'react';

class Header extends Component {
    //addUndoItem
    constructor(props) {
        super(props);
        this.handleInputChange = this.handleInputChange.bind(this);
        this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
        this.state = {
            value: ''
        }
    }

    handleInputKeyUp(e) {
        const { value } = this.state;
        if (e.keyCode === 13 && value) {
            this.props.addUndoItem(value)
        }
    }

    handleInputChange(e) {
        this.setState({
            value: e.target.value
        })
    }

    render() {
        const { value } = this.state;
        return (
            <div>
                <input data-test='input'
                    value={value}
                    onChange={this.handleInputChange}
                    onKeyUp={this.handleInputKeyUp}
                />
            </div>);
    }
}

export default Header;

测试代码还可以进一步优化,使用 toHaveBeenLastCalledWith,如下代码所示:

test('header组件 input框 输入回车时,如果input 无内容,函数应该被调用', () => {
    //当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({ value: '学习React' })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })
    expect(fn).toHaveBeenCalled();
    //不但有测试函数是否被调用过的API函数,还可以测试函数被调用的参数
    expect(fn).toHaveBeenLastCalledWith('学习React');
});

最后写完的Header.js测试代码如下

import React from 'react';
import Header from '../../components/Header'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('header组件包含一个input框', () => {
    //单元测试适合用shallow
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.length).toBe(1);
});

test('header组件 input框 内容,初始化应该为空', () => {
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    expect(inputElme.prop('value')).toEqual('');
});


test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
    //模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
    const wrapper = shallow(< Header />);
    const inputElme = wrapper.find("[data-test='input']");
    const userInput = '今天要学习Jest';
    //对用户的动作做测试,单元测试里面一般是面向数据的测试,一般是这种测试,下面模拟e
    inputElme.simulate('change', {
        target: { value: userInput }
    })
    expect(wrapper.state('value')).toEqual(userInput);

    //当用户操作后,对组件的DOM属性做测试,继承测试中一般是对DOM属性做测试
    //组件属性发生变化之后,一般都要重新获取,要不然取到的还是老的组件
    // const newInputElme = wrapper.find("[data-test='input']");
    // expect(newInputElme.prop('value')).toBe(userInput);
});


test('header组件 input框 输入回车时,如果input 无内容,无操作', () => {
    //当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({
        value: ''
    })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })
    expect(fn).not.toHaveBeenCalled();
});


test('header组件 input框 输入回车时,如果input 有内容,函数应该被调用', () => {
    //当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({ value: '学习React' })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })
    expect(fn).toHaveBeenCalled();
    //不但有测试函数是否被调用过的API函数,还可以测试函数被调用的参数
    expect(fn).toHaveBeenLastCalledWith('学习React');
});


test('header组件 input框 输入回车时,如果input 有内容,最后应该被清除', () => {
    const fn = jest.fn();

    const wrapper = shallow(< Header addUndoItem={fn} />);
    const inputElme = wrapper.find("[data-test='input']");
    //确保内容是空
    wrapper.setState({ value: '学习React' })
    //keyUp为13时,表示输入回车键
    inputElme.simulate('keyUp', {
        keyCode: 13
    })

    //当输入回车后,清空input框里面的内容
    //这里要等回车之后再次获取,如果直接用inputElme还是,初始化的inputElme,本来是空的
    const newInputElme = wrapper.find("[data-test='input']");
    expect(newInputElme.prop('value')).toBe('');
});

Header.js的源代码如下

import React, { Component } from 'react';

class Header extends Component {
    //addUndoItem
    constructor(props) {
        super(props);
        this.handleInputChange = this.handleInputChange.bind(this);
        this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
        this.state = {
            value: ''
        }
    }

    handleInputKeyUp(e) {
        const { value } = this.state;
        if (e.keyCode === 13 && value) {
            this.props.addUndoItem(value);
            this.setState({
                value: ''
            })
        }
    }

    handleInputChange(e) {
        this.setState({
            value: e.target.value
        })
    }

    render() {
        const { value } = this.state;
        return (
            <div>
                <input data-test='input'
                    value={value}
                    onChange={this.handleInputChange}
                    onKeyUp={this.handleInputKeyUp}
                />
            </div>);
    }
}

export default Header;

4.4.2 TodoList的测试代码编写

在unit目录下新建文件TodoList.js,来编写TodoList的单元测试

TodoList.js的测试文件代码如下:通过一个个编写测试用例,让测试用例通过完成我们的代码开发过程

import React from 'react';
import TodoList from '../../index'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('TodoList 初始化列表为空', () => {
    //TodoList里面undoList为空
    const wrapper = shallow(< TodoList />);
    expect(wrapper.state('undoList')).toEqual([]);
});

test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
    const wrapper = shallow(< TodoList />);
    const Header = wrapper.find('Header');
    //TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
    //wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
    expect(Header.prop('addUndoItem')).toBe(wrapper.instance().addUndoItem)
});

test('当Header 回车时,TodoList应该新增内容', () => {
    //这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
    //实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
    //在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
    const wrapper = shallow(< TodoList />);
    const Header = wrapper.find('Header');
    const addFunc = Header.prop('addUndoItem');
    addFunc('学习React')
    expect(wrapper.state('undoList').length).toBe(1);
    expect(wrapper.state('undoList')[0]).toBe('学习React');
});

index.js的源代码如下:

import React, { Component } from 'react';
import Header from './components/Header'

class TodoList extends Component {
    constructor(props) {
        super(props);
        this.addUndoItem = this.addUndoItem.bind(this);
        this.state = {
            undoList: []
        }
    }

    addUndoItem(value) {
        this.setState({
            undoList: [...this.state.undoList, value]
        })
    }

    render() {
        return (
            <div>
                <Header addUndoItem={this.addUndoItem} />
            </div>
        );
    }
}

export default TodoList;

测试代码通过之后,我们也不知道界面UI写的是否正确,修改index.js代码如下:

import React, { Component } from 'react';
import Header from './components/Header'

class TodoList extends Component {
    constructor(props) {
        super(props);
        this.addUndoItem = this.addUndoItem.bind(this);
        this.state = {
            undoList: []
        }
    }

    addUndoItem(value) {
        this.setState({
            undoList: [...this.state.undoList, value]
        })
    }

    render() {
        return (
            <div>
                <Header addUndoItem={this.addUndoItem} />
                {
                    this.state.undoList.map((item, index) => {
                        return <div key={index}>{item}</div>
                    })
                }
            </div>
        );
    }
}

export default TodoList;

以上过程可以看出,利用TDD的方式编写TodoList组件,能够发现代码中的大部分bug。

4.4.3 Header 组件样式新增及快照测试

在TodoList文件夹下面新建文件style.css,代码内容如下:

* {
    margin: 0;
    padding: 0
}

.header {
    line-height: 60px;
    background: #333;
    font-size: 24px;
    color: #fff;
}

.header-content {
    width: 600px;
    margin: 0 auto;
    font-size: 24px;
    color: #fff;
}

.header-input {
    outline: none;
    width: 360px;
    margin-top: 15px;
    float: right;
    line-height: 24px;
    border-radius: 5px;
    padding: 0 10px;
}

在index.js文件里引入css文件

import React, { Component } from 'react';

import Header from './components/Header'

import './style.css'

在Header.js文件里面添加一些样式

import React, { Component } from 'react';

class Header extends Component {
    //addUndoItem
    constructor(props) {
        super(props);
        this.handleInputChange = this.handleInputChange.bind(this);
        this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
        this.state = {
            value: ''
        }
    }

    handleInputKeyUp(e) {
        const { value } = this.state;
        if (e.keyCode === 13 && value) {
            this.props.addUndoItem(value);
            this.setState({
                value: ''
            })
        }
    }

    handleInputChange(e) {
        this.setState({
            value: e.target.value
        })
    }

    render() {
        const { value } = this.state;
        return (
            <div className="header">
                <div className="header-content">
                    TodoList
                <input
                        placeholder="Todo"
                        className="header-input"
                        data-test='input'
                        value={value}
                        onChange={this.handleInputChange}
                        onKeyUp={this.handleInputKeyUp}
                    />
                </div>
            </div>);
    }
}

export default Header;

当Header组件的样式写完之后,我们不希望它做频繁的变化,可以写个快照测试,如下代码

test('header渲染样式正常', () => {
    //单元测试适合用shallow
    const wrapper = shallow(< Header />);
    expect(wrapper).toMatchSnapshot();
});

之后UI发生变化之后,快照测试不会通过,提醒我们验证一下修改后的样式是否正确。

4.4.4 通用的测试代码提取封装

1.相似代码提取

2.enzyme引入文件封装到一个文件,这个文件配置到package.json文件中的setUpFilesAferEnv配置项里面

上面的例子中虽然额外写了一些测试代码,但是当项目里面新添加一个功能时,需要验证以前的老代码,如果有自动化测试代码,只要确保这些测试用例通过就可以了,如果没有自动化测试代码的话,老代码手动点击回归测试的时间会比较多,非常耗费人力。

4.4.5 UndoList的实现

当实现了文本框输入,输入回车之后,需要将内容添加到undoList中,当点击确认后,内容添加到已经完成项。

接下来我们通过TDD的形式来 实现UndoList。

在components文件目录下新建文件UndoList.js文件,在测试目录unit目录下新建文件UndoList.js,测试文件代码如下所示,每编写一个测试用例,然后再写源代码,

import React from 'react';
import UndoList from '../../components/UndoList'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('未完成列表当数据为空数组时 count数目为0,,列表无内容', () => {
    const wrapper = shallow(< UndoList list={[]} />);
    const countElem = wrapper.find("[data-test='count']");
    const listItems = wrapper.find("[data-test='list-item']");
    expect(countElem.text()).toEqual("0");
    expect(listItems.length).toEqual(0);
});

test('未完成列表当数据有内容时 count数目显示数据长度,,列表不为空', () => {
    const listData = ['学习Jest', '学习TDD', '学习单元测试'];
    const wrapper = shallow(< UndoList list={listData} />);
    const countElem = wrapper.find("[data-test='count']");
    const listItems = wrapper.find("[data-test='list-item']");
    expect(countElem.text()).toEqual("3");
    expect(listItems.length).toEqual(3);
});

test('未完成列表当数据有内容时 要存在删除按钮', () => {
    const listData = ['学习Jest', '学习TDD', '学习单元测试'];
    const wrapper = shallow(< UndoList list={listData} />);
    const deleteItems = wrapper.find("[data-test='delete-item']");
    expect(deleteItems.length).toEqual(3);
});

test('未完成列表当数据有内容时 点击某个删除按钮,,会调用删除方法', () => {
    const listData = ['学习Jest', '学习TDD', '学习单元测试'];
    const fn = jest.fn();
    const index = 1;
    const wrapper = shallow(< UndoList deleteItem={fn} list={listData} />);
    const deleteItems = wrapper.find("[data-test='delete-item']");
    //jest里面先通过数组找某一项,不能通过下标的形式,而是要通过at()方法
    deleteItems.at(index).simulate('click');
    expect(fn).toHaveBeenLastCalledWith(index);
});


UndoList.js代码如下:

import React, { Component } from 'react';

class UndoList extends Component {
    render() {
        const { list, deleteItem } = this.props;
        return (
            <div>
                <div data-test="count">{list.length}</div>
                <ul>
                    {
                        list.map((item, index) => {
                            return (
                                <li
                                    data-test='list-item'
                                    key={`${item}-${index}`}
                                >
                                    {item}
                                    <span
                                        data-test='delete-item'
                                        onClick={() => { deleteItem(index) }}>
                                        -
                                    </span>
                                </li>)
                        })
                    }
                </ul>

            </div>
        )

    }
}

export default UndoList;

上面的测试代码就实现了UndoList内部的单元测试,删除的功能就放在TodoList来做,测试代码如下

import React from 'react';
import TodoList from '../../index'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('TodoList 初始化列表为空', () => {
    //TodoList里面undoList为空
    const wrapper = shallow(< TodoList />);
    expect(wrapper.state('undoList')).toEqual([]);
});

test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
    const wrapper = shallow(< TodoList />);
    const Header = wrapper.find('Header');
    //TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
    //wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
    expect(Header.prop('addUndoItem')).toBeTruthy();
});

test('当addItem 被执行时,undoList应该新增内容', () => {
    //这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
    //实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
    //在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
    const wrapper = shallow(< TodoList />);
    wrapper.instance().addUndoItem('学习React')
    expect(wrapper.state('undoList').length).toBe(1);
    expect(wrapper.state('undoList')[0]).toBe('学习React');
});


test('TodoList 应该给 UndoList组件传递list数据,以及deleteItem方法', () => {
    const wrapper = shallow(< TodoList />);
    const UndoList = wrapper.find('UndoList');
    //传递的属性数据
    expect(UndoList.prop('list')).toBeTruthy();
    expect(UndoList.prop('deleteItem')).toBeTruthy();
});


test('当 deleteItem方法被执行时,undoList应该删除内容', () => {
    const wrapper = shallow(< TodoList />);
    wrapper.setState({
        undoList: ['学习Jest', 'dell', 'lee']
    })
    wrapper.instance().deleteItem(1);
    expect(wrapper.state('undoList')).toEqual(['学习Jest', 'lee']);
    //上面的addItem已经
});

index.jsx代码如下:

import React, { Component } from 'react';
import Header from './components/Header'
import './style.css'
import UndoList from './components/UndoList';

class TodoList extends Component {
    constructor(props) {
        super(props);
        this.addUndoItem = this.addUndoItem.bind(this);
        this.deleteItem = this.deleteItem.bind(this);
        this.state = {
            undoList: []
        }
    }

    addUndoItem(value) {
        this.setState({
            undoList: [...this.state.undoList, value]
        })
    }

    deleteItem(index) {
        const newList = [...this.state.undoList];
        newList.splice(index, 1);
        this.setState({
            undoList: newList
        })
    }

    render() {
        const { undoList } = this.state;
        return (
            <div>
                <Header addUndoItem={this.addUndoItem} />
                <UndoList list={undoList} deleteItem={this.deleteItem} />
            </div>
        );
    }
}

export default TodoList;

4.4.6 给UndoList添加样式

4.4.7 测试代码优化

每个组件测试用例的描述都比较长,可以将每个test测试用例放到describe里面,describe的名称是组件的名称,这样测试代码看起来,可读性就会更高一些。

4.4.8 UndoList编辑功能实现

当编辑undoList的每项时,可以让其变成编辑状态,当失焦或者按下回车时,保存为修改后的名称。

我们存储的undoList数据结构是一个数组,只用于展示,并不是识别input框,所以这里要改变其数据结构,识别是不是input框的状态。

这样添加数据项的时候也应该修改一下数据结构,index.jsx代码如下

import React, { Component } from 'react';
import Header from './components/Header'
import './style.css'
import UndoList from './components/UndoList';

class TodoList extends Component {
    constructor(props) {
        super(props);
        this.addUndoItem = this.addUndoItem.bind(this);
        this.deleteItem = this.deleteItem.bind(this);
        this.changeStatus = this.changeStatus.bind(this);
        this.state = {
            undoList: []
        }
    }

    addUndoItem(value) {
        this.setState({
            undoList: [...this.state.undoList, {
                status: 'div',
                value
            }]
        })
    }

    deleteItem(index) {
        const newList = [...this.state.undoList];
        newList.splice(index, 1);
        this.setState({
            undoList: newList
        })
    }

    changeStatus(index) {
        console.log(index)
    }

    render() {
        const { undoList } = this.state;
        return (
            <div>
                <Header addUndoItem={this.addUndoItem} />
                <UndoList list={undoList} deleteItem={this.deleteItem} changeStatus={this.changeStatus} />
            </div>
        );
    }
}

export default TodoList;

修改代码的数据结构之后,UndoList的测试文件代码也要跟着修改,如下代码

import React from 'react';
import TodoList from '../../index'

import Enzyme, {
    shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});

test('TodoList 初始化列表为空', () => {
    //TodoList里面undoList为空
    const wrapper = shallow(< TodoList />);
    expect(wrapper.state('undoList')).toEqual([]);
});

test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
    const wrapper = shallow(< TodoList />);
    const Header = wrapper.find('Header');
    //TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
    //wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
    expect(Header.prop('addUndoItem')).toBeTruthy();
});

test('当addItem 被执行时,undoList应该新增内容', () => {
    //这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
    //实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
    //在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
    const wrapper = shallow(< TodoList />);
    wrapper.instance().addUndoItem('学习React')
    expect(wrapper.state('undoList').length).toBe(1);
    expect(wrapper.state('undoList')[0]).toEqual({
        status: 'div',
        value: '学习React'
    });
});


test('TodoList 应该给 UndoList组件传递list数据,以及deleteItem以及changeStatus方法', () => {
    const wrapper = shallow(< TodoList />);
    const UndoList = wrapper.find('UndoList');
    //传递的属性数据
    expect(UndoList.prop('list')).toBeTruthy();
    expect(UndoList.prop('deleteItem')).toBeTruthy();
    expect(UndoList.prop('changeStatus')).toBeTruthy();
});


test('当 deleteItem方法被执行时,undoList应该删除内容', () => {
    const wrapper = shallow(< TodoList />);
    wrapper.setState({
        undoList: ['学习Jest', 'dell', 'lee']
    })
    wrapper.instance().deleteItem(1);
    expect(wrapper.state('undoList')).toEqual(['学习Jest', 'lee']);
    //上面的addItem已经
});

UndoList.js代码如下

import React, { Component } from 'react';

class UndoList extends Component {
    render() {
        const { list, deleteItem, changeStatus } = this.props;
        return (
            <div className='undo-list'>
                <div className="undo-list-title">
                    正在进行
                    <div data-test="count" className="undo-list-count">{list.length}</div>
                </div>

                <ul className="undo-list-content">
                    {
                        list.map((item, index) => {
                            return (
                                <li className='undo-list-item'
                                    data-test='list-item'
                                    key={index}
                                    onClick={() => changeStatus(index)}
                                >
                                    {item.value}
                                    <span
                                        className="undo-list-delete"
                                        data-test='delete-item'
                                        onClick={() => { deleteItem(index) }}>
                                        -
                                    </span>
                                </li>)
                        })
                    }
                </ul>

            </div>
        )

    }
}

export default UndoList;

修改数据结构后,修改相关报错代码,当单元测试全部通过的时候,页面却是挂的;

使用TDD加上单元测试方式的问题:真正的数据结构或者组件内容发生变化的时候,需要回头重新修改测试用例;因为测试用例里面用了大量耦合的数据;当有需求变更的时候,会导致之前的测试用例不可用。

即便所有的单元测试用例都通过了测试,也无法保证项目在浏览器上可以正确无误地运行,因为单元测试测试的是每个组件,并没有将每个组件集成在一起做测试,这样每个组件是好用的,但是合在一起是否好用不知道。

4.4.9 UndoList编辑功能实现2

该小节实现当输入框失去焦点时,就 不是输入框,而是显示状态了。通过先写测试用例,然后写代码的方式实现这个功能。

4.4.10 CodeCoverage代码覆盖率

看看测试代码覆盖了多少业务逻辑代码

在package.json文件里添加一个命令coverage,执行npm run coverage命令就可以看到测试用例的覆盖率了,可以index.html中详细看出,coverage这2行命令都可以被成功执行。

  "scripts": {
    "start": "node scripts/start.js",
    "build": "node scripts/build.js",
    "test": "node scripts/test.js",
    "coverage": "node scripts/test.js --coverage --watchAll=false"
    //"coverage": "jest --coverage --watchAll=false"
  },

4.5 TDD和单元测试总结

TDD和单元测试是2个不同的概念,TDD也可以和集成测试在一起。

TDD的好处:代码质量提高,在写代码之前,反复思考过代码和测试用例分别怎么写才合适
单元测试:

好处:测试覆盖率高;

劣势:业务耦合度高;代码量大(测试代码比源代码还要多); 过于独立(单元测试通过,项目不一定运行正常)

当写函数库的时候,非常适合用单元测试来写。

业务场景下,单元测试的劣势很明显,业务代码使用集成测试更好地保证项目的质量。

5.BDD和集成测试

BDD(Behavaior Driven Development) 行为驱动开发

先写代码,再写测试代码

开发模式 介绍
TDD 1.先写测试再写代码;

TDD

1.先写测试再写代码;

2一般结合单元测试使用,是白盒测试;

3.测试重点在代码;

4.安全感第;

5.速度快;

BDD

1.先写代码再写测试;

2.一般结合集成测试使用,是黑盒测试;

3.测试重点在于UI(DOM)

4.安全感高

5.速度慢

posted @ 2020-06-18 14:19  melimeli  阅读(364)  评论(0)    收藏  举报