为什么要做单元测试
- 重构保障
- 代码质量保障
- 也是敏捷开发,快速迭代的基本保障
- 人员会流动,应用会变大,业务会增加
- 反馈代码合理性(易测)
单元测试(Jest + Enzyme)
import { shallow, mount, render } from 'enzyme';
- 浅渲染 shallow()
仅仅对当前jsx结构内的顶级组件进行渲染,而不对这些组件的内部子组件进行渲染,渲染virtual DOM 不会返回真实的节点
shallow<C extends Component, P = C['props'], S = C['state']>(
node: ReactElement<P>,
options?: ShallowRendererProps
): ShallowWrapper<P, S, C>;
- 深度渲染 mount()
mount则会进行完整渲染,包括子组件
mount<C extends Component, P = C['props'], S = C['state']>(
node: ReactElement<P>,
options?: MountRendererProps
): ReactWrapper<P, S, C>;
- 静态渲染 render()
render也会进行完整渲染,但不依赖DOM API,而是渲染成HTML结构,并利用cheerio实现html节点的选择
render<P, S>(node: ReactElement<P>, options?: any): Cheerio;
最简单的测试
只关注输入输出,不关注内部实现
export interface IPeopleModel {
name: string;
age: number;
}
// 计算总年龄(reduce实现)
// const computeTotalAge = (people: IPeopleModel[]) => {
// return people.reduce((total, person) => total + person.age, 0);
// };
// 计算总年龄(forEach)
const computeTotalAge = (people: IPeopleModel[]) => {
let sum = 0;
people.forEach((person, index) => {
sum += person.age;
});
return sum;
};
// testing code
it('测试computeTotalAge函数', () => {
// given - 准备数据
const people = [
{ name: 'a', age: 200 },
{ name: 'b', age: 300 },
{ name: 'c', age: 300 },
];
// when - 调用被测函数
const result = computeTotalAge(people);
// then - 断言结果(只关注结果是否符合预期)
expect(result).toBe(800);
});
components测试
- 展示型业务组件
- 容器型业务组件
- 功能型组件
展示型业务组件(Empty.tsx)
import React from 'react';
import './index.css';
import emptySvg from '@assets/wawa.png';
const Empty: React.FC<{ description?: string }> = ({ description }) => (
<div className="ksm-empty">
<img src={emptySvg} alt="emptySvg" />
<span>{description}</span>
</div>
);
export default Empty;
Empty.test.tsx
import * as React from 'react';
import { mount } from 'enzyme';
import Empty from '@components/Empty';
describe('Empty组件测试', () => {
it('UI渲染测试', () => {
const empty = mount(<Empty />);
expect(empty.exists()).toEqual(true);
expect(empty.find(Empty)).toHaveLength(1);
expect(empty.hasClass('ksm-empty'));
});
it('逻辑测试', () => {
const desc: string = '测试描述';
const empty = mount(<Empty description={desc} />);
expect(empty.find('span').text()).toBe(desc);
});
});
功能型组件(withSignWay.tsx)
import React, { Component } from 'react';
import { getSignWay } from './../utils';
import dd from 'dingtalk-jsapi';
const setDDHeader = () => {
const title = getSignWay() === 'NEW' ? '商户新签' : '商户续约';
if (dd.version) {
dd.biz.navigation.setTitle({ title });
}
};
const withSignWay = (WrappedComponent: any) => {
return class WithSignWayHOC extends Component {
componentDidMount() {
// 设置title
setDDHeader();
}
render() {
return <WrappedComponent {...this.props} />;
}
};
};
export default withSignWay;
withSignWay.test.tsx
import React from 'react';
import { mount } from 'enzyme';
import withSignWay from '@pages/sign/hoc/withSignWay';
class MockApp extends React.Component {
render() {
return <p>test</p>;
}
}
describe('withSignWay HOC test', () => {
it('新签', () => {
const WrapComponent = withSignWay(MockApp);
const MountWrapComponent = mount(<WrapComponent />);
expect(MountWrapComponent.exists()).toEqual(true);
});
it('续约', () => {
const WrapComponent = withSignWay(MockApp);
const MountWrapComponent = mount(<WrapComponent />);
expect(MountWrapComponent.exists()).toEqual(true);
});
});
容器型业务组件(SignTypeContext.tsx)
import React, { Component } from 'react';
type SignType = 'COMPANY' | 'PERSON' | string;
export interface ISignProviderState {
// 当前签约主体类型
signType: SignType;
// 切换签约主体类型
onChangeSignType?: (signType: [SignType]) => void;
}
export const SignContext = React.createContext<ISignProviderState>({
signType: 'COMPANY',
});
export const SignProvider = SignContext.Provider;
export const SignConsumer = SignContext.Consumer;
export default class SignProviderContainer extends Component<
{},
ISignProviderState
> {
public handleChangeSignType = (signType: [SignType]) => {
this.setState({
signType: signType.join(),
});
};
public state = {
// 当前选中的 signType
signType: 'COMPANY',
onChangeSignType: this.handleChangeSignType,
};
render() {
return (
<SignProvider value={this.state}>{this.props.children}</SignProvider>
);
}
}
signContext.test.tsx
import * as React from 'react';
import { mount } from 'enzyme';
import SignProviderContainer, {
SignConsumer,
} from '@pages/sign/context';
describe('SignType Context 测试', () => {
beforeEach(() => {
jest.resetModules();
});
test('signType cosumer 默认值', () => {
const defaultState = {
signType: 'PERSON',
};
const provider = mount(
<SignProviderContainer>
<SignConsumer>
{val => <div className="context">{val.signType}</div>}
</SignConsumer>
</SignProviderContainer>
);
expect(
provider
.setState(defaultState)
.find('.context')
.text()
).toBe('PERSON');
});
test('改变signType时', () => {
const provider = mount(
<SignProviderContainer>
<SignConsumer>
{val => (
<>
<div className="context">{val.signType}</div>
<button
onClick={() => {
val.onChangeSignType && val.onChangeSignType(['COMPANY']);
}}
>
click
</button>
</>
)}
</SignConsumer>
</SignProviderContainer>
);
provider.find('button').simulate('click');
expect(provider.find('.context').text()).toBe('COMPANY');
});
});
e2e测试(end-to-end 黑盒)(Cypress)
- 测试应用是否符合预期
- 从用户角度出发
- 重点在于判断真实的DOM是否满足预期
Write your first Test
cd /code/kry/kic-sign-mobile-web-e2e
npm install cypress --save-dev
因为cypress package
较大 为了不影响jenkins 安装依赖 放在其它repo
首页
home.spec.js
context("首页", () => {
beforeEach(() => {
// 访问首页
cy.visit("/");
});
it("页面标题", () => {
cy.wait(3000);
cy.title().should("eq", "移动签约");
});
it("页面渲染", () => {
// 获取dom结构(通过class选择器)
cy.get(".home .home-menu")
.children()
// 断言
.should("have.length", 4);
});
});
最佳实践
tag
class
id
选择器在重构或优化的时候可能会被更新或者弃用 使用 data-cy
来替代 class选择器,例如cy.get('[data-cy=submit]')
只用来写测试
结束语
虽然自动化测试 并不能完全覆盖所有的情景 特别是在前端 用户的操作非常随意,但在重构以及后续优化迭代能起到很好的参考价值,并且能够驱使开发写出高质量的代码
Q&A
...