React测试在项目中的实践

139 阅读3分钟

为什么要做单元测试

  • 重构保障
  • 代码质量保障
  • 也是敏捷开发,快速迭代的基本保障
  • 人员会流动,应用会变大,业务会增加
  • 反馈代码合理性(易测)

单元测试(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

...