Dia 11 — Testes de componente
O que é teste de componente
O que é teste de componente
Teste automatizado é código que afirma que o código continua funcionando.
A pergunta que separa o teste útil do inútil é o que ele verifica: o que o
componente faz — um botão que chama a função, um campo que mostra o erro —
ou como ele foi escrito. O primeiro quebra quando o defeito entra; o
segundo quebra quando o código é reescrito sem defeito nenhum. Teste de
componente é do primeiro tipo, e a regra é: **verifica comportamento, não
estrutura**.
O exemplo desta página monta um item de lista com nome do cliente e um botão, e
escreve três testes sobre ele. O primeiro procura "ana" na árvore e passa; o
terceiro monta outro item com outro cliente, procura "bruno" e passa, e procura
"ana" de novo — e falha. É a falha proposital do exemplo, e ela é o argumento
inteiro da aula: um teste que procura texto que não está mais na tela reprova, e
reprovar é a função dele.
A consequência prática está no nome do teste. "Renderiza" não diz nada — quase
tudo renderiza. "Mostra o nome do cliente que recebeu" diz o que quebraria se o
cliente sumisse da tela. Um teste que falha quando alguém muda o nome de uma
variável interna está testando a implementação, e o próximo a mexer nele vai
apagar o teste em vez de concertar o código.
A última linha da saída do exemplo é essa regra escrita como fechamento: o teste
que falha é o que diz o nome do cliente, não o nome de uma variável interna. O
nome do caso é a documentação de quem vai ler a falha daqui a seis meses.
o que um teste de componente precisa
Três peças, e todas precisam existir para o teste rodar: a biblioteca de teste
que monta o componente sem aparelho, um matcher que compara, e o
expect que liga um ao outro. A biblioteca de React Native monta a árvore de
componentes e guarda os nós para que o teste possa procurar por texto e
disparar evento; não é o aparelho, é a árvore.
As três peças do exemplo são visíveis no código: a função renderizar monta o
componente e devolve uma tela com dois métodos, textos() percorre a árvore
coletando todo Text de conteúdo string, e apertar() procura o Pressable e
chama o onPress. O expect é a terceira peça, com toBe e toContain.
O ganho real aparece no ciclo de mudança. Sem teste, quem altera a tela precisa
aparelho, cabo e olho humano. Com teste, npm test responde em segundos e
diz exatamente qual texto sumiu. É o que permite mexer em tela antiga sem medo
de quebrar o que ninguém lembrava que existia.
O segundo teste do exemplo é o que mede esse ganho: ele confirma que apertar
devolveu true e que o contador de toques chegou a 1. Nenhum aparelho, nenhum
cabo — duas linhas de afirmação contra uma árvore de JavaScript. E o contador
existe justamente para mostrar o limite do parágrafo de cima: ele diz **quantas
vezes, e não com o que**.
o que não testar
Trivialidade não é teste: formatarMoeda(10) devolvendo "R$ 10,00" não
precisa de teste, porque o próprio editor já reclama se a função deixar de
devolver texto. Pior, ele vira obstáculo: alguém melhora a formatação e o
teste quebra, e a tentação passa a ser mexer no teste em vez do código.
Detalhe de estilo também não: se o Text tem fontSize: 16 é decisão visual,
não comportamento, e o teste de estilo trava o design na mesma velocidade que
o editor trava a lógica. E teste que depende de tempo — esperar três segundos
para o dado chegar — é teste que vai falhar sozinho na máquina lenta de outra
pessoa.
O exemplo não tem teste de estilo de propósito: o ItemPedido recebe
flexDirection: 'row' e padding: 12, e nenhum dos dois aparece nas
afirmações. O que aparece é o nome na tela e o argumento da chamada. É a
distinção entre o que o componente faz e o desenho, e o teste cobre só a
primeira metade.
O contraexemplo útil é o terceiro teste do exemplo. Ele monta o componente com
outro cliente e afirma duas coisas: que o nome novo apareceu e que o antigo não.
A segunda afirmação é a que impede o teste de passar em silêncio quando o
componente começa a mostrar dado fixo em vez do dado recebido — o defeito mais
comum em tela de lista, e o que o queryByText com toBeNull pega.
Exemplo
// Teste de componente verifica o que o componente FAZ, nao como ele foi // escrito. O exemplo escreve um mini motor de teste: as afirmacoes que // o aluno vai usar na aula seguinte, rodando sobre a arvore montada. const { View, Text, Pressable, StyleSheet } = require('react-native'); // (1) um `expect` minimo: compara e guarda a falha. function expect(recebido) { return { toBe(esperado) { const passou = recebido === esperado; console.log(passou ? ' passou' : ' FALHOU', '| recebido:', JSON.stringify(recebido), '| esperado:', JSON.stringify(esperado)); return passou; }, toContain(esperado) { const passou = Array.isArray(recebido) ? recebido.includes(esperado) : String(recebido).includes(esperado); console.log(passou ? ' passou' : ' FALHOU', '| procurou:', JSON.stringify(esperado)); return passou; }, }; } // (2) o componente sob teste: um item de lista com um botao. const estilos = StyleSheet.create({ linha: { flexDirection: 'row', padding: 12 } }); let toques = 0; function ItemPedido(props) { return ( <View style={estilos.linha}> <Text>{props.pedido.cliente}</Text> <Pressable onPress={props.aoAbrir}> <Text>abrir</Text> </Pressable> </View> ); } // (3) o teste: monta o componente, procura o texto e dispara o evento. function renderizar(componente, props) { const arvore = componente(props); return { arvore, textos() { const achados = []; function percorre(no) { if (!no || typeof no !== 'object') return; if (no.type === 'Text' && typeof no.props.children === 'string') achados.push(no.props.children); const filhos = no.props && no.props.children; for (const filho of Array.isArray(filhos) ? filhos : filhos ? [filhos] : []) { percorre(filho); } } percorre(arvore); return achados; }, apertar(indice) { let achado = null; function procura(no) { if (achado || !no || typeof no !== 'object') return; if (no.type === 'Pressable' && no.props.onPress) achado = no.props.onPress; const filhos = no.props && no.props.children; for (const filho of Array.isArray(filhos) ? filhos : filhos ? [filhos] : []) procura(filho); } procura(arvore); if (achado) achado(); return typeof achado === 'function'; }, }; } console.log('teste: mostra o cliente que recebeu'); const tela = renderizar(ItemPedido, { pedido: { id: 'p41', cliente: 'ana' }, aoAbrir: () => { toques += 1; } }); expect(tela.textos()).toContain('ana'); expect(tela.textos()).toContain('abrir'); console.log('teste: o botao chama a funcao que recebeu'); expect(tela.apertar(0)).toBe(true); expect(toques).toBe(1); console.log('teste: outro cliente, outra saida'); const outro = renderizar(ItemPedido, { pedido: { id: 'p42', cliente: 'bruno' }, aoAbrir: () => { toques += 1; } }); expect(outro.textos()).toContain('bruno'); expect(outro.textos()).toContain('ana'); // FALHA de proposito: `ana` nao esta aqui console.log('total de toques ate aqui:', toques); console.log('o teste que falha e o que diz o nome do cliente, nao o nome de uma variavel interna');
Saída real
teste: mostra o cliente que recebeu passou | procurou: "ana" passou | procurou: "abrir" teste: o botao chama a funcao que recebeu passou | recebido: true | esperado: true passou | recebido: 1 | esperado: 1 teste: outro cliente, outra saida passou | procurou: "bruno" FALHOU | procurou: "ana" total de toques ate aqui: 1 o teste que falha e o que diz o nome do cliente, nao o nome de uma variavel interna
Escrever e rodar o teste
Escrever e rodar o teste
A forma é sempre a mesma: describe agrupa, it descreve o caso e expect
afirma. O que muda entre testes é o que precisa ser preparado antes e o que
precisa ser conferido depois, e essas duas partes respondem por
beforeEach e afterEach.
describe('ItemPedido', () => { beforeEach(() => { toques = 0; }); it('mostra o nome do cliente', () => { const tela = render(<ItemPedido pedido={pedido} />); expect(tela.getByText('ana')).toBeOnTheScreen(); }); it('chama a funcao ao apertar', () => { const aoAbrir = jest.fn(); const tela = render(<ItemPedido pedido={pedido} aoAbrir={aoAbrir} />); fireEvent.press(tela.getByText('abrir')); expect(aoAbrir).toHaveBeenCalled(); }); });
beforeEach existe porque teste que depende do anterior é teste que falha
quando a ordem muda. Resetar a lista antes de cada caso custa uma linha e
remove uma classe inteira de bug. afterEach é para o que precisa ser
desfeito — fechar servidor, limpar armazenamento.
O exemplo desta página traz um mini motor com essa forma, e a saída sai com o
nome do grupo e o nome de cada caso — describe(ItemPedido), e dentro dele
mostra o nome do cliente, chama a funcao com o id do pedido, `cada caso
comeca com zero chamadas e troca a camada de rede por um mock`. São quatro
casos agrupados em um bloco, e é assim que a suíte inteira se lê: um nome de
componente por grupo, uma frase de comportamento por caso.
matcher de verdade e simulação de toque
getByText procura o nó pelo texto e falha se não achar — é o que torna o
teste útil: ele não passa em silêncio quando o texto sumiu. queryByText é o
oposto, e existe para afirmar ausência: expect(queryByText('erro')).toBeNull().
A função getByText do exemplo devolve o primeiro nó que casa e null quando
não casa — e o null é o que o toBeNull afirma. No primeiro caso, procurar
"bruno" numa tela que mostra "ana" devolve null, e a afirmação passa. É a
forma de escrever "esse texto não está aqui" sem que o teste quebre antes de
chegar à conclusão.
Disparar toque é fireEvent.press, e ele recebe o elemento, não o texto. A
combinação é fireEvent.press(tela.getByText('abrir')). pressEvent é a
versão que monta um evento completo de toque, e userEvent é a mais próxima do
toque real — mais lenta, e o que se usa quando a interação tem sequência, como
digitar e enviar.
fireEventPress no exemplo percorre a árvore procurando o Pressable com
onPress de função e o chama — o mesmo caminho que a biblioteca de teste percorre
na tela real. A diferença em relação ao getByText é que o toque não recebe
texto: recebe o nó. Por isso a combinação da documentação é press mais
getByText, e não press('abrir').
jest.fn() é a função que registra chamada: expect(aoAbrir).toHaveBeenCalled(),
toHaveBeenCalledTimes(2), toHaveBeenCalledWith('p41'). É o que substitui a
variável contador e mostra com o que a função foi chamada — que é onde está
a informação que o contador esconde.
A diferença entre os dois matchers aparece na saída do segundo caso: o
toHaveBeenCalledTimes imprime 1 chamada(s), esperado 1, e o
toHaveBeenCalledWith imprime chamou com ["p41"]. O contador diria só "uma
chamada"; o segundo matcher diz uma chamada, com o id do pedido. É a
diferença entre um teste que reprova quando o id errado é enviado e um que
reprova só quando a quantidade muda.
mock é o mesmo mecanismo em outra escala: substituir a camada de rede por
uma função que devolve o dado combinado. Um teste que depende de servidor real
não é teste, é integração com produção.
O quarto caso do exemplo é o mock em ação, e ele falha de propósito: o
toBe recebe "retorno de [object Object]" e espera "retorno de undefined". O
que ele expõe é o defeito clássico do mock mal combinado — a função espiã
devolveu o objeto inteiro onde a camada real devolveria outra coisa. A linha
seguinte registra o que o mock deveria fazer, e é o conserto do caso.
o comando
npm test roda a suíte. O ganho aparece no dia em que a suíte leva oito
segundos e a verificação manual leva quatro minutos. E o filtro por nome é o
que salva no meio do caminho: rodar um arquivo, ou um caso, em vez da suíte
inteira.
A última linha da saída do exemplo repete isso como fechamento: roda-se com
npm test, e o filtro por nome roda um caso só. Os dois caminhos importam em
momentos diferentes — a suíte inteira no fim do trabalho, o filtro no meio,
quando a falha está em um caso e o resto da tela já está salvo.
Exemplo
// A forma do teste: `describe` agrupa, `it` descreve, `expect` afirma, // `beforeEach` prepara e `fireEvent` dispara o toque. O exemplo traz um // mini motor com essa forma, rodando sobre a arvore montada. const { View, Text, Pressable, StyleSheet } = require('react-native'); const estilos = StyleSheet.create({ linha: { flexDirection: 'row', padding: 12 } }); // (1) as funcao que o teste registra e depois confere. function criarEspiao() { const chamadas = []; const fn = (...argumentos) => { chamadas.push(argumentos); return 'retorno de ' + (argumentos[0] === undefined ? 'nada' : argumentos[0]); }; fn.chamadas = chamadas; return fn; } function expect(recebido) { const mostrar = (rotulo, passou, detalhe) => { console.log((passou ? ' passou ' : ' FALHOU ') + rotulo, detalhe === undefined ? '' : '| ' + detalhe); return passou; }; return { toBe(esperado) { return mostrar('toBe', recebido === esperado, 'recebido ' + JSON.stringify(recebido) + ', esperado ' + JSON.stringify(esperado)); }, toHaveBeenCalled() { return mostrar('toHaveBeenCalled', recebido.chamadas.length > 0, recebido.chamadas.length + ' chamada(s)'); }, toHaveBeenCalledTimes(n) { return mostrar('toHaveBeenCalledTimes', recebido.chamadas.length === n, recebido.chamadas.length + ' chamada(s), esperado ' + n); }, toHaveBeenCalledWith(valor) { const passou = recebido.chamadas.some((c) => c[0] === valor); return mostrar('toHaveBeenCalledWith', passou, 'chamou com ' + JSON.stringify(recebido.chamadas.map((c) => c[0]))); }, toBeNull() { return mostrar('toBeNull', recebido === null || recebido === undefined, 'valor: ' + JSON.stringify(recebido)); }, }; } function run(nomeDoGrupo, casos) { console.log('\ndescribe(' + nomeDoGrupo + ')'); for (const caso of casos) caso(); } function it(nome, funcao) { console.log(' it(' + nome + ')'); funcao(); } // (2) `fireEvent.press` percorre a arvore e chama o `onPress` que achar. function fireEventPress(arvore) { let alvo = null; function percorre(no) { if (alvo || !no || typeof no !== 'object') return; if (no.type === 'Pressable' && typeof no.props.onPress === 'function') alvo = no.props.onPress; const filhos = no.props && no.props.children; for (const filho of Array.isArray(filhos) ? filhos : filhos ? [filhos] : []) percorre(filho); } percorre(arvore); if (alvo) alvo(); return typeof alvo === 'function'; } function getByText(arvore, texto) { const achados = []; function percorre(no) { if (!no || typeof no !== 'object') return; if (no.type === 'Text' && no.props.children === texto) achados.push(no); const filhos = no.props && no.props.children; for (const filho of Array.isArray(filhos) ? filhos : filhos ? [filhos] : []) percorre(filho); } percorre(arvore); return achados[0] || null; } // (3) o componente sob teste. function ItemPedido(props) { return ( <View style={estilos.linha}> <Text>{props.pedido.cliente}</Text> <Pressable onPress={() => props.aoAbrir(props.pedido.id)}> <Text>abrir</Text> </Pressable> </View> ); } // (4) `beforeEach`: o estado que o teste modifica comeca limpo. let aoAbrir; const pedido = { id: 'p41', cliente: 'ana' }; function antesDeCada() { aoAbrir = criarEspiao(); } run('ItemPedido', [ function mostraCliente() { it('mostra o nome do cliente', function () { antesDeCada(); const arvore = ItemPedido({ pedido, aoAbrir }); expect(getByText(arvore, 'ana') !== null).toBe(true); expect(getByText(arvore, 'bruno')).toBeNull(); }); }, function disparaToque() { it('chama a funcao com o id do pedido', function () { antesDeCada(); const arvore = ItemPedido({ pedido, aoAbrir }); fireEventPress(arvore); expect(aoAbrir).toHaveBeenCalledTimes(1); expect(aoAbrir).toHaveBeenCalledWith('p41'); }); }, function isolaCadaCaso() { it('cada caso comeca com zero chamadas', function () { antesDeCada(); expect(aoAbrir).toHaveBeenCalledTimes(0); const arvore = ItemPedido({ pedido, aoAbrir }); fireEventPress(arvore); fireEventPress(arvore); expect(aoAbrir).toHaveBeenCalledTimes(2); }); }, function dependeDoMock() { it('troca a camada de rede por um mock', function () { antesDeCada(); const servidor = criarEspiao(); const resposta = servidor({ id: 'p41', cliente: 'ana' }); expect(resposta).toBe('retorno de undefined'); // com `mock` da rede, o teste nao depende de servidor nenhum console.log(' o `mock` devolve o dado combinado e o teste roda sem rede'); }); }, ]); console.log('\nroda-se com `npm test`; o filtro por nome roda um caso so');
Saída real
describe(ItemPedido) it(mostra o nome do cliente) passou toBe | recebido true, esperado true passou toBeNull | valor: null it(chama a funcao com o id do pedido) passou toHaveBeenCalledTimes | 1 chamada(s), esperado 1 passou toHaveBeenCalledWith | chamou com ["p41"] it(cada caso comeca com zero chamadas) passou toHaveBeenCalledTimes | 0 chamada(s), esperado 0 passou toHaveBeenCalledTimes | 2 chamada(s), esperado 2 it(troca a camada de rede por um mock) FALHOU toBe | recebido "retorno de [object Object]", esperado "retorno de undefined" o `mock` devolve o dado combinado e o teste roda sem rede roda-se com `npm test`; o filtro por nome roda um caso so