WEB3DEV

Cover image for Como Não Escrever Testes de Propriedade em Javascript
Panegali
Panegali

Posted on • Atualizado em

Como Não Escrever Testes de Propriedade em Javascript

Testes baseados em propriedades nos dão mais confiança em nosso código. Eles são ótimos em capturar casos extremos que talvez não tenhamos pensado de outra forma. Mas essa confiança tem um custo. Testes de propriedade exigem mais esforço para serem escritos. Eles forçam você a pensar muito sobre o que o código está fazendo e qual deve ser o comportamento esperado. É trabalho duro. Além disso, executar mais de 100 testes sempre levará mais tempo do que executar de 3 a 5 testes baseados em exemplos. Esse custo é real e levanta a questão: como evitar a especificação excessiva ou escrever testes desnecessários?

Evite Reimplantar a Função em Teste

O erro mais comum que vemos em iniciantes é a reimplantação do sistema em teste. E isso faz sentido. Porque é difícil chegar a propriedades que devem ser sempre verdadeiras sobre nosso código. Para usar um exemplo bobo, vamos imaginar que estamos escrevendo alguma função para ordenar uma lista de comentários por data. O código é algo parecido com isto:

const sortByPostDate = (comments) =>
    [...comments].sort((c1, c2) => c1.posted.valueOf() - c2.posted.valueOf());
Enter fullscreen mode Exit fullscreen mode

Queremos ter certeza de que a função de ordenação resulta em tudo estar em ordem. Se não estivermos pensando muito, podemos escrever algo como isto:

describe('sortByPostDate()', () => {
    it('should always return comments in sorted order', () =>
        fc.assert(
            fc.property(fc.array(generateComment()), (comments) => {
                const sortedComments = sortByPostDate(comments);
                const expected = comments.slice(0).sort(({ posted: d1 }, { posted: d2 }) => {
                    if (d1 < d2) return -1;
                    if (d1 > d2) return 1;
                    return 0;
                });
                expect(sortedComments).toEqual(expected);
            }),
        ));
});
Enter fullscreen mode Exit fullscreen mode

Aqui, nosso teste reimplementa a mesma lógica do sortByPostDate(), de modo que não nos diz muito. Tudo o que podemos dizer é que temos a capacidade de escrever a mesma função de duas maneiras diferentes.

Pensando em Propriedades

Uma abordagem melhor seria perguntar que propriedades esperamos manter quando ao ordenarmos nossa lista de comentários? E podemos fazer um brainstorming de algumas idéias:

  • A ordenação não deve acrescentar ou remover nenhum elemento.
  • A ordenação não deve alterar nenhum dos elementos do vetor.
  • A data de lançamento do primeiro item deve ser menor do que todas as outras datas de lançamento.
  • A data de lançamento para o último item deve ser maior do que todas as outras datas de lançamento.
  • A ordenação de duas matrizes com os mesmos elementos deve produzir o mesmo resultado. Mesmo se as duas matrizes estiverem em uma ordem diferente.

Agora podemos pensar em qual destas leis queremos testar. Vamos supor que queiramos garantir que a ordenação não acrescente ou remova elementos. Poderíamos começar testando se a ordenação tem o mesmo comprimento que o vetor de entrada:

describe('sortByPostDate()', () => {
    it('should always return a list with the same length, for any list of comments', () =>
        fc.assert(
            fc.property(fc.array(generateComment()), (comments) => {
                const sortedComments = sortByPostDate(comments);
                expect(sortedComments).toHaveLength(comments.length);
            }),
        ));
});
Enter fullscreen mode Exit fullscreen mode

Esse teste nos dá um pouco mais de confiança. Mas, e se a função de ordenação remover um elemento e adicionar outro? O teste .length não vai pegar isso. Vamos adicionar outro teste para verificar se cada item do vetor de entrada existe no vetor de saída:

describe('sortByPostDate()', () => {
    it('should always return a list of the same length, for any list of comments', () =>
        fc.assert(
            fc.property(fc.array(generateComment()), (comments) => {
                const sortedComments = sortByPostDate(comments);
                expect(sortedComments).toHaveLength(comments.length);
            }),
        ));

    it('should always contain each element from the input list, for any list of comments', () =>
        fc.assert(
            fc.property(fc.array(generateComment()), (comments) => {
                const sortedComments = sortByPostDate(comments);
                sortedComments.forEach((comment) => {
                    expect(sortedComments.includes(comment)).toBe(true);
                });
            }),
        ));
});
Enter fullscreen mode Exit fullscreen mode

Com isso no lugar, agora estamos cobrindo as duas primeiras propriedades de nossa lista de ideias. Mas se você estiver prestando atenção, você notará algo. Se removermos um único teste, não podemos garantir nenhuma das duas propriedades. E nenhum destes testes aborda o aspecto de classificação real de nossa função. As propriedades 3 e 4 podem nos mover ainda mais nessa direção.

Vamos dar outra olhada nessas propriedades:

  • A data de lançamento para o primeiro item deve ser menor do que todas as outras datas de lançamento.
  • A data de lançamento para o último item deve ser maior do que todas as outras datas de lançamento.

Estas duas são coincidentes uma da outra. Se pudermos mostrar que uma delas é válida, então poderemos escrever uma prova mostrando que a outra propriedade é válida também. Assim, vamos nos concentrar na primeira.

Agora, se considerarmos isso um pouco, podemos ampliar um pouco a propriedade. Se tivermos ordenado o conjunto, então a primeira data de postagem deverá ser a mais próxima. Ou seja, é mais recente do que qualquer item que venha depois dela. Mas, o segundo item também deve ter uma data mais recente que os itens que vêm depois dele. E o terceiro. E assim por diante. Isso sugere uma prova recursiva para verificar se classificamos o conjunto:

Um vetor é considerado ordenado em ordem crescente se o primeiro valor for menor que todos os outros valores e o restante do vetor estiver ordenado.

Colocando isso em código, temos:

const isSortedAsc = (list) => {
    if (list.length <= 1) return true;
    const [head, next, ...tail] = list;
    return head <= next && isSortedAsc([next, ...tail]);
};
Enter fullscreen mode Exit fullscreen mode

Não é o código mais eficiente do mundo. Mas ele testará se um vetor de números está em ordem. E podemos usá-lo em um teste de propriedade:

it('should always return elements sorted in order of post date, for any list of comments', () =>
    fc.assert(
        fc.property(fc.array(generateComment()), (comments) => {
            const sortedComments = sortByPostDate(comments);
            expect(isSortedAsc(sortedComments.map(({ posted }) => posted.valueOf()))).toBe(
                true,
            );
        }),
    ));
Enter fullscreen mode Exit fullscreen mode

Agora asseguramos que nossa função ordena sem modificar, adicionar ou remover elementos. Mas ainda temos mais uma propriedade da nossa lista de ideias.

Estamos Especificando Demais?

A última propriedade que pensamos foi:

  • Ordenar dois vetores com os mesmos elementos deve produzir o mesmo resultado. Mesmo que os dois vetores estejam em uma ordem diferente.

Isso é certamente algo que deveria ser verdade. Então, certamente poderíamos escrever um teste de propriedade para ele:

// A quick-and-dirty shuffle function.
const shuffle = (arr) =>
    arr.reduce(
        ({ shuffled, toShuffle }) => {
            const idx = Math.floor(Math.random() * toShuffle.length);
            return {
                shuffled: shuffled.concat([toShuffle[idx]]),
                toShuffle: [...toShuffle.slice(0, idx), ...toShuffle.slice(idx + 1)],
            };
        },
        { shuffled: [], toShuffle: arr },
    ).shuffled;

// … Back to our test code

it('should return identical arrays, for any pair of shuffled arrays', () =>
    fc.assert(
        fc.property(fc.array(generateComment()), (comments) => {
            const shuffledComments = shuffle(comments);
            const sortedCommentsA = sortByPostDate(comments);
            const sortedCommentsB = sortByPostDate(shuffledComments);
            expect(sortedCommentsA).toEqual(sortedCommentsB);
        }),
    ));
Enter fullscreen mode Exit fullscreen mode

A questão é, precisamos deste teste? Isso nos diz algo que os outros não dizem? Pense nisso por um momento. Se eu te perguntasse, como você responderia?

A resposta é, sim, isso nos diz alguma coisa. Mas podemos não nos importar. A propriedade 'vetores idênticos' falhará para um caso específico de limite. Ela falhará quando houver mais de um comentário com a mesma data (até o milissegundo). Nesse caso, a função de classificação interna deixará as entradas do vetor na ordem em que as encontrar. E essa ordem pode ser diferente se tivermos embaralhado os vetores.

Mas isso importa? Bem, isto depende. Depende do que mais está acontecendo em nosso sistema. E as razões pelas quais queríamos organizar a lista em primeiro lugar. Se nosso objetivo é mostrar os comentários do usuário em uma ordem sensata, isso pode não importar. Mas e se estivermos tentando reconciliar um fluxo de edições em um documento? Nesse caso, o não determinismo tem potencial para causar sérios problemas. Mas, na maioria dos casos, não precisaremos desse último teste de propriedade.

Este exemplo generaliza para uma regra prática: evite especificar mais do que o necessário. Agora, alguém pode estar pensando, essa regra funciona para qualquer teste automatizado. Mas, para testes de propriedade, é útil continuar perguntando: “Esta propriedade já foi comprovada (ou inferida) por outras propriedades?”

Isso Precisa Ser Uma Propriedade?

Há muitas situações em que os testes de propriedade funcionam, mas podem não ser necessários. Imagine que estamos criando um componente TextField genérico. Estamos usando para nos ajudar a criar alguns formulários para nossa equipe. Pode parecer algo assim:

const TextField = ({ id, name, label, value, placeholder = '', maxlength = 255 }) => (
    <div className="FormField">
        <label className="FormField-label" htmlFor={id}>
            {label}
        </label>
        <input
            type="text"
            name={name}
            value={value}
            id={id}
            placeholder={placeholder}
            maxLength={maxlength}
        />
    </div>
);
Enter fullscreen mode Exit fullscreen mode

A questão é: existem propriedades que devem valer para um componente (ou função) como este? A maior parte da função é colocar as props (As props são entradas que não podem ser alteradas dentro de um componente. Os componentes devem, obrigatoriamente, apenas ler as props. Sendo assim, todos os componentes de React devem ser “puros” e não podem alterar nenhum valor das props) em espaços reservados. Existem propriedades que podemos definir aqui?

Queremos garantir que cada prop de entrada termine no lugar certo. Mas um punhado de exemplos em uma tabela describe.each() nos daria confiança. Só consigo pensar em uma propriedade que parece importante afirmar aqui:

  • A propriedade htmlFor do rótulo deve sempre se referir à propriedade id da entrada.

Se quebrarmos essa ligação, é uma falha de acessibilidade. Assim, poderíamos escrever um teste de propriedade para ele:

const generateProps = () =>
    fc.record(
        {
            id: fc.string(),
            name: fc.string(),
            label: fc.string(),
            value: fc.string(),
            placeholder: fc.string(),
            maxlength: fc.double(),
        },
        { requiredKeys: ['id', 'name', 'label'] },
    );

describe('TextField', () => {
    it('should always link the label to the input field, given any set of input props', () =>
        fc.assert(
            fc.property(generateProps(), (props) => {
                const wrapper = shallow(<TextField {...props} />);
                expect(wrapper.find('label').prop('htmlFor')).toBe(
                    wrapper.find('input').prop('id'),
                );
            }),
        ));
});
Enter fullscreen mode Exit fullscreen mode

Agora, alguém pode estar pensando que mesmo isso é um exagero. Um punhado de testes de exemplo em describe.each() seria suficiente para isso também. E no cenário que dei, estamos usando esse componente para criar um único formulário. Podemos usá-lo, digamos, dez vezes no total? Se esse for o cenário, poderíamos criar um exemplo para cada id que passarmos. E conhecemos os internos aqui, para que possamos verificar visualmente se o id não interage com outras props. Nesse cenário, executar centenas de testes para esse componente pode ser uma perda de tempo. Podemos generalizar essa ideia para uma regra também:

Se você puder listar todas as entradas que fornecerá à função, talvez não seja necessário um teste de propriedade.

Escrever Testes de Propriedade Para Utilitários e Bibliotecas Compartilhadas

E se o cenário do formulário fosse diferente? E se isso for parte de um sistema de design? As pessoas podem jogar todos os tipos de props estranhas e maravilhosas neste componente. Nesse caso, os testes de propriedade se tornam muito mais valiosos. Até mesmo escrever o gerador levanta algumas questões interessantes:

  • A prop maxlength tem o tipo number. Isso significa que as pessoas podem passar qualquer tipo de valor flutuante. O que deve acontecer se alguém inserir um valor negativo? Ou um valor fracionário? A especificação HTML afirma que isso deve ser um número inteiro positivo. Mas nosso sistema de tipos não pode representar isso. Como queremos lidar com isto?

  • Temos três props necessárias para o componente. Mas são todas strings. E é perfeitamente possível que alguém forneça uma string vazia. Isso é um problema? Em caso afirmativo, o que deve acontecer se as pessoas tentarem?

Em ambos os casos, um teste de propriedade pode ajudar, mas como escrevemos o teste depende das respostas que damos.

Por Que se Preocupar Com Testes de Propriedade?

Nós conversamos muito sobre como os testes de propriedade são caros e difíceis. E, diante de tudo isso, parece razoável perguntar: por que se incomodar? Os testes de propriedade valem o esforço? Não seria melhor focar na integração e nos testes de ponta a ponta? Afinal de contas, esses testes dão muito retorno. Eles não apenas testam se os componentes individuais estão funcionando. Em vez disso, eles testam se os componentes estão trabalhando juntos para entregar valor ao cliente. E é disso que se trata, certo?

Isso é tudo verdade. À medida que os testes, a integração e os testes de ponta a ponta fornecem o maior valor. Mas, como no Test Driven Development (TDD), os testes não são o ponto. A razão pela qual fiquei entusiasmado com o TDD não foi porque fiz muitos testes. Fiquei entusiasmado com o TDD porque, quando o praticava, escrevia códigos melhores. A disciplina de pensar em testes me forçou a esclarecer minha intenção. Comecei a escrever código em pedaços menores e mais compreensíveis. Não só o código precisava de menos manutenção, mas quando precisava, eu temia voltar menos ao código antigo.

Então eu descobri o teste baseado em propriedade. Ele pega todos esses benefícios do TDD e os aumenta em uma ordem de magnitude. Achei que tinha entendido meu código. Então comecei a pensar em propriedades e descobri que não. Em vez de pensar se meu código funcionava, comecei a pensar se estava correto.

twitter Escrever testes, primeiro força você a pensar sobre o problema que está resolvendo. Escrever testes baseados em propriedades força você a pensar muito mais.

Engenheiros de software experientes, todos falam da boca para fora para “pensar em casos extremos”. Devemos considerar todas as coisas possíveis que o mundo possa lançar em nosso código. Testes de propriedade forçam você a realmente fazer isso.

Não se trata apenas de casos extremos. Pensar em propriedades é uma mentalidade. E essa mentalidade é tão valiosa que vale a pena praticar, mesmo que você exclua todos os testes posteriormente. Claro, você precisaria escrever alguns outros testes para capturar regressões. Mas se os testes de propriedade estiverem diminuindo a velocidade de suas compilações, exclua-os. Copie as propriedades em comentários de código ou adicione .skip aos seus testes para que você possa recuperá-los se precisar. Os testes não são o ponto, eles são um benefício colateral.

Claro, não há balas de prata no desenvolvimento de software. Testes de propriedade não são pó mágico que você espalha sobre seu código para tornar tudo melhor. Eles nem garantem código livre de bugs. E, como discutimos, eles são lentos para executar e difíceis de escrever. Mas eles valem a pena. Sim, tenha cuidado com eles. Não, eles podem não se adequar a todas as situações. O ato de pensar neles, porém, ajudará você a escrever um código melhor.

Folha de Referência Grátis

Já esqueceu qual método do vetor JavaScript faz o quê? Deixe o Civilized Guide to JavaScript Array Methods gentilmente guiá-lo para o caminho certo. É gratuito para quem se inscrever para receber atualizações. Adquira sua cópia (Em inglês)

Deixe-me saber seus pensamentos através do Twitter.

Inscreva-se para receber atualizações via e mail.


Este artigo foi escrito por James Sinclair, e traduzido por Marcelo Panegali. Você pode encontrar o texto original aqui.

Latest comments (0)