前端工程化 - 代码测试

174 阅读5分钟

背景

在敏捷开发场景中,产品迭代速度快、重构场景时有发生,如何进一步保证代码质量和系统稳定性?

一、测试基础理论

1. 测试框架

jestjs.io/docs/gettin…

Jest

开箱即用 - 基本不需要额外的配置

功能强大 - 自带断言、测试覆盖率等工具,支持Mock、Snapshot和异步测试等

应用广泛 - 是vue cli和create-react-app默认集成的测试框架

2. Test Suit / Test Case

const myData = {
  delicious: true,
  sour: false,
};

// test suit,测试套件,表示一组相关用例的分组
describe("myData", () => {
  // test case,测试用例,表示对一个功能点的测试
  test("is delicious", () => {
    expect(myData.delicious).toBeTruthy;
  });

  test("is not sour", () => {
    expect(myData.sour).toBeFalsy;
  });
});

3. 断言

jestjs.io/docs/using-…

Jest中的断言,称作“Matcher”

test("object assignment", () => {
  const data = {
    one: 1,
  };
  data["two"] = 2;
  expect(data).toEqual({
    one: 1,
    two: 2,
  });
});

test('null', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).not.toBeTruthy();
  expect(n).toBeFalsy();
});

test('zero', () => {
  const z = 0;
  expect(z).not.toBeNull();
  expect(z).toBeDefined();
  expect(z).not.toBeUndefined();
  expect(z).not.toBeTruthy();
  expect(z).toBeFalsy();
});

4. 异步测试

jestjs.io/docs/asynch…

promise和async / await场景下的异步测试

test("the data is Jack", () => {
  return fetchData().then((data) => {
    expect(data).toBe("Jack");
  });
});

test("the data is Jack", async () => {
  await expect(fetchData()).resolves.toBe("Jack");
});

5. Mock

jestjs.io/docs/mock-f…

一种覆盖原有函数、类的实际实现,来检测其调用情况的一种测试方法

// forEach.js
export function forEach(items, callback) {
  for (const item of items) {
    callback(item);
  }
}

可以通过Mock函数的.mock属性,拿到其调用的各种信息

// forEach.test.js
const forEach = require('./forEach');

const mockCallback = jest.fn(x => 42 + x);

test('forEach mock function', () => {
  forEach([0, 1], mockCallback);

  // The mock function was called twice
  expect(mockCallback.mock.calls).toHaveLength(2);

  // The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0);

  // The first argument of the second call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1);

  // The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42);
});

Mock一个第三方模块(目的:去除外部依赖,测试Users本身的逻辑)

// users.js
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);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

6. Snapshot

jestjs.io/docs/snapsh…

一种强大的测试工具,一般用于UI组件的测试

第一次运行用例:生成快照

第2 ~ n 次:使用快照对比现有组件

import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});
exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

7. 测试覆盖率

Jest内置Istanbul模块,从以下4个维度统计测试覆盖率

  • Istanbul在代码被执行之前,拦截了模块加载器,为其中的每一个逻辑分支、函数等添加了计数器,从而得到覆盖率结果。
  • 实现原理:www.alloyteam.com/2019/07/134…

二、单元测试

  • 只测试一个独立单元的工作
  • 往往以组件、util为粒度
  • 为什么要有单元测试?- 保证重构代码、版本迭代的安全性

1. 原则

AIR原则

  • Automatic(自动化):不能有对人工操作的依赖
  • Independent(独立性):用例不能有逻辑和顺序上的依赖关系
  • Repeatable(可重复):尽可能不依赖外部环境

BCDE原则

  • Border:边界值测试:循环边界、特殊取值、特殊长度、数据顺序等
  • Correct:正确的输入和预期结果
  • Design:与设计文档相结合,不能对着代码编写单元测试
  • Error:强制错误信息输入(非法数据等),并得到预期结果

2. 工具

Enzyme - shallow / mount / render

Airbub出品,是对React官方测试工具库的封装

  • Shallow Rendering(shallow):将React组件渲染成虚拟DOM,不渲染所有子组件
import { shallow } from 'enzyme'
import Foo from './Foo'

// 渲染速度极快
// wrapper对象中包含多种选择器
describe('MyComponet', () => {
  it('renders thress <Foo /> components', ()=>{
    const wrapper = shallow(<MyComponent />)
    expect(wrapper.find(Foo)).to.have.lengthOf(3)
  })
})
  • Full Rendering(mount):将React组件渲染成真实DOM节点
import { mount } from 'enzyme'
import Foo from './Foo'

describe( '<Foo />' , ()=>  {
  it('allows us to set props', () => {
    const wrapper = mount(<Foo bar="baz" />)
    expect(wrapper.props().bar).to.equal( 'baz' )
    wrapper.setProps({ bar: 'foo' })
    expect(wrapper.props().bar ).to.equal('foo' )
  })
})
  • Static Rendering(render):将React组件渲染成静态HTML字符串
import { render } from 'enzyme'
import Foo from './Foo'

// warpper是Cheerio实例
describe('<Foo />' , ()=>  {
  it('renders a div', () => {
    const wrapper = render(
      <div className = 'myClass' />
    )
    expect(wrapper.html()).to.contain('div')
  })
})

3. 案例

Ant Design - Switch

describe('Switch', () => {

  // snapshot快照
  it('should match snapshot', () => {
    const wrapper = shallow(<Switch />);
    expect(wrapper.render()).toMatchSnapshot();
  });

  // checked属性
  it('should checked when padd checked=true props', () => {
    const wrapper = render(<Switch checked={true}/>);
    expect(wrapper.hasClass('ant-switch-checked')).toBe(true); // 找到对应的类名
  });

  // checkedChildren属性
  it('should render checked children correctly', () => {
    const wrapper = mount(<Switch checked={true} checkedChildren={'ok'}/>);
    expect(wrapper.text()).toBe('ok');
  });

  // disabled属性
  it('should be disable when pass props disable=true', () => {
    const wrapper = render(<Switch disabled={true}/>);
    expect(wrapper.hasClass('ant-switch-disabled')).toBe(true);
  });


  // onChange属性
  it('should call onChange function when switch toggled', () => {
    const change = jest.fn(checked => checked);
    const wrapper = mount(<Switch onChange={change} />);
    wrapper.simulate('click');
    expect(change.mock.results[0].value).toBe(true);
    wrapper.simulate('click');
    expect(change.mock.results[1].value).toBe(false);
  })
});

三、E2E测试

概念

  • End to End(端到端)
  • 模仿用户,从某个入口开始,逐步执行操作,直到完成某项工作

测试框架

Cypress - 一个轻量、现代、强大的E2E测试框架

  • 支持快照: 可以查看每一步操作发生了什么
  • 可调式性: 测试的过程中可以Debug
  • 网络条件模拟: 模拟各种异常的网络状况
  • 截图和录屏功能: 保留测试证据,方便分析测试结果
  • 可视化: 有可视化的Dashboard方便用户操作

E2E测试示例 - ToDoList

describe('Main Process', () => {
  beforeEach(() => {
    cy.visit('http://localhost:8080');
  });

  it('Make a Todo', () => {
    cy.get('input').first().type('some things');
    cy.get('.v-input__append-outer .v-icon--link').click();
    cy.get('.v-list>div .v-list__tile__content').first().should('have.text', 'some things');
  });

  it('Check a Todo', () => {
    cy.get('input').first().type('some things');
    cy.get('.v-input__append-outer .v-icon--link').click();
    cy.get('.v-input--selection-controls__ripple').first().click();
    cy.get('.v-list>div .v-list__tile__content .v-list__tile__title').first().should('have.class', 'done');
  });

  it('Deleta a Todo', () => {
    cy.get('input').first().type('some things');
    cy.get('.v-input__append-outer .v-icon--link').click();
    cy.get('.v-list .v-list__tile__action .v-icon--link').first().click();
    cy.wait(300);
    cy.get('.v-btn__content').eq(1).click();
    cy.get('.v-list>div').should('have.length', 0);
  });
})

四、测试驱动开发

  • TDD(Test-Driven Development,测试驱动开发)

  • 敏捷开发 - 快速迭代如何保证质量?

  • TDD原则

    • 先编写完善的测试用例,把测试当作设计,再写代码
    • 只允许编写刚好能够导致失败的单元测试
    • 只允许编写刚好能够导致失败的单元测试【通过】的产品代码