O autor selecionou a Open Internet/Free Speech Fund para receber uma doação como parte do programa Write for DOnations.

Introdução

Realizar testes é uma parte fundamental no desenvolvimento de software. É comum que os programadores executem códigos que testam seu aplicativo, enquanto eles fazem alterações para confirmar se o aplicativo está se comportando como eles gostariam. Com a configuração de teste correta, este processo pode até ser automatizado, economizando bastante tempo. Realizar testes consistentemente após escrever um novo código garante que novas alterações não quebrem recursos pré-existentes. Isso dá ao desenvolvedor confiança em sua base de códigos, especialmente quando ela é implantada para a produção, para que os usuários possam interagir com ela.

Um framework de teste estrutura a maneira como criamos casos de teste. O Mocha é um framework de teste popular em JavaScript. Ele organiza nossos casos de teste e executa-os para nós. No entanto, o Mocha não verifica o comportamento do nosso código. Para comparar valores em um teste, podemos utilizar o módulo assert do Node.js.

Neste artigo, você escreverá testes para um módulo de lista TODO (de AFAZERES) do Node.js. Você configurará e usará o framework de teste Mocha para estruturar seus testes. Então, usará o módulo assert do Node.js para criar os testes de fato. Neste sentido, você usará o Mocha como um construtor de planos e o assert para implementar o plano.

Pré-requisitos

  • Node.js instalado em sua máquina de desenvolvimento. Este tutorial utiliza a versão 10.16.0 do Node.js. Para instalar essa versão em macOS ou Ubuntu 18.04, siga os passos descritos no artigo sobre Como instalar o Node.js e criar um ambiente de desenvolvimento local em macOS ou a seção entitulada Instalando usando um PPA, do artigo sobre Como instalar o Node.js no Ubuntu 18.04.
  • Um conhecimento básico do JavaScript, que pode ser encontrado em nossa série Como programar em JavaScript.

Passo 1 — Escrevendo um módulo do Node

Vamos começar este artigo escrevendo o módulo do Node.js que queremos testar. Este módulo gerenciará itens de uma lista de AFAZERES. Usando este módulo, seremos capazes de listar todos os AFAZERES dos quais estamos mantendo o controle, adicionar novos itens e marcar alguns como completos. Além disso, vamos conseguir exportar uma lista de AFAZERES para um arquivo CSV. Caso queira um lembrete sobre módulos do Node.js, leia nosso artigo sobre Como criar um módulo do Node.js.

Primeiro, precisamos configurar o ambiente de programação. Crie uma pasta com o nome do seu projeto no terminal. Este tutorial usará o nome todos:

  • mkdir todos

Então, acesse aquela pasta:

  • cd todos

Agora, inicialize o npm, pois vamos usar sua funcionalidade CLI para executar os testes mais tarde:

  • npm init -y

Temos apenas uma dependência, o Mocha, que usaremos para organizar e executar nossos testes. Para baixar e instalar o Mocha, use o seguinte:

  • npm i request --save-dev mocha

Instalamos o Mocha como uma dependência dev, pois o módulo não exige a presença dela em uma configuração de produção. Caso queira aprender mais sobre os pacotes do Node.js ou o npm, confira nosso guia sobre Como usar os módulos do Node.js com o npm e o package.json.

Por fim, vamos criar o arquivo que terá o código do nosso módulo:

  • touch index.js

Com isso, estamos prontos para criar nosso módulo. Abra o index.js em um editor de texto como o nano:

  • nano index.js

Vamos começar definindo a classeTodos. Essa classe contém todas as funções que precisamos para gerenciar nossa lista de AFAZERES. Adicione as linhas de código a seguir ao index.js:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }
}

module.exports = Todos;

Começamos o arquivo criando uma classe Todos. Sua função constructor() não recebe argumentos, de forma que não precisamos fornecer valores para instanciar objetos para esta classe. Tudo o que fazemos ao inicializar um objeto de Todos é criar uma propriedade de todos que é uma matriz vazia.

A linha modules (módulos) permite que outros módulos do Node.js exijam nossa classe Todos. Sem exportar explicitamente a classe, o arquivo de teste que criaremos mais tarde não seria capaz de usá-la.

Vamos adicionar uma função para retornar a matriz de todos que armazenamos. Escreva nas seguintes linhas em destaque:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }
}

module.exports = Todos;

Nossa função list() retorna uma cópia da matriz que é usada pela classe. Ele faz uma cópia da matriz usando a sintaxe de desestruturação do JavaScript. Fazemos uma cópia da matriz, para que alterações que o usuário faz na matriz retornada por list() não afete a matriz usada pelo objeto Todos.

Nota: as matrizes do JavaScript são tipos de referência. Isso significa que, para qualquer atribuição de variável a uma matriz ou invocação de função com uma matriz como parâmetro, o JavaScript se refere à matriz original criada. Por exemplo, caso tenhamos uma matriz com três itens chamados x e criemos uma nova variável, y, tal que y = x, y e x se referem à mesma coisa. Qualquer alteração que fizermos em y na matriz tem impacto na variável x e vice-versa.

Agora, vamos escrever a função add(), que adiciona um novo item de AFAZERES:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }

        this.todos.push(todo);
    }
}

module.exports = Todos;

Nossa função add() recebe uma string e a coloca em uma nova propriedade de um objeto do JavaScript. O novo objeto também tem uma propriedade completed (finalizado), que é definida como false (falso) por padrão. Depois, adicionamos este novo objeto à nossa matriz de AFAZERES.

Uma funcionalidade importante em um gestor de AFAZERES é marcar itens como finalizados. Para essa implantação, vamos fazer um loop que percorre nossa matriz todos para encontrar o item de AFAZERES que o usuário está procurando. Caso um seja encontrado, marcaremos o processo como concluído. Caso nenhum seja encontrado, emitiremos um erro.

Adicione a função complete(), desta forma:

todos/index.js
class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }

        this.todos.push(todo);
    }

    complete(title) {
        let todoFound = false;
        this.todos.forEach((todo) => {
            if (todo.title === title) {
                todo.completed = true;
                todoFound = true;
                return;
            }
        });

        if (!todoFound) {
            throw new Error(`No TODO was found with the title: "${title}"`);
        }
    }
}

module.exports = Todos;

Salve o arquivo e saia do editor de texto.

Temos agora um gerenciador de AFAZERES básico, que podemos testar. Em seguida, vamos testar manualmente nosso código para ver se o aplicativo está funcionando.

Passo 2 — Testando o código manualmente

Neste passo, executaremos as funções do nosso código e observaremos o resultado para garantir que ele corresponda às nossas expectativas. Isso é chamado de teste manual. É provável que seja aplicada a metodologia de testes mais comum. Apesar do fato de que iremos automatizar nosso teste mais tarde com o Mocha, vamos primeiro testar manualmente nosso código para ter uma melhor ideia de como o teste varia entre diferentes frameworks de teste.

Vamos adicionar dois itens de AFAZERES ao nosso aplicativo e marcar um deles como completo. Inicie o REPL do Node.js na mesma pasta que o arquivo index.js:

  • node

Você verá o prompt > no REPL que nos diz que podemos inserir o código do JavaScript. Digite o seguinte no prompt:

  • const Todos = require('./index');

Com o require(), carregamos o módulo de AFAZERES em uma variável Todos. Lembre-se de que nosso módulo retorna a classe Todos por padrão.

Agora, vamos instanciar um objeto para essa classe. No REPL, adicione esta linha de código:

  • const todos = new Todos();

Podemos utilizar o objeto todos para verificar nossos trabalhos de implementação. Vamos adicionar nosso primeiro item de AFAZERES:

  • todos.add("run code");

Até agora, não vimos nenhum resultado em nosso terminal. Vamos verificar se armazenamos nosso item de AFAZERES "run code" (executar o código), obtendo uma lista de todos os nossos AFAZERES:

  • todos.list();

Você verá este resultado em seu REPL:

Output
[ { title: 'run code', completed: false } ]

Este é o resultado esperado: temos um item de AFAZERES em nossa matriz de AFAZERES. Além disso, o arquivo não está concluído por padrão.

Vamos adicionar outro item de AFAZERES:

  • todos.add("test everything");

Marque o primeiro item de AFAZERES como concluído:

  • todos.complete("run code");

Nosso objeto todos agora está gerenciando dois itens: "run code" e "test everything" (testar tudo). O item de AFAZERES "run code" também será concluído. Vamos confirmar isso, chamando list() novamente:

  • todos.list();

O REPL irá gerar como resultado:

Output
[ { title: 'run code', completed: true }, { title: 'test everything', completed: false } ]

Agora, saia do REPL com o seguinte:

  • .exit

Confirmamos que nosso módulo se comporta da maneira como esperávamos. Embora não tenhamos colocado nosso código em um arquivo de teste ou usado uma biblioteca de teste, testamos nosso código manualmente. Infelizmente, essa forma de teste gasta muito tempo se feita todas as vezes que fizermos uma mudança. Em seguida, vamos utilizar testes automatizados no Node.js e ver se podemos resolver esse problema com o framework de testes Mocha.

Passo 3 — Escrevendo seu primeiro teste com o Mocha e Assert

No último passo, testamos manualmente nosso aplicativo. Isso funciona para casos individuais de uso. Entretanto, conforme nosso módulo aumenta em escala, menos viável se torna esse método. À medida que testamos novas características, precisamos ter certeza de que adicionamos funcionalidades que não criaram problemas com funcionalidades já existentes. Seria interessante testarmos todas as funcionalidades para cada alteração no código mais uma vez. Porém, fazer isso manualmente necessitaria de muito esforço e estaria propenso a erros.

Uma prática mais eficiente poderia ser configurar testes automatizados. Esses testes seguem scripts escritos como qualquer outro bloco de código. Executamos nossas funções com entradas definidas e inspecionamos seus efeitos para garantir que se comportam como esperamos. À medida que nossa base de código cresce, também crescem nossos testes automatizados. Ao escrevermos novos testes juntamente com as funcionalidades, podemos verificar se todos os módulos ainda funcionam — tudo isso sem precisar lembrar como usar cada função toda vez.

Neste tutorial, estamos usando o framework de testes Mocha com o módulo assert do Node.js. Vamos colocar um pouco a mão na massa para ver como eles funcionam juntos.

Para começar, crie um novo arquivo para armazenar nosso código de teste:

  • touch index.test.js

Agora, utilize seu editor de texto preferido para abrir o arquivo de teste. Você pode usar o nano, assim como anteriormente:

  • nano index.test.js

Na primeira linha do arquivo de texto, carregaremos o módulo de AFAZERES, assim como fizemos no shell do Node.js. Depois disso, carregaremos o módulo assert para quando escrevermos nossos testes. Adicione as linhas a seguir:

todos/index.test.js
const Todos = require('./index');
const assert = require('assert').strict;

A propriedade strict do módulo assert nos permitirá usar testes de igualdade especiais recomendados pelo Node.js. Esses testes são também adequados para testes futuros, já que representam mais casos de uso.

Antes de escrevermos os testes, vamos discutir como o Mocha organiza o nosso código. Geralmente, os testes estruturados no Mocha seguem este modelo:

describe([String with Test Group Name], function() {
    it([String with Test Name], function() {
        [Test Code]
    });
});

Note duas funções chave: describe() e it(). A função describe() é usada para agrupar testes semelhantes. Ela não é exigida para que o Mocha realize testes, mas o agrupamento de testes torna nosso código de teste mais fácil de ser mantido. Recomenda-se que você agrupe seus testes de uma maneira que torne fácil a atualização dos que são semelhantes de uma só vez.

O it() contém nosso código de teste. É aqui que interagimos com as funções do nosso módulo e usamos a biblioteca assert. Muitas funções it() podem ser definidas em uma função describe().

Nosso objetivo nesta seção é usar o Mocha e o assert para automatizar nosso teste manual. Faremos isso passo a passo, começando com nosso bloco describe. Adicione isto ao seu arquivo após as linhas do módulo:

todos/index.test.js
...
describe("integration test", function() {
});

Com este bloco de código, criamos um agrupamento para nossos testes integrados. Os testes de unidade testariam uma função de cada vez. Os integration tests (testes de integração) verificam o quão bem as funções dentro de um módulo de diferentes módulos funcionam juntas. Quando o Mocha executa nosso teste, todos os testes dentro desse bloco describe serão executados no grupo "integration test".

Vamos adicionar uma função it(), para que possamos começar a testar o código do nosso módulo:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
    });
});

Note como escolhemos um nome descritivo para o teste. Caso alguém execute nosso teste, ficará imediatamente claro o que está passando ou falhando. Normalmente, um aplicativo bem testado é um aplicativo bem documentado. Além disso, os testes podem ser, por vezes, um tipo de documentação eficaz.

Para nosso primeiro teste, criaremos um novo objeto Todos e verificaremos se ele não tem itens nele:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.notStrictEqual(todos.list().length, 1);
    });
});

A primeira linha de código instanciou um novo objeto Todos, assim como faríamos no REPL do Node.js ou outro módulo. Na segunda linha nova, usamos o módulo assert.

A partir do módulo assert, usamos o método notStrictEqual(). Essa função recebe dois parâmetros: o valor que queremos testar (chamado de valor actual (real)) e o valor que esperamos obter (chamada de valor expected (esperado)). Caso ambos os argumentos sejam iguais, notStrictEqual() emite um erro para fazer o teste falhar.

Salve e saia do index.test.js.

O caso base será verdadeiro, pois o comprimento deveria ser 0, que é diferente de 1. Vamos confirmar isso executando o Mocha. Para fazer isso, precisamos modificar nosso arquivo package.json. Abra o seu arquivo package.json com seu editor de texto:

  • nano package.json

Agora, modifique sua propriedade scripts para que se pareça com isto:

todos/package.json
...
"scripts": {
    "test": "mocha index.test.js"
},
...

Acabamos de alterar o comportamento do comando CLI test do npm. Ao executarmos npm test, o npm irá revisar o comando que acabamos de digitar no package.json. Ela irá procurar pela biblioteca do Mocha em nossa pasta node_modules e executará o comando mocha com nosso arquivo de teste.

Salve e saia do package.json.

Vamos ver o que acontece ao executarmos nosso teste. Em seu terminal, digite:

  • npm test

O comando gerará o seguinte resultado:

Output
> todos@1.0.0 test your_file_path/todos > mocha index.test.js integrated test ✓ should be able to add and complete TODOs 1 passing (16ms)

Em primeiro lugar, este resultado nos mostra qual grupo de testes está prestes a ser executado. Para cada teste dentro de um grupo, pula-se uma linha no caso de teste. Vemos nosso nome de teste da forma como o descrevemos na função it(). A marcação no lado esquerdo do caso de teste indica que o teste foi aprovado.

No final, recebemos um resumo de todos os nossos testes. Em nosso caso, nosso único teste foi aprovado e foi concluído em 16ms (o tempo varia de computador para computador).

Nossa testagem foi iniciada com sucesso. No entanto, este caso de teste atual pode permitir falsos positivos. Um falso positivo é um caso de teste que é aprovado quando na verdade deveria falhar.

Neste momento, verificamos que o comprimento da matriz não é igual a 1. Vamos modificar o teste para que essa condição seja verdadeira quando não deveria. Adicione as linhas a seguir ao index.test.js:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        todos.add("get up from bed");
        todos.add("make up bed");
        assert.notStrictEqual(todos.list().length, 1);
    });
});

Salve e saia do arquivo.

Adicionamos dois itens de AFAZERES. Vamos executar o teste para ver o que acontece:

  • npm test

Isso resultará no seguinte:

Output
... integrated test ✓ should be able to add and complete TODOs 1 passing (8ms)

O teste é aprovado conforme esperado, pois o comprimento é maior que 1. No entanto, ele anula o propósito original de ter aquele primeiro teste. O primeiro teste visa confirmar que estamos começando de um estado vazio. Um teste mais bem acabado confirmará isso, em todos os casos.

Vamos alterar o teste, para que seja aprovado apenas se tivermos absolutamente nenhum item de AFAZERES em estoque. Faça as seguintes alterações no index.test.js:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        todos.add("get up from bed");
        todos.add("make up bed");
        assert.strictEqual(todos.list().length, 0);
    });
});

Você alterou o notStrictEqual() para strictEqual(), uma função que verifica se há uma igualdade entre seu argumento real e esperado. A função strict equal falhará no caso de nossos argumentos não serem exatamente iguais.

Salve, saia e então execute o teste para que possamos ver o que acontece:

  • npm test

Desta vez, o resultado mostrará um erro:

Output
... integration test 1) should be able to add and complete TODOs 0 passing (16ms) 1 failing 1) integration test should be able to add and complete TODOs: AssertionError [ERR_ASSERTION]: Input A expected to strictly equal input B: + expected - actual - 2 + 0 + expected - actual -2 +0 at Context.<anonymous> (index.test.js:9:10) npm ERR! Test failed. See above for more details.

Este texto será útil para que possamos depurar o porquê do teste ter falhado. Note que, como o teste falhou, não houve marcador no início do caso de teste.

Nosso resumo de teste já não está mais no final do resultado, mas sim logo após a exibição de nossa lista de casos de teste:

...
0 passing (29ms)
  1 failing
...

O resultado que restou nos fornece dados sobre nossos testes que falharam. Primeiro, vemos qual caso de teste falhou:

...
1) integrated test
       should be able to add and complete TODOs:
...

Em seguida, vemos o motivo pelo qual nosso teste falhou:

...
      AssertionError [ERR_ASSERTION]: Input A expected to strictly equal input B:
+ expected - actual

- 2
+ 0
      + expected - actual

      -2
      +0

      at Context.<anonymous> (index.test.js:9:10)
...

Um AssertionError é lançado quando o strictEqual() falha. Vemos que o valor expected, 0, é diferente do valor actual, 2.

Depois disso, vemos a linha do nosso arquivo de teste em que o código falha. Neste caso, é a linha 10.

Agora, vimos por conta própria que nosso teste falhará se esperarmos valores incorretos. Vamos alterar nosso caso de teste novamente para o valor correto. Primeiro, abra o arquivo:

  • nano index.test.js

Em seguida, retire as linhas todos.add para que seu código se pareça com o seguinte:

todos/index.test.js
...
describe("integration test", function () {
    it("should be able to add and complete TODOs", function () {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);
    });
});

Salve e saia do arquivo.

Execute-o novamente para confirmar que ele foi aprovado sem possíveis falsos positivos:

  • npm test

O resultado será o seguinte:

Output
... integration test ✓ should be able to add and complete TODOs 1 passing (15ms)

Agora, melhoramos significativamente nossa resiliência do teste. Vamos prosseguir com o nosso teste de integração. O próximo passo é adicionar um novo item de AFAZERES no index.test.js:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);

        todos.add("run code");
        assert.strictEqual(todos.list().length, 1);
        assert.deepStrictEqual(todos.list(), [{title: "run code", completed: false}]);
    });
});

Após usar a função add(), confirmamos que temos agora um item de AFAZERES sendo gerenciado pelo nosso objeto todos com o strictEqual(). Nosso próximo teste confirma os dados em todos com o deepStrictEqual(). A função deepStrictEqual() testa recursivamente se nossos objetos esperado e real possuem as mesmas propriedades. Neste caso, ela testa se ambas as matrizes esperadas possuem um objeto do JavaScript dentro delas. Depois disso, verifica se seus objetos do JavaScript possuem as mesmas propriedades, ou seja, se ambas as suas propriedades title são "run code" e ambas as suas propriedades completed são false.

Depois disso, completamos os testes restantes usando esses dois controles de igualdade, conforme necessário, pela adição das seguintes linhas em destaque:

todos/index.test.js
...
describe("integration test", function() {
    it("should be able to add and complete TODOs", function() {
        let todos = new Todos();
        assert.strictEqual(todos.list().length, 0);

        todos.add("run code");
        assert.strictEqual(todos.list().length, 1);
        assert.deepStrictEqual(todos.list(), [{title: "run code", completed: false}]);

        todos.add("test everything");
        assert.strictEqual(todos.list().length, 2);
        assert.deepStrictEqual(todos.list(),
            [
                { title: "run code", completed: false },
                { title: "test everything", completed: false }
            ]
        );

        todos.complete("run code");
        assert.deepStrictEqual(todos.list(),
            [
                { title: "run code", completed: true },
                { title: "test everything", completed: false }
            ]
    );
  });
});

Salve e saia do arquivo.

Nosso teste agora imita nosso teste manual. Com esses testes programáticos, não precisamos verificar o resultado continuamente se nossos testes forem aprovados quando os executarmos. Normalmente, deseja-se testar todos os aspectos de uso para garantir que o código seja testado corretamente.

Vamos executar nosso teste com o npm test novamente para obter este resultado bastante conhecido:

Output
... integrated test ✓ should be able to add and complete TODOs 1 passing (9ms)

Agora, você configurou um teste integrado com o framework do Mocha e a biblioteca assert.

Vamos considerar uma situação em que compartilhamos nosso módulo com outros desenvolvedores, e, agora, eles estão nos dando feedback. Uma boa parte de nossos usuários iria gostar que a função complete() retornasse um erro se nenhum item de AFAZERES tivesse sido adicionado até agora. Vamos adicionar essa funcionalidade em nossa função complete().

Abra o index.js no seu editor de texto:

  • nano index.js

Adicione o que vem a seguir à função:

todos/index.js
...
complete(title) {
    if (this.todos.length === 0) {
        throw new Error("You have no TODOs stored. Why don't you add one first?");
    }

    let todoFound = false
    this.todos.forEach((todo) => {
        if (todo.title === title) {
            todo.completed = true;
            todoFound = true;
            return;
        }
    });

    if (!todoFound) {
        throw new Error(`No TODO was found with the title: "${title}"`);
    }
}
...

Salve e saia do arquivo.

Agora, vamos adicionar um novo teste para este novo recurso. Queremos verificar se, caso um objeto Todos que não possui itens seja chamado de completo, ele retornará nosso erro especial.

Volte para o index.test.js:

  • nano index.test.js

No final do arquivo, adicione o seguinte código:

todos/index.test.js
...
describe("complete()", function() {
    it("should fail if there are no TODOs", function() {
        let todos = new Todos();
        const expectedError = new Error("You have no TODOs stored. Why don't you add one first?");

        assert.throws(() => {
            todos.complete("doesn't exist");
        }, expectedError);
    });
});

Usamos o describe() e o it() assim como anteriormente. Nosso teste começa com a criação de um novo objeto todos. Depois disso, definimos o erro que estamos esperando receber ao chamarmos a função complete().

Em seguida, usamos a função throws() do módulo assert. Essa função foi criada para que possamos verificar os erros que são emitidos em nosso código. Seu primeiro argumento é uma função que contém o código que emite o erro. O segundo argumento é o erro que estamos esperando receber.

Em seu terminal, execute novamente os testes com o npm test e você verá agora o seguinte resultado:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs 2 passing (25ms)

Este resultado destaca o benefício e o porquê de realizarmos testes automatizados com o Mocha e o assert. Como nossos testes têm um script, toda vez que executamos o npm test, verificamos que todos os nossos testes estão sendo aprovados. Não precisamos verificar manualmente se o outro código ainda está funcionando; sabemos que está, porque o teste que fizemos ainda foi aprovado.

Até agora, nossos testes verificaram os resultados de código síncrono. Vamos ver como precisaríamos adaptar nossos hábitos de teste recém-formados para trabalhar com código assíncrono.

Passo 4 — Testando código assíncrono

Uma das funcionalidades que queremos em nosso módulo de AFAZERES é um recurso de exportação em CSV. Isso imprimirá todos os AFAZERES que temos armazenados, junto com o status finalizado para um arquivo. Isso exige que usemos o módulo fs — um módulo integrado do Node.js para trabalhar com o sistema de arquivos.

Escrever em um arquivo é uma operação assíncrona. Há várias maneiras de gravar em um arquivo no Node.js. Podemos usar callbacks, promessas, ou as palavras-chave async/await. Nesta seção, veremos como gravar testes para esses diferentes métodos.

Callbacks

Uma função callback é usada como um argumento para uma função assíncrona. Ela é chamada quando a operação assíncrona é concluída.

Vamos adicionar uma função à nossa classe Todos chamada saveToFile(). Essa função construirá uma string, percorrendo em loop todos os nossos itens de AFAZERES e gravando a string em um arquivo.

Abra o seu arquivo index.js:

  • nano index.js

Neste arquivo, adicione o código em destaque a seguir:

todos/index.js
const fs = require('fs');

class Todos {
    constructor() {
        this.todos = [];
    }

    list() {
        return [...this.todos];
    }

    add(title) {
        let todo = {
            title: title,
            completed: false,
        }
        this.todos.push(todo);
    }

    complete(title) {
        if (this.todos.length === 0) {
            throw new Error("You have no TODOs stored. Why don't you add one first?");
        }

        let todoFound = false
        this.todos.forEach((todo) => {
            if (todo.title === title) {
                todo.completed = true;
                todoFound = true;
                return;
            }
        });

        if (!todoFound) {
            throw new Error(`No TODO was found with the title: "${title}"`);
        }
    }

    saveToFile(callback) {
        let fileContents = 'Title,Completed\n';
        this.todos.forEach((todo) => {
            fileContents += `${todo.title},${todo.completed}\n`
        });

        fs.writeFile('todos.csv', fileContents, callback);
    }
}

module.exports = Todos;

Primeiro, precisamos importar o módulo fs para o nosso arquivo. Depois disso, adicionamos nossa nova função saveToFile(). Nossa função recebe uma função de callback que será usada assim que a operação de gravação do arquivo for concluída. Nessa função, criamos uma variável fileContents que armazena toda a string que queremos que seja salva como um arquivo. Ela é inicializada com os cabeçalhos do CSV. Depois disso, percorremos em loop cada item de AFAZERES com o método forEach() da matriz interna. À medida que iteramos, adicionamos as propriedades title e completed dos objetos todos individuais.

Por fim, usamos o módulo fs para gravar o arquivo com a função writeFile(). Nosso primeiro argumento é o nome do arquivo: todos.csv. O segundo é o conteúdo do arquivo, sendo neste caso, nossa variável fileContents. Nosso último argumento é a nossa função callback, que lida com quaisquer erros de gravação de arquivos.

Salve e saia do arquivo.

Vamos agora escrever um teste para a nossa função saveToFile(). Nosso teste fará duas coisas: confirmar se o arquivo existe em primeiro lugar e, em seguida, verificar se ele tem o conteúdo correto.

Abra o arquivo index.test.js:

  • nano index.test.js

Vamos começar carregando o módulo fs no topo do arquivo, pois o usaremos para ajudar a testar nossos resultados:

todos/index.test.js
const Todos = require('./index');
const assert = require('assert').strict;
const fs = require('fs');
...

Agora, no final do arquivo, vamos adicionar nosso novo caso de teste:

todos/index.test.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", function(done) {
        let todos = new Todos();
        todos.add("save a CSV");
        todos.saveToFile((err) => {
            assert.strictEqual(fs.existsSync('todos.csv'), true);
            let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
            let content = fs.readFileSync("todos.csv").toString();
            assert.strictEqual(content, expectedFileContents);
            done(err);
        });
    });
});

Assim como anteriormente, usamos o describe() para agrupar nosso teste separadamente dos outros, uma vez que ele envolve uma nova funcionalidade. A função it() é ligeiramente diferente de nossas outras. Normalmente, a função callback que usamos não tem argumentos. Desta vez, temos done como um argumento. Precisamos desse argumento ao testar as funções com callbacks. A função callback done() é usada pelo Mocha para dizer a ele quando uma função assíncrona é concluída.

Todas as funções callback que estão sendo testadas no Mocha devem chamar o callback done() Caso contrário, o Mocha nunca saberia quando a função foi concluída e ficaria preso à espera de um sinal.

Continuando, criamos nossa instância Todos e adicionamos um único item a ela. Em seguida, chamamos a função saveToFile() com um call que captura um erro de gravação de arquivos. Note como nosso teste para essa função reside no callback. Caso nosso código de teste estivesse fora do callback, ele falharia, enquanto o código fosse chamado antes da gravação do arquivo terminar.

Em nossa função callback, verificamos primeiro se o nosso arquivo existe:

todos/index.test.js
...
assert.strictEqual(fs.existsSync('todos.csv'), true);
...

A função fs.existsSync() retorna true caso o caminho do arquivo em seu argumento exista, false caso contrário.

Nota: as funções do módulo fs são assíncronas por padrão. No entanto, para as funções chave, foram criadas contrapartes síncronas. Este teste é mais simples ao usar funções síncronas, pois não precisamos aninhar o código assíncrono para garantir que ele funcione. No módulo fs, as funções síncronas geralmente têm seus nomes terminados com "Sync".

Depois disso, criamos uma variável para armazenar nosso valor esperado:

todos/index.test.js
...
let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
...

Usamos o readFileSync() do módulo fs para ler o arquivo de maneira síncrona:

todos/index.test.js
...
let content = fs.readFileSync("todos.csv").toString();
...

Agora, provisionamos o readFileSync() com o caminho correto para o arquivo: todos.csv. À medida que o readFileSync() retorna um objeto Buffer, que armazena dados binários, usamos seu método toString() para que possamos comparar seu valor com a string que esperamos que tenha sido salva.

Assim como anteriormente, usamos o strictEqual, do módulo assert, para fazer uma comparação:

todos/index.test.js
...
assert.strictEqual(content, expectedFileContents);
...

Terminamos nosso teste chamando o callback done(), garantindo que o Mocha saiba quando parar de testar esse caso:

todos/index.test.js
...
done(err);
...

Fornecemos o objeto err ao done(), para que o Mocha possa reprovar o teste caso tenha ocorrido um erro.

Salve e saia do index.test.js.

Vamos executar este teste com o npm test, assim como anteriormente. Seu console exibirá este resultado:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (15ms)

Agora, você testou sua primeira função assíncrona com o Mocha usando callbacks. Mas no momento em que este tutorial é escrito, as Promessas são mais prevalentes que callbacks em novos códigos do Node.js, como explicado em nosso artigo Como escrever um código assíncrono em Node.js. Em seguida, vamos aprender também a como testá-los com o Mocha.

Promessas

Uma Promessa é um objeto do JavaScript que retornará, eventualmente, um valor. Quando uma Promessa é bem-sucedida, ela é resolvida. Quando encontra um erro, ela é rejeitada.

Vamos modificar a função saveToFile(), para que ela utilize Promessas, em vez de callbacks. Abra o index.js:

  • nano index.js

Primeiro, precisamos alterar a forma como o módulo fs é carregado. No seu arquivo index.js, modifique a declaração require() no topo do arquivo, para que se pareça com isto:

todos/index.js
...
const fs = require('fs').promises;
...

Acabamos de importar o módulo fs que utiliza Promessas, em vez de callbacks. Agora, precisamos fazer algumas alterações no saveToFile(), para que ele funcione com Promessas.

No seu editor de texto, faça as seguintes alterações à função saveToFile() para remover os callbacks:

todos/index.js
...
saveToFile() {
    let fileContents = 'Title,Completed\n';
    this.todos.forEach((todo) => {
        fileContents += `${todo.title},${todo.completed}\n`
    });

    return fs.writeFile('todos.csv', fileContents);
}
...

A primeira diferença é que nossa função já não aceita qualquer argumento. Com Promessas, não precisamos de uma função callback. A segunda mudança diz respeito à forma como o arquivo é gravado. Agora, retornamos o resultado da promessa writeFile().

Salve e feche o index.js.

Vamos agora adaptar nosso teste, para que ele funcione com Promessas. Abra o index.test.js:

  • nano index.test.js

Altere o teste saveToFile(), substituindo por isto:

todos/index.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", function() {
        let todos = new Todos();
        todos.add("save a CSV");
        return todos.saveToFile().then(() => {
            assert.strictEqual(fs.existsSync('todos.csv'), true);
            let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
            let content = fs.readFileSync("todos.csv").toString();
            assert.strictEqual(content, expectedFileContents);
        });
    });
});

A primeira mudança que precisamos fazer é remover o callback done() dos seus argumentos. Caso o Mocha passe o argumento done(), ele precisa ser chamado, ou emitirá um erro assim:

1) saveToFile()
       should save a single TODO:
     Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/home/ubuntu/todos/index.test.js)
      at listOnTimeout (internal/timers.js:536:17)
      at processTimers (internal/timers.js:480:7)

Ao testar Promessas, não inclua o callback done() no it().

Para testar nossa promessa, precisamos colocar nosso código de asserção na função then(). Note que retornamos essa promessa no teste e que não temos uma função catch() para capturar quando a Promessa é rejeitada.

Retornaremos a promessa, para que quaisquer erros que forem lançados na função then() sejam borbulhados até a função it(). Caso os erros não sejam borbulhados, o Mocha não reprovará o caso de teste. Ao testar Promessas, é necessário usar o return na Promessa que está sendo testada. Caso contrário, existe o risco de se obter um falso positivo.

Também omitimos a cláusula catch(), porque o Mocha pode detectar quando uma promessa é rejeitada. Caso tenha sido rejeitada, ele reprova automaticamente o teste.

Agora que temos nosso teste funcionando, salve e saia do arquivo. Em seguida, execute o Mocha com o npm test para confirmar se recebemos um resultado bem-sucedido:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (18ms)

Alteramos nosso código e teste para usar Promessas e agora sabemos que ele funciona. No entanto, os padrões assíncronos mais recentes utilizam as palavras-chave async/await. Isso é feito para que não precisemos criar várias funções then() para lidar com resultados bem-sucedidos. Vamos ver como testar com async/await.

async/await

As palavras-chave async/await tornam o trabalho com as Promessas menos prolixo. Assim que definimos uma função como assíncrona com a palavra-chave async, podemos obter quaisquer resultados futuros nessa função com a palavra-chave await. Desta maneira, podemos usar as Promessas sem precisar usar as funções then() ou catch().

Podemos simplificar nosso teste saveToFile(), que é baseado em promessas, com async/await. No seu editor de texto, faça essas pequenas edições no teste saveToFile(), dentro de index.test.js:

todos/index.test.js
...
describe("saveToFile()", function() {
    it("should save a single TODO", async function() {
        let todos = new Todos();
        todos.add("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

A primeira mudança é que a função utilizada pela função it() agora tem a palavra-chave async quando é definida. Isso nos permite usar a palavra-chave await dentro do seu corpo.

A segunda mudança é encontrada quando chamamos saveToFile(). A palavra-chave await é usada antes de ser chamada. Agora, o Node.js sabe que precisa esperar até que essa função seja resolvida antes de continuar o teste.

Agora, o código da nossa função é mais fácil de se ler, uma vez que mudamos o código que estava na função then() para o corpo da função it(). Executar este código com o npm test produz este resultado:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO 3 passing (30ms)

Agora, podemos testar as funções assíncronas usando qualquer um dos três paradigmas assíncronos adequadamente.

Abordamos bastante conteúdo sobre realizar testes com o código síncrono e assíncrono com o Mocha. Em seguida, vamos ir um pouco mais fundo em algumas outras funcionalidades que o Mocha oferece para melhorar nossa experiência de teste. Em particular, como os ganchos podem mudar os ambientes de teste.

Passo 5 — Usando hooks (ganchos) para melhorar os casos de teste

Os ganchos são uma funcionalidade útil do Mocha que nos permite configurar o ambiente antes e após um teste. Normalmente, adicionamos ganchos dentro de um bloco de função describe(), já que eles possuem lógica de configuração e desmontagem específicos para alguns casos de teste.

O Mocha provisiona quatro ganchos que podemos usar em nossos testes:

  • before: esse gancho é executado uma vez antes do início do teste.
  • beforeEach: este gancho é executado antes de todos os casos de teste.
  • after: este gancho é executado uma vez após o último caso de teste é concluído.
  • afterEach: este gancho é executado após todos os casos de teste.

Quando testamos uma função ou recurso várias vezes, os ganchos são úteis, uma vez que nos permitem separar o código de configuração do teste (assim como na criação do objeto todos) do código de declaração do teste.

Para ver o valor dos ganchos, vamos adicionar mais testes ao nosso bloco de teste saveToFile().

Apesar de termos confirmado que podemos salvar nossos itens de AFAZERES em um arquivo, salvamos apenas um item. Além disso, o item não foi marcado como concluído. Vamos adicionar mais testes para ter certeza de que os vários aspectos do nosso módulo funcionam.

Primeiro, vamos adicionar um segundo teste para confirmar que nosso arquivo é salvo corretamente quando concluímos um item de AFAZERES. Abra seu arquivo index.test.js no editor de texto:

  • nano index.test.js

Troque o último teste pelo seguinte:

todos/index.test.js
...
describe("saveToFile()", function () {
    it("should save a single TODO", async function () {
        let todos = new Todos();
        todos.add("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });

    it("should save a single TODO that's completed", async function () {
        let todos = new Todos();
        todos.add("save a CSV");
        todos.complete("save a CSV");
        await todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,true\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

O teste é semelhante ao que tínhamos anteriormente. As principais diferenças são que chamamos a função complete() antes de chamar saveToFile(), e que nosso expectedFileContents tem agora agora true, ao invés de false para o valor completed da coluna.

Salve e saia do arquivo.

Vamos executar nosso novo teste, e todos os outros, com o npm test:

  • npm test

Isso resultará no seguinte:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO ✓ should save a single TODO that's completed 4 passing (26ms)

Ele está funcionando conforme esperado. No entanto, há espaço para melhorias. Ambos precisam instanciar um objeto Todos no início do teste. À medida que adicionamos mais casos de teste, isso torna-se rapidamente repetitivo e um desperdício de memória. Além disso, sempre que executamos o teste, ele cria um arquivo. Isso pode ser confundido com o resultado real por alguém menos familiarizado com o módulo. Seria bom se limpássemos nossos arquivos do resultado após os testes.

Vamos fazer essas melhorias usando ganchos de teste. Vamos usar o gancho beforeEach() para configurar nosso acessório de teste dos itens de AFAZERES. Um acessório de teste é qualquer estado consistente usado em um teste. Em nosso caso, nosso acessório de teste é um novo objeto todos que tem um item de AFAZERES já adicionado nele. Depois disso, usaremos o afterEach() para remover o arquivo criado pelo teste.

No index.test.js, faça as seguintes alterações no seu último teste em saveToFile():

todos/index.test.js
...
describe("saveToFile()", function () {
    beforeEach(function () {
        this.todos = new Todos();
        this.todos.add("save a CSV");
    });

    afterEach(function () {
        if (fs.existsSync("todos.csv")) {
            fs.unlinkSync("todos.csv");
        }
    });

    it("should save a single TODO without error", async function () {
        await this.todos.saveToFile();

        assert.strictEqual(fs.existsSync("todos.csv"), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,false\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });

    it("should save a single TODO that's completed", async function () {
        this.todos.complete("save a CSV");
        await this.todos.saveToFile();

        assert.strictEqual(fs.existsSync('todos.csv'), true);
        let expectedFileContents = "Title,Completed\nsave a CSV,true\n";
        let content = fs.readFileSync("todos.csv").toString();
        assert.strictEqual(content, expectedFileContents);
    });
});

Vamos detalhar todas as alterações que fizemos. Adicionamos um bloco de beforeEach() no bloco de teste:

todos/index.test.js
...
beforeEach(function () {
    this.todos = new Todos();
    this.todos.add("save a CSV");
});
...

Essas duas linhas de código criam um novo objeto Todos que ficará disponível em cada um dos nossos testes. Com o Mocha, o objeto this em beforeEach() refere-se ao mesmo objeto this no it(). O this é o mesmo para todos os blocos de código dentro do bloco describe(). Para obter mais informações sobre o this, consulte nosso tutorial Entendendo this, bind, call e apply no JavaScript.

Este poderoso compartilhamento de contexto é o motivo pelo qual podemos criar rapidamente os acessórios de teste que funcionam para ambos os nossos testes.

Depois disso, limparemos nosso arquivo CSV na função afterEach():

todos/index.test.js
...
afterEach(function () {
    if (fs.existsSync("todos.csv")) {
        fs.unlinkSync("todos.csv");
    }
});
...

Caso nosso teste tenha falhado, então ele pode não ter criado um arquivo. Por esse motivo, verificamos se o arquivo existe antes de usar a função unlinkSync() para excluí-lo.

As alterações remanescentes trocam a referência dos todos, que foram criados anteriormente na função it(), para this.todos, que está disponível no contexto do Mocha. Também excluímos as linhas que instanciaram os todos anteriormente nos casos de teste individuais.

Agora, vamos executar este arquivo para confirmar se nossos testes ainda funcionam. Insira o npm teste em seu terminal para obter:

Output
... integrated test ✓ should be able to add and complete TODOs complete() ✓ should fail if there are no TODOs saveToFile() ✓ should save a single TODO without error ✓ should save a single TODO that's completed 4 passing (20ms)

Os resultados são os mesmos e, como um benefício, reduzimos ligeiramente o tempo de configuração dos novos testes para a função saveToFile(). Além disso, encontramos uma solução para o arquivo CSV residual.

Conclusão

Neste tutorial, você escreveu um módulo do Node.js para gerenciar os itens de AFAZERES e testou o código manualmente usando o REPL do Node.js. Depois disso, criou um arquivo de teste e usou o framework do Mocha para executar testes automatizados. Com o módulo assert, você foi capaz de verificar se seu código funciona. Você também testou funções síncronas e assíncronas com o Mocha. Por fim, criou ganchos com o Mocha que fazem com que escrever vários casos de teste relacionados seja muito mais legível e sustentável.

Equipado com este conhecimento, desafio você a escrever testes para novos módulos do Node.js que está criando. Consegue pensar nas entradas e resultados da sua função e escrever seu teste antes de escrever seu código?

Caso queira mais informações sobre o framework de teste do Mocha, confira a documentação oficial do Mocha. Caso queira continuar aprendendo sobre o Node.js, volte para a página da série Como codificar em Node.js.

0 Comments

Creative Commons License