# 先说一下示例怎么运行,先确定本机安装好 node 环境
# 安装项目依赖
npm install
# 首先启动 webpack-dev-server
npm run start-dev
# 上一个运行完毕后不要关闭,开一个新的命令行,启动 node server 服务
npm run start-server
#上述两个启动好后打开浏览器访问 http://localhost:3000 即可
# 跑测试用例
npm test
# 生成测试覆盖报告,跑完后看 coverage 子目录下的内容
npm run test-coverage
# 以上脚本定义都在 package.json 中À medida que os aplicativos da Web se tornam cada vez mais complexos, muitas empresas prestam cada vez mais atenção aos testes de unidade de front-end. A maioria dos tutoriais que vemos falará sobre a importância dos testes de unidade e como usar algumas APIs representativas da estrutura de teste, mas como iniciar o teste de unidade em projetos reais? Que conteúdo específico deve testar casos de teste?
Este artigo começa a partir de um cenário de aplicação real, analisa o que os testes de unidade devem conter a partir do padrão de design e estrutura de código e como escrever casos de teste específicos. Espero que você possa ganhar algo com os sapatos infantis que você vê.
O projeto usa a pilha de tecnologia react e as principais estruturas usadas incluem: react , redux , react-redux , redux-actions , reselect , redux-saga , seamless-immutable , antd .

Da camada da interface do usuário, este cenário de aplicativo consiste principalmente em duas partes:
Quando você vê os sapatos de alguns filhos aqui, você pode dizer: Corte! Uma interface tão simples e a lógica de negócios ainda é um cenário real? Você ainda precisa escrever testes de unidade Shenma?
Não se preocupe, para garantir a experiência de leitura e a duração do artigo, uma cena simples que pode explicar o problema claramente é uma boa cena, certo? Olhe para baixo lentamente.
Nesse design e desenvolvimento de cenários, respeitamos estritamente as melhores práticas de fluxo de dados unidirecional redux e react-redux , e usamos redux-saga para processar o fluxo de negócios, reselect para processar o cache do estado e chamar interfaces de back-end através fetch , o que não é diferente dos projetos reais.
O design em camadas e a organização de código são os seguintes:

O conteúdo da store do meio está relacionado ao redux e você deve saber o significado olhando o nome.
Para códigos específicos, consulte aqui.
Vamos falar sobre quais estruturas e ferramentas de teste são usadas, o principal conteúdo inclui:
jest , estrutura de testeenzyme , especialize -se em testar a camada de interface do usuário do Reactsinon tem biblioteca independente de falsificações, espiões, stubs e zombaresnock , simular servidor httpSe você tem sapatos infantis que não estão familiarizados com o uso e a configuração acima, basta ler a documentação oficial, que é melhor do que qualquer tutorial.
Em seguida, começamos a escrever código de caso de teste específico. O seguinte fornecerá trechos de código e parses para cada nível. Então vamos começar com actions .
Para tornar o artigo o mais curto e claro possível, os seguintes trechos de código não são o conteúdo completo de cada arquivo e o conteúdo completo está aqui.
No negócio, usei redux-actions para gerar action . Aqui eu uso a barra de ferramentas como exemplo. Vamos primeiro olhar para um código de negócios:
import { createAction } from 'redux-actions' ;
import * as type from '../types/bizToolbar' ;
export const updateKeywords = createAction ( type . BIZ_TOOLBAR_KEYWORDS_UPDATE ) ;
// ... Para testes actions , verificamos principalmente se o objeto action gerado está correto:
import * as type from '@/store/types/bizToolbar' ;
import * as actions from '@/store/actions/bizToolbar' ;
/* 测试 bizToolbar 相关 actions */
describe ( 'bizToolbar actions' , ( ) => {
/* 测试更新搜索关键字 */
test ( 'should create an action for update keywords' , ( ) => {
// 构建目标 action
const keywords = 'some keywords' ;
const expectedAction = {
type : type . BIZ_TOOLBAR_KEYWORDS_UPDATE ,
payload : keywords
} ;
// 断言 redux-actions 产生的 action 是否正确
expect ( actions . updateKeywords ( keywords ) ) . toEqual ( expectedAction ) ;
} ) ;
// ...
} ) ;A lógica deste caso de teste é muito simples. Primeiro, construa um resultado que esperamos, depois ligue para o código comercial e, finalmente, verifique se o resultado da operação do código comercial é consistente com as expectativas. Esta é a rotina básica de redação de casos de teste.
Tentamos manter a responsabilidade única de uso ao escrever casos de teste e não abrangem muitos escopos de negócios diferentes. Pode haver muitos casos de teste, mas cada um não deve ser complicado.
Em seguida, são reducers , que ainda usam handleActions de redux-actions para escrever reducer . Aqui está uma tabela, por exemplo:
import { handleActions } from 'redux-actions' ;
import Immutable from 'seamless-immutable' ;
import * as type from '../types/bizTable' ;
/* 默认状态 */
export const defaultState = Immutable ( {
loading : false ,
pagination : {
current : 1 ,
pageSize : 15 ,
total : 0
} ,
data : [ ]
} ) ;
export default handleActions (
{
// ...
/* 处理获得数据成功 */
[ type . BIZ_TABLE_GET_RES_SUCCESS ] : ( state , { payload } ) => {
return state . merge (
{
loading : false ,
pagination : { total : payload . total } ,
data : payload . items
} ,
{ deep : true }
) ;
} ,
// ...
} ,
defaultState
) ;O objeto de estado aqui usa
seamless-immutable
Para reducer , testamos principalmente dois aspectos:
action.type desconhecida.Type, se o status atual pode ser retornado.Aqui estão os códigos de teste para os dois pontos acima:
import * as type from '@/store/types/bizTable' ;
import reducer , { defaultState } from '@/store/reducers/bizTable' ;
/* 测试 bizTable reducer */
describe ( 'bizTable reducer' , ( ) => {
/* 测试未指定 state 参数情况下返回当前缺省 state */
test ( 'should return the default state' , ( ) => {
expect ( reducer ( undefined , { type : 'UNKNOWN' } ) ) . toEqual ( defaultState ) ;
} ) ;
// ...
/* 测试处理正常数据结果 */
test ( 'should handle successful data response' , ( ) => {
/* 模拟返回数据结果 */
const payload = {
items : [
{ id : 1 , code : '1' } ,
{ id : 2 , code : '2' }
] ,
total : 2
} ;
/* 期望返回的状态 */
const expectedState = defaultState
. setIn ( [ 'pagination' , 'total' ] , payload . total )
. set ( 'data' , payload . items )
. set ( 'loading' , false ) ;
expect (
reducer ( defaultState , {
type : type . BIZ_TABLE_GET_RES_SUCCESS ,
payload
} )
) . toEqual ( expectedState ) ;
} ) ;
// ...
} ) ;A lógica do caso de teste aqui também é muito simples e ainda é a rotina de afirmar o resultado esperado acima. Abaixo está a parte dos seletores.
A função do selector é obter o status do negócio correspondente. reselect é usado aqui para armazenar em cache para evitar o recálculo quando state não é alterado. Vamos primeiro olhar para o código seletor da tabela:
import { createSelector } from 'reselect' ;
import * as defaultSettings from '@/utils/defaultSettingsUtil' ;
// ...
const getBizTableState = ( state ) => state . bizTable ;
export const getBizTable = createSelector ( getBizTableState , ( bizTable ) => {
return bizTable . merge ( {
pagination : defaultSettings . pagination
} , { deep : true } ) ;
} ) ;Os parâmetros do pager aqui são definidos uniformemente no projeto, então o Reselect faz bem esse trabalho: se o status dos negócios permanecer inalterado, ele retornará diretamente ao último cache. As configurações padrão do dispositivo de paginação são as seguintes:
export const pagination = {
size : 'small' ,
showTotal : ( total , range ) => ` ${ range [ 0 ] } - ${ range [ 1 ] } / ${ total } ` ,
pageSizeOptions : [ '15' , '25' , '40' , '60' ] ,
showSizeChanger : true ,
showQuickJumper : true
} ;Então nossos testes são principalmente dois aspectos:
O código de teste é o seguinte:
import Immutable from 'seamless-immutable' ;
import { getBizTable } from '@/store/selectors' ;
import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil' ;
/* 测试 bizTable selector */
describe ( 'bizTable selector' , ( ) => {
let state ;
beforeEach ( ( ) => {
state = createState ( ) ;
/* 每个用例执行前重置缓存计算次数 */
getBizTable . resetRecomputations ( ) ;
} ) ;
function createState ( ) {
return Immutable ( {
bizTable : {
loading : false ,
pagination : {
current : 1 ,
pageSize : 15 ,
total : 0
} ,
data : [ ]
}
} ) ;
}
/* 测试返回正确的 bizTable state */
test ( 'should return bizTable state' , ( ) => {
/* 业务状态 ok 的 */
expect ( getBizTable ( state ) ) . toMatchObject ( state . bizTable ) ;
/* 分页默认参数设置 ok 的 */
expect ( getBizTable ( state ) ) . toMatchObject ( {
pagination : defaultSettingsUtil . pagination
} ) ;
} ) ;
/* 测试 selector 缓存是否有效 */
test ( 'check memoization' , ( ) => {
getBizTable ( state ) ;
/* 第一次计算,缓存计算次数为 1 */
expect ( getBizTable . recomputations ( ) ) . toBe ( 1 ) ;
getBizTable ( state ) ;
/* 业务状态不变的情况下,缓存计算次数应该还是 1 */
expect ( getBizTable . recomputations ( ) ) . toBe ( 1 ) ;
const newState = state . setIn ( [ 'bizTable' , 'loading' ] , true ) ;
getBizTable ( newState ) ;
/* 业务状态改变了,缓存计算次数应该是 2 了 */
expect ( getBizTable . recomputations ( ) ) . toBe ( 2 ) ;
} ) ;
} ) ;O caso de teste ainda é muito simples? Apenas mantenha esse ritmo. Vamos falar sobre a parte um pouco complicada, a parte Sagas.
Aqui eu uso redux-saga para processar o fluxo de negócios, que é chamado especificamente da API para solicitar dados de maneira assíncrona, processar resultados bem-sucedidos e resultados de erro etc.
Os sapatos de algumas crianças podem pensar que é tão complicado. Não acabou de usar redux-thunk solicitado de forma assíncrona? Não se preocupe, você entenderá depois de ler pacientemente.
Aqui é necessário introduzir brevemente o método de trabalho do redux-saga . A SAGA é uma função do gerador es6 - gerador, que usamos para gerar vários effects declarativos, que são digeridos e processados pelo mecanismo redux-saga para impulsionar os negócios.
Aqui vamos dar uma olhada no código comercial para obter dados da tabela:
import { all , takeLatest , put , select , call } from 'redux-saga/effects' ;
import * as type from '../types/bizTable' ;
import * as actions from '../actions/bizTable' ;
import { getBizToolbar , getBizTable } from '../selectors' ;
import * as api from '@/services/bizApi' ;
// ...
export function * onGetBizTableData ( ) {
/* 先获取 api 调用需要的参数:关键字、分页信息等 */
const { keywords } = yield select ( getBizToolbar ) ;
const { pagination } = yield select ( getBizTable ) ;
const payload = {
keywords ,
paging : {
skip : ( pagination . current - 1 ) * pagination . pageSize , max : pagination . pageSize
}
} ;
try {
/* 调用 api */
const result = yield call ( api . getBizTableData , payload ) ;
/* 正常返回 */
yield put ( actions . putBizTableDataSuccessResult ( result ) ) ;
} catch ( err ) {
/* 错误返回 */
yield put ( actions . putBizTableDataFailResult ( ) ) ;
}
} Se você não estiver familiarizado com redux-saga não preste muita atenção à escrita específica do código. Você deve entender as etapas específicas deste negócio, observando os comentários:
state correspondente, e o seletor agora é chamado aqui.Então, como devemos escrever casos de teste específicos? Todos sabemos que esse tipo de código comercial envolve chamadas da API ou de outras camadas. Se você deseja escrever testes de unidade, deve fazer algumas zombarias para impedir que a camada da API seja realmente chamada. Vamos dar uma olhada em como escrever casos de teste para esta saga:
import { put , select } from 'redux-saga/effects' ;
// ...
/* 测试获取数据 */
test ( 'request data, check success and fail' , ( ) => {
/* 当前的业务状态 */
const state = {
bizToolbar : {
keywords : 'some keywords'
} ,
bizTable : {
pagination : {
current : 1 ,
pageSize : 15
}
}
} ;
const gen = cloneableGenerator ( saga . onGetBizTableData ) ( ) ;
/* 1. 是否调用了正确的 selector 来获得请求时要发送的参数 */
expect ( gen . next ( ) . value ) . toEqual ( select ( getBizToolbar ) ) ;
expect ( gen . next ( state . bizToolbar ) . value ) . toEqual ( select ( getBizTable ) ) ;
/* 2. 是否调用了 api 层 */
const callEffect = gen . next ( state . bizTable ) . value ;
expect ( callEffect [ 'CALL' ] . fn ) . toBe ( api . getBizTableData ) ;
/* 调用 api 层参数是否传递正确 */
expect ( callEffect [ 'CALL' ] . args [ 0 ] ) . toEqual ( {
keywords : 'some keywords' ,
paging : { skip : 0 , max : 15 }
} ) ;
/* 3. 模拟正确返回分支 */
const successBranch = gen . clone ( ) ;
const successRes = {
items : [
{ id : 1 , code : '1' } ,
{ id : 2 , code : '2' }
] ,
total : 2
} ;
expect ( successBranch . next ( successRes ) . value ) . toEqual (
put ( actions . putBizTableDataSuccessResult ( successRes ) ) ) ;
expect ( successBranch . next ( ) . done ) . toBe ( true ) ;
/* 4. 模拟错误返回分支 */
const failBranch = gen . clone ( ) ;
expect ( failBranch . throw ( new Error ( '模拟产生异常' ) ) . value ) . toEqual (
put ( actions . putBizTableDataFailResult ( ) ) ) ;
expect ( failBranch . next ( ) . done ) . toBe ( true ) ;
} ) ; Este caso de teste é um pouco mais complicado que o anterior. Vamos falar sobre o princípio da saga de testes primeiro. Como mencionado anteriormente, a SAGA realmente retorna vários effects declarativos e é realmente executada pelo mecanismo. Portanto, o objetivo do nosso teste é verificar se a geração de effects atende às expectativas. Então, effect é uma coisa mágica? Na verdade, é um objeto literal!
Podemos gerar esses objetos literais da mesma maneira no código de negócios. As afirmações dos objetos literais são muito simples e não há necessidade de zombar sem chamar diretamente a camada da API! A etapa deste caso de teste é usar a função do gerador para gerar o próximo effect passo a passo e depois afirmar e comparar.
Como pode ser visto nos comentários 3 e 4 acima,
redux-sagatambém fornece algumas funções auxiliares para lidar facilmente com pontos de interrupção de ramificação.
É também por isso que escolhi redux-saga : é poderoso e propício aos testes.
Em seguida está a camada da API relacionada. Como mencionado anteriormente, usei fetch para chamar solicitações de segundo plano. Encapsulei dois métodos para simplificar o processamento de chamadas e resultados: getJSON() e postJSON() , correspondendo às solicitações de GET e POST, respectivamente. Vamos dar uma olhada no código da camada da API:
import { fetcher } from '@/utils/fetcher' ;
export function getBizTableData ( payload ) {
return fetcher . postJSON ( '/api/biz/get-table' , payload ) ;
}O código comercial é simples, portanto os casos de teste são simples:
import sinon from 'sinon' ;
import { fetcher } from '@/utils/fetcher' ;
import * as api from '@/services/bizApi' ;
/* 测试 bizApi */
describe ( 'bizApi' , ( ) => {
let fetcherStub ;
beforeAll ( ( ) => {
fetcherStub = sinon . stub ( fetcher ) ;
} ) ;
// ...
/* getBizTableData api 应该调用正确的 method 和传递正确的参数 */
test ( 'getBizTableData api should call postJSON with right params of fetcher' , ( ) => {
/* 模拟参数 */
const payload = { a : 1 , b : 2 } ;
api . getBizTableData ( payload ) ;
/* 检查是否调用了工具库 */
expect ( fetcherStub . postJSON . callCount ) . toBe ( 1 ) ;
/* 检查调用参数是否正确 */
expect ( fetcherStub . postJSON . lastCall . calledWith ( '/api/biz/get-table' , payload ) ) . toBe ( true ) ;
} ) ;
} ) ; Como a camada da API chama diretamente a biblioteca de ferramentas, aqui usamos sinon.stub() para substituir a biblioteca de ferramentas para obter fins de teste.
Em seguida, teste a biblioteca de ferramentas de busca que você encapsulou. Aqui eu uso isomorphic-fetch , então escolhi nock para simular o servidor para testar, testando principalmente os resultados do acesso normal e simular exceções do servidor, etc. O fragmento de exemplo é o seguinte:
import nock from 'nock' ;
import { fetcher , FetchError } from '@/utils/fetcher' ;
/* 测试 fetcher */
describe ( 'fetcher' , ( ) => {
afterEach ( ( ) => {
nock . cleanAll ( ) ;
} ) ;
afterAll ( ( ) => {
nock . restore ( ) ;
} ) ;
/* 测试 getJSON 获得正常数据 */
test ( 'should get success result' , ( ) => {
nock ( 'http://some' )
. get ( '/test' )
. reply ( 200 , { success : true , result : 'hello, world' } ) ;
return expect ( fetcher . getJSON ( 'http://some/test' ) ) . resolves . toMatch ( / ^hello.+$ / ) ;
} ) ;
// ...
/* 测试 getJSON 捕获 server 大于 400 的异常状态 */
test ( 'should catch server status: 400+' , ( done ) => {
const status = 500 ;
nock ( 'http://some' )
. get ( '/test' )
. reply ( status ) ;
fetcher . getJSON ( 'http://some/test' ) . catch ( ( error ) => {
expect ( error ) . toEqual ( expect . any ( FetchError ) ) ;
expect ( error ) . toHaveProperty ( 'detail' ) ;
expect ( error . detail . status ) . toBe ( status ) ;
done ( ) ;
} ) ;
} ) ;
/* 测试 getJSON 传递正确的 headers 和 query strings */
test ( 'check headers and query string of getJSON()' , ( ) => {
nock ( 'http://some' , {
reqheaders : {
'Accept' : 'application/json' ,
'authorization' : 'Basic Auth'
}
} )
. get ( '/test' )
. query ( { a : '123' , b : 456 } )
. reply ( 200 , { success : true , result : true } ) ;
const headers = new Headers ( ) ;
headers . append ( 'authorization' , 'Basic Auth' ) ;
return expect ( fetcher . getJSON (
'http://some/test' , { a : '123' , b : 456 } , headers ) ) . resolves . toBe ( true ) ;
} ) ;
// ...
} ) ; Basicamente, não há nada complicado. O principal é observar que buscar um retorno de promessa e várias soluções de teste assíncronas do jest podem ser atendidas muito bem.
O resto está relacionado à interface do usuário.
O principal objetivo do componente de contêiner é passar no estado e nas ações. Veja o código do componente do contêiner na barra de ferramentas:
import { connect } from 'react-redux' ;
import { getBizToolbar } from '@/store/selectors' ;
import * as actions from '@/store/actions/bizToolbar' ;
import BizToolbar from '@/components/BizToolbar' ;
const mapStateToProps = ( state ) => ( {
... getBizToolbar ( state )
} ) ;
const mapDispatchToProps = {
reload : actions . reload ,
updateKeywords : actions . updateKeywords
} ;
export default connect ( mapStateToProps , mapDispatchToProps ) ( BizToolbar ) ; Então, o objetivo dos casos de teste também é verificá -los. Aqui usamos redux-mock-store para simular o Redux Store:
import React from 'react';
import { shallow } from 'enzyme';
import configureStore from 'redux-mock-store';
import BizToolbar from '@/containers/BizToolbar';
/* 测试容器组件 BizToolbar */
describe('BizToolbar container', () => {
const initialState = {
bizToolbar: {
keywords: 'some keywords'
}
};
const mockStore = configureStore();
let store;
let container;
beforeEach(() => {
store = mockStore(initialState);
container = shallow(<BizToolbar store={store}/>);
});
/* 测试 state 到 props 的映射是否正确 */
test('should pass state to props', () => {
const props = container.props();
expect(props).toHaveProperty('keywords', initialState.bizToolbar.keywords);
});
/* 测试 actions 到 props 的映射是否正确 */
test('should pass actions to props', () => {
const props = container.props();
expect(props).toHaveProperty('reload', expect.any(Function));
expect(props).toHaveProperty('updateKeywords', expect.any(Function));
});
});
É muito simples, então não há nada a dizer.
Aqui, tomamos o componente da tabela como exemplo, e veremos diretamente como o caso de teste é gravado. De um modo geral, testamos principalmente os seguintes aspectos dos componentes da interface do usuário:
Aqui está o código do caso de teste:
import React from 'react';
import { mount } from 'enzyme';
import sinon from 'sinon';
import { Table } from 'antd';
import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
import BizTable from '@/components/BizTable';
/* 测试 UI 组件 BizTable */
describe('BizTable component', () => {
const defaultProps = {
loading: false,
pagination: Object.assign({}, {
current: 1,
pageSize: 15,
total: 2
}, defaultSettingsUtil.pagination),
data: [{id: 1}, {id: 2}],
getData: sinon.fake(),
updateParams: sinon.fake()
};
let defaultWrapper;
beforeEach(() => {
defaultWrapper = mount(<BizTable {...defaultProps}/>);
});
// ...
/* 测试是否渲染了正确的功能子组件 */
test('should render table and pagination', () => {
/* 是否渲染了 Table 组件 */
expect(defaultWrapper.find(Table).exists()).toBe(true);
/* 是否渲染了 分页器 组件,样式是否正确(mini) */
expect(defaultWrapper.find('.ant-table-pagination.mini').exists()).toBe(true);
});
/* 测试首次加载时数据列表为空是否发起加载数据请求 */
test('when componentDidMount and data is empty, should getData', () => {
sinon.spy(BizTable.prototype, 'componentDidMount');
const props = Object.assign({}, defaultProps, {
pagination: Object.assign({}, {
current: 1,
pageSize: 15,
total: 0
}, defaultSettingsUtil.pagination),
data: []
});
const wrapper = mount(<BizTable {...props}/>);
expect(BizTable.prototype.componentDidMount.calledOnce).toBe(true);
expect(props.getData.calledOnce).toBe(true);
BizTable.prototype.componentDidMount.restore();
});
/* 测试 table 翻页后是否正确触发 updateParams */
test('when change pagination of table, should updateParams', () => {
const table = defaultWrapper.find(Table);
table.props().onChange({current: 2, pageSize: 25});
expect(defaultProps.updateParams.lastCall.args[0])
.toEqual({paging: {current: 2, pageSize: 25}});
});
});
Graças à racionalidade da estratificação do design, é fácil usar os props para obter fins de teste. Combinando enzyme e sinon , os casos de teste ainda mantêm um ritmo simples.
O exposto acima é o caso completo de idéias de escrita e código de amostra para esse cenário. As idéias e métodos mencionados no artigo também podem ser usados em projetos Vue e Angular . O conteúdo completo do código está aqui (vou dizer coisas mais importantes, acho que é fácil me ajudar).
Por fim, podemos usar a taxa de cobertura para verificar se o nível de cobertura do caso de uso é suficiente (geralmente, não há necessidade de perseguir deliberadamente 100%, dependendo da situação real):

O teste de unidade é a base do desenvolvimento orientado a testes de TDD. Do processo acima, podemos ver que a boa hierarquia de design é fácil de gravar casos de teste, e o teste de unidade não é apenas para garantir a qualidade do código: forçará você a pensar na racionalidade do design de código e rejeitar o código do macarrão?
Para emprestar a conclusão do código limpo:
Em 2005, enquanto participava da Conferência de Agilidade em Denver, Elisabeth Hedrickson me entregou uma pulseira verde semelhante à vendida por Lance Armstrong. A pulseira diz "Teste obcecado". Eu o coloquei com alegria e orgulhosamente o mantive. Eu realmente fiquei obcecado com o desenvolvimento orientado a testes desde que aprendi sobre o TDD com Kent Beck em 1999.
Mas algo estranho aconteceu. Eu me vi incapaz de remover a alça. Não apenas porque a pulseira é muito apertada, mas também é um feitiço de aperto espiritual. Essa pulseira é uma declaração da minha ética no trabalho e uma dica para o meu compromisso de fazer o possível para escrever o melhor código. Tire isso como se fosse contra essas declarações e promessas.
Então ainda está no meu pulso. Enquanto escrevia código, eu o vi do fundo do olho. Continuou me lembrando que eu prometi escrever um código elegante.