Desenvolvimento

21 nov, 2016

Desenvolvimento guiado por testes em JavaScript

Publicidade

Estou escrevendo o livro Construindo APIs testáveis com Node.js, e testes serão uma das coisas mais importantes do conteúdo. Para isso, vamos seguir um modelo conhecido como TDD (Test Driven Development ou Desenvolvimento Guiado por Testes).

Test Driven Development – TDD

O TDD é um processo de desenvolvimento de software que visa ao feedback rápido e garante que o comportamento da aplicação está cumprindo o que é requerido. Para isso, o processo funciona em ciclos pequenos, e os requerimentos são escritos como casos de teste.

A prática do TDD aumentou depois que Kent Beck publicou o livro TDD – Test Driven Development e fomentou a discussão. Grandes figuras da comunidade ágil como Martin Fowler também influenciaram na adoção dessa prática publicando artigos, palestras e cases de sucesso.

Os ciclos do TDD

Quando desenvolvemos guiados por testes, o teste acaba se tornando uma consequência do processo, já que vai ser ele que vai determinar o comportamento esperado da implementação. Para que seja possível validar todas as etapas, o TDD se divide em ciclos que seguem um padrão conhecido como: Red, Green, Refactor.

RED

Significa escrever o teste antes da funcionalidade e executá-lo; nesse momento, como a funcionalidade ainda não foi implementada, o teste deve quebrar, pois se não há algo errado nele, essa fase também serve para verificar se não há erros na sintaxe e na semântica.

Green

Refere-se à etapa em que a funcionalidade é adicionada para que o teste passe. Nesse momento, não é necessário ter a lógica definida, mas é importante atender aos requerimentos do teste. Aqui podem ser deixados to-dos, dados estáticos, fixmes, ou seja, o suficiente para o teste passar.

Refactor

É onde se aplica a lógica necessária, e como o teste já foi validado nos passos anteriores ele garantirá que a funcionalidade está sendo implementada corretamente. Nesse momento, devem ser removidos os dados estáticos, além de coisas adicionadas somente para que o teste passasse, e ser feita a implementação real até que o teste volte a passar.

A imagem abaixo representa o ciclo do TDD:

tdd-1

A pirâmide de testes

A pirâmide de testes é um conceito criado por Mike Cohn, autor do livro Succeeding with Agile. A obra propõe que haja mais testes de baixo nível, ou seja: testes de unidade, depois testes de integração e, no topo, testes que envolvem interface.

tdd-2

O autor observa que os testes de interface são custosos, para alguns testes é necessário inclusive licença de softwares que permitam a gravação dos passos e depois a execução do playback para ter a resposta do teste. Apesar de valioso, esse tipo de teste necessita de todo o ambiente para rodar e tende a demorar muito tempo.

O que Mike defende é ter a base do desenvolvimento com uma grande cobertura de testes de unidade; no segundo nível, garantir a integração entre os serviços e componentes com testes de integração, sem precisar envolver a interface do usuário. E, no topo, possuir testes que envolvam o fluxo completo de interação com a UI, para validar todo o fluxo.

Vale lembrar que testes de unidade e integração podem ser feitos em qualquer parte da aplicação, tanto no lado do servidor quanto no lado do cliente; isso elimina a necessidade de ter testes complexos envolvendo todo o fluxo.

Os tipos de teste

Atualmente, contamos com uma variada gama de testes, sempre em crescimento de acordo com o surgimento de novas necessidades. Os mais comuns são os teste de unidade e integração, nos quais iremos focar aqui.

Testes de unidade (Unit tests)

Testes de unidade são a base da pirâmide de testes e possivelmente os mais comuns; ainda assim, existem muitas pessoas que confundem o termo e as responsabilidades do mesmo. Segundo Martin Fowler, testes unitários são de baixo nível, com foco em pequenas partes do software e tendem a ser mais rapidamente executados quando comparados com outros testes, pois testam partes isoladas.

O primeiro ponto que deve ficar claro é: o que é uma unidade, afinal? Esse conceito é divergente e pode variar de projeto, linguagem, time e paradigma de programação. Linguagens orientadas a objeto tendem a ter classes como uma unidade, já linguagens procedurais ou funcionais consideram normalmente funções como sendo uma unidade. Essa definição é algo muito relativo e depende do contexto e do acordo dos desenvolvedores envolvidos no processo. Nada impede que um grupo de classes relacionadas entre si ou funções sejam uma unidade.

No fundo, o que define uma unidade é o comportamento e a facilidade de ser isolada das suas dependências (dependências podem ser classes ou funções que tenham algum tipo de interação com a unidade).

Digamos que, por exemplo, decidimos que as nossas unidades serão as classes e estamos testando uma função da classe Billing que depende de uma função da classe Orders. A imagem abaixo mostra a dependência:

tdd-3

Para testar unitariamente, é necessário isolar a classe Billing da sua dependência, a classe Orders, como na imagem a seguir:

tdd-4

Esse isolamento pode ser feito de diversas maneiras, por exemplo utilizando mocks, stubs, spys ou qualquer outra técnica de substituição de dependência e comportamento. O importante é que seja possível isolar a unidade e ter o comportamento esperado da dependência.

Testes de integração (Integration tests)

Testes de integração servem para verificar se a comunicação entre os componentes de um sistema está funcionando conforme o esperado. Diferentemente dos testes de unidade, nos quais a unidade é isolada de duas dependências, no teste de integração deve ser testado o comportamento da interação entre as unidades.

Não há um nível de granularidade específico – a integração pode ser testada em qualquer nível, seja a interação entre camadas, classes ou até mesmo serviços.

No exemplo a seguir, temos uma arquitetura comum de aplicações Node.js e desejamos testar a integração entre rotas, controllers, models e banco de dados:

tdd-5

Nossa integração pode ser desde a rota até salvar no banco de dados (nesse caso, MongoDB); dessa maneira, é possível validar todo o fluxo até o dado ser salvo no banco, como na imagem a seguir:

tdd-6

Esse teste é imprescindível, mas custoso. Será necessário limpar o banco de dados a cada teste e criar os dados novamente, além de custar tempo e depender de um serviço externo como o MongoDB. Um grau de interação desse nível terá vários possíveis casos de teste – por exemplo, o usuário mandou um dado errado e deve receber um erro de validação; para esses tipos de cenário, às vezes é melhor diminuir a granularidade do teste para que seja possível ter mais casos de teste.

Para um caso onde o controller chama o model passando dados inválidos e a validação deve emitir um erro, poderíamos testar a integração entre o controller e o model, como no exemplo a seguir:

tdd-7

Nesse exemplo, todos os componentes do sistema são facilmente desacopláveis – pode haver casos em que o model depende diretamente do banco de dados, e como queremos apenas testar a validação não precisamos inserir nada no banco. Nesse caso, é possível substituir o banco de dados ou qualquer outra dependência por um mock ou stub para reproduzir o comportamento de um banco de dados sem realmente chamar o banco.

tdd-8

Teste de integração de contrato (Integration contract tests)

Testes de contrato ganharam muita força devido ao crescimento das APIs e dos microsserviços. Normalmente, quando testamos a nossa aplicação, mesmo com o teste de integração, tendemos a não usar os serviços externos, e sim um substituto que devolve a resposta esperada. Isso porque serviços externos podem afetar no tempo de resposta da requisição, podem cair, aumentar o custo, e isso pode afetar nossos testes.

Mas, por outro lado, quando isolamos nossa aplicação dos outros serviços para testar, ficamos sem garantia de que esses serviços não mudaram suas APIs, que a resposta esperada ainda é a mesma. Para solucionar esses problemas, existem os testes de contrato.

A definição de um contrato

Sempre que consumimos um serviço externo, dependemos de alguma parte dele ou de todos os dados que ele provém, e o serviço se compromete a entregar esses dados. O exemplo abaixo mostra um teste de contrato entre a aplicação e um serviço externo; nele é verificado se o contrato entre os dois ainda se mantém o mesmo.

tdd-9

É importante notar que o contrato varia de acordo com a necessidade. Nesse exemplo, a nossa aplicação depende apenas dos campos email e birthday, então o contrato formado entre eles verifica apenas isso. Se o name mudar, ele não quebrará nossa aplicação, nem o contrato que foi firmado. Em testes de contrato, o importante é o tipo e não o valor. No exemplo, verificamos se o email ainda é String e se o campo birthday ainda é do tipo Date; dessa maneira, garantimos que a nossa aplicação não vai quebrar. O exemplo a seguir mostra um contrato quebrado onde o campo birthday virou born, ou seja, o serviço externo mudou o nome do campo, e nesse momento o contrato deve quebrar.

tdd-10

Testes de contrato possuem diversas extensões, o caso acima é chamado de consumer contract, onde o consumidor verifica o contrato e, caso o teste falhe, notifica o provider (provedor) ou altera sua aplicação para o novo contrato. Também existe o provider contracts, onde o próprio provedor testa se as alterações feitas irão quebrar os consumidores.

O que são stubs, mocks, spys

Nos exemplos de testes anteriores, vimos que em algum momento será necessário substituir uma dependência real por algo que reproduza um comportamento esperado, seja substituir uma classe para poder testar unitariamente, seja substituir o banco de dados por algo que retorne sempre a mesma resposta.

Para fazer isso, existem várias técnicas e ferramentas. Aqui, vamos nos aprofundar nas três mais conhecidas (e também mais confundidas) e que se encaixam nas mais diversas necessidades.

Mock

Quando testamos, é frequente a necessidade de substituir uma dependência para que ela retorne algo específico, independentemente de como for chamada, com quais parâmetros, quantas vezes, a resposta sempre deve ser a mesma. Nesse momento, a melhor escolha são os mocks. Mocks podem ser classes, objetos ou funções fakes que possuem uma resposta fixa, independentemente da maneira como são chamadas, como no exemplo abaixo.

class NameGenerator {
 constructor(parser) {
 
  this.parser = parser;
 
 }
 
 
 generate(text) {
 
  return this.parser.parse(text);
 
 }
}

Teste para verificar se o método generate está cumprindo o esperado:

it('should check if the generator is generating correctly', () => {
 const parserMock = {
  parse (text) {
   return "expected result";
  }
 };
 
 const nameGenerator = new NameGenerator(parserMock);
 expect(nameGenerator.generate('test')).to.equal("expected result");
});

Note que o método parse vai retornar a mesma coisa, independentemente da maneira como for chamado – isso o caracteriza um mock.

Stub

Como visto, mocks são simples e substituem uma dependência real com facilidade, porém, quando é necessário representar mais de um cenário para a mesma dependência, eles podem não dar conta. Para isso entram na jogada os stubs. Stubs são semelhantes aos mocks, só que um pouco mais inteligentes, já que possuem comportamentos diferentes, dependendo da maneira como são chamados, como no exemplo a seguir:

it('should check if the generator is generating correctly', () => {
 const parserStub = {
  parse: sinon.stub()
 };
 
 parserStub.withArgs('test').returns('parsed test');
 parserStub.withArgs('another test').returns('parsed another test');
 
 const nameGenerator = new NameGenerator(parserStub);
 expect(nameGenerator.generate('test')).to.equal('parsed test');
 expect(nameGenerator.generate('another test')).to.equal('parsed another test');
});

Para criarmos um stub, vamos precisar de alguma biblioteca; nesse caso, optei pelo SinonJS – ele possui stubs e spys já por padrão. No exemplo acima, digo que a função parse agora é um stub, e abaixo determino os comportamentos esperados para cada tipo de chamada. Assim, é possível remover a dependência de usar a classe Parser original e conseguimos ter comportamentos que variam conforme a função é chamada e podemos testar a função generate unitariamente.

Spy

Relembrando: mocks respondem sempre a mesma coisa quando são chamados; stubs conseguem responder coisas diferentes, dependendo da maneira como são chamados, e ambos são classes, objetos ou funções fakes. Porém, há casos em que queremos que somente um método de uma classe tenha um comportamento fixo, e os demais devem ter seu comportamento original. Para isso, vamos usar os spys. Spys são a dependência original com algum comportamento fake; isso permite usar os comportamentos originais da classe e simular outros. O exemplo abaixo mostra um spy:

it('should check if the generator is generating correctly', () => {
 sinon.spy(Parser, 'parse');
 
 
 const nameGenerator = new NameGenerator(Parser);
 nameGenerator.generate('test');
 expect(Parser.parse.getCall(0).args[0]).to.equal('test');
});

Seguimos usando o Sinon, agora para criar o spy. Diferente do stub, o spy usa a classe e a função original, e apenas adiciona algumas coisas ao objeto original, permitindo ao sinon saber como esse objeto se comportou durante o fluxo.

Note que o expect do teste agora verifica se a função foi chamada com os argumentos esperados. Nesse cenário, não estamos testando a classe NameGenerator unitariamente, pois estamos usando a classe Parser original e chamando a função.

O ambiente de testes em JavaScript

Diferente de muitas linguagens que contam com ferramentas de teste de forma nativa ou possuem algum xUnit (JUnit, PHPUnit etc.), no JavaScript temos todos os componentes das suítes de testes separados, o que nos permite escolher a melhor combinação para a nossa necessidade (mas também pode criar confusão).

Para isso, precisamos conhecer os componentes que fazem parte de uma suíte de testes em JavaScript:

Test runners

Test runners são responsáveis por importar os arquivos de testes e executar os casos de teste. Eles esperam que cada caso de teste devolva true ou false. Alguns dos test runners mais conhecidos de JavaScript são o Mocha e o Karma.

Bibliotecas de assert

Alguns test runners possuem bibliotecas de assert por padrão, mas é bem comum usar uma externa. Bibliotecas de assert verificam se o teste está cumprindo com o determinado, fazendo a afirmação e respondendo com true ou false para o runner. Algumas das bibliotecas mais conhecidas são o chai e o assert.

Bibliotecas de suporte

Somente rodar os arquivos de teste e fazer o assert nem sempre basta, é necessário substituir dependências, subir servidores fake, alterar o DOM e várias outras coisas. Para isso, existem as bibliotecas de suporte, elas se separam em diversas responsabilidades – para fazer mocks e spys, temos SinonJS e TestDoubleJS. Já para emular servidores existe, o supertest.

Espero ter clarificado como o TDD funciona e quais são as ferramentas para começar a praticar a técnica a partir de agora.

Até o próximo.

Esse artigo fará parte do livro Construindo APIs testáveis com Node.js.