Desenvolvimento guiado por testes
Agora que vamos começar a desenvolver nossa aplicação precisamos garantir que a responsabilidade, as possíveis rotas, as requisições e as respostas estão sendo atendidas; que estamos entregando o que prometemos e que está tudo funcionando. Para isso, vamos seguir um modelo conhecido como TDD (Test Driven Development ou Desenvolvimento Guiado por Testes).
TDD - Test Driven Development
O TDD é um processo de desenvolvimento de software que visa o feedback rápido e a garantia de 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 em torno do tema. Grandes figuras da comunidade ágil como Martin Fowler também influenciaram na adoção dessa prática publicando artigos, ministrando palestras e compartilhando cases de sucesso.
Os ciclos do TDD
Quando desenvolvemos guiados por testes, o teste acaba se tornando uma consequência do processo, ja 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. Essa fase também serve para verificar se não há erros na sintáxe e na semântica.
Green
Refere-se a etapa em que a funcionalidade é adicionada para que o teste passe. Nesse momento ainda 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. Como o teste já foi validado nos passos anteriores, o refactor garantirá que a funcionalidade está sendo implementada corretamente. Nesse momento devem ser removidos os dados estáticos e todos itens adicionadas apenas para forçar o teste a passar, em seguida deve ser feita a implementação real para que o teste volte a passar. A imagem abaixo representa o ciclo do TDD:
A pirâmide de testes
A pirâmide de testes é um conceito criado por Mike Cohn, escritor do livro Succeeding with Agile. O livro propõe que hajam mais testes de baixo nível, ou seja, testes de unidade, depois testes de integração e no topo os testes que envolvem interface.
O autor observa que os testes de interface são custosos, para alguns testes é necessário inclusive licença de softwares. Apesar de valioso, esse tipo de teste necessita da preparação de todo um ambiente para rodar e tende a ocupar 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 testes
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. 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.
Mas 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 sí 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 unidade 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 ilustra a dependência:
Para testar unitariamente é necessário isolar a classe Billing da sua dependência, a classe Orders, como na imagem a seguir:
Esse isolamento pode ser feito de diversas maneiras, por exemplo utilizando mocks ou stubs 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á ocorrendo conforme o esperado. Diferente dos testes de unidade, onde 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 as rotas, controllers, models e banco de dados:
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:
Esse teste é custoso porém imprescindível. 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, como por exemplo: o usuário mandou um dado errado e deve receber um erro de validação. Para esse tipo de cenário pode ser 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 válidação deve emitir um erro, poderíamos testar a integração entre o controller e o model, como no exemplo a seguir:
Nesse exemplo todos os componentes do sistema são facilmente desacopláveis, podem haver casos onde 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.
Teste de integração de contrato (Integration contract tests)
Testes de contrato ganharam muita força devido ao crescimento das APIs e dos micro serviç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 por que 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 e 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.
É 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, nesse momento o contrato deve quebrar.
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.
Test Doubles
Testar código com ajax, network, timeouts, banco de dados e outras dependências que produzem efeitos colaterais é sempre complicado. Por exemplo, quando se usa ajax, ou qualquer outro tipo de networking, é necessário comunicar com um servidor que irá responder para a requisição; já com o banco de dados será necessário inicializar um serviço para tornar possível o teste da aplicação: limpar e criar tabelas para executar os testes e etc.
Quando as unidades que estão sendo testadas possuem dependências que produzem efeitos colaterais, como os exemplos acima, não temos garantia de que a unidade está sendo testada isoladamente. Isso abre espaço para que o teste quebre por motivos não vinculados a unidade em sí, como por exemplo o serviço de banco não estar disponível ou uma API externa retornar uma resposta diferente da esperada no teste.
Há alguns anos atrás Gerard Meszaros publicou o livro XUnit Test Patterns: Refactoring Test Code e introduziu o termo Test Double (traduzido como “dublê de testes”) que nomeia as diferentes maneiras de substituir dependências. A seguir vamos conhecer os mais comuns test doubles e quais são suas características, prós e contras.
Para facilitar a explicação será utilizado o mesmo exemplo para os diferentes tipos de test doubles, também será usada uma biblioteca de suporte chamada Sinon.js que possibilita a utilização de stubs, mocks e spies.
A controller abaixo é uma classe que recebe um banco de dados como dependência no construtor. O método que iremos testar unitariamente dessa classe é o método getAll, ele retorna uma consulta do banco de dados com uma lista de usuários.
1 const Database = {
2 findAll() {}
3 }
4
5 class UsersController {
6 constructor(Database) {
7 this.Database = Database;
8 }
9
10 getAll() {
11 return this.Database.findAll('users');
12 }
13 }
Fake
Durante o teste, é frequente a necessidade de substituir uma dependência para que ela retorne algo específico, independente 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 Fakes. Fakes podem ser classes, objetos ou funções que possuem uma resposta fixa independente da maneira que forem chamadas. O exemplo abaixo mostra como testar a classe UsersController usando um fake:
1 describe('UsersController getAll()', () => {
2 it('should return a list of users', () => {
3 const expectedDatabaseResponse = [{
4 id: 1,
5 name: 'John Doe',
6 email: 'john@mail.com'
7 }];
8
9 const fakeDatabase = {
10 findAll() {
11 return expectedDatabaseResponse;
12 }
13 }
14 const usersController = new UsersController(fakeDatabase);
15 const response = usersController.getAll();
16
17 expect(response).to.be.eql(expectedDatabaseResponse);
18 });
19 });
Nesse caso de teste não é necessária nenhuma biblioteca de suporte, tudo é feito apenas criando um objeto fake para substituir a dependência do banco de dados. O método findAll passa a ter uma resposta fixa, que é uma lista com um usuário.
Para validar o teste é necessário verificar se a resposta do método getAll do controller responde com uma lista igual a declarada no expectedDatabaseResponse.
Vantagens:
- Simples de escrever
- Não necessita de bibliotecas de suporte
- Desacoplado da dependencia original
Desvantagens:
- Não possibilita testar múltiplos casos
- Só é possível testar se a saída está como esperado, não é possível validar o comportamento interno da unidade
Quando usar fakes:
Fakes devem ser usados para testar dependências que não possuem muitos comportamentos.
Spy
Como vimos anteriormente os fakes permitem substituir uma dependência por algo customizado mas não possibilitam saber, por exemplo, quantas vezes uma função foi chamada, quais parâmetros ela recebeu e etc. Para isso existem os spies, como o próprio nome já diz, eles gravam informações sobre o comportamento do que está sendo “espionado”.
No exemplo abaixo é adicionado um spy no método findAll do Database para verificar se ele está sendo chamado com os parâmetros corretos:
1 describe('UsersController getAll()', () => {
2 it('should database findAll with correct parameters', () => {
3 const findAll = sinon.spy(Database, 'findAll');
4
5 const usersController = new UsersController(Database);
6 usersController.getAll();
7
8 sinon.assert.calledWith(findAll, 'users');
9 findAll.restore();
10 });
11 });
Note que é adicionado um spy na função findAll do Database, dessa maneira o Sinon devolve uma referência a essa função e também adiciona alguns comportamentos a ela que possibilitam realizar checagens como sinon.assert.calledWith(findAll, 'users') onde é verificado se a função foi chamada com o parâmetro esperado.
Vantagens:
- Permite melhor assertividade no teste
- Permite verificar comportamentos internos
- Permite integração com dependências reais
Desvantagens:
- Não permitem alterar o comportamento de uma dependência
- Não é possível verificar múltiplos comportamentos ao mesmo tempo
Quando usar spies:
Spies podem ser usados sempre que for necessário ter assertividade de uma dependência real ou, como em nosso caso, em um fake. Para casos onde é necessário ter muitos comportamos é provável que stubs e mocks venham melhor a calhar.
Stub
Fakes e spies são simples e substituem uma dependência real com facilidade, como visto anteriormente, porém, quando é necessário representar mais de um cenário para a mesma dependência eles podem não dar conta. Para esse cenário entram na jogada os Stubs. Stubs são spies que conseguem mudar o comportamento dependendo da maneira em que forem chamados, veja o exemplo abaixo:
1 describe('UsersController getAll()', () => {
2 it('should return a list of users', () => {
3 const expectedDatabaseResponse = [{
4 id: 1,
5 name: 'John Doe',
6 email: 'john@mail.com'
7 }];
8
9 const findAll = sinon.stub(Database, 'findAll');
10 findAll.withArgs('users').returns(expectedDatabaseResponse);
11
12 const usersController = new UsersController(Database);
13 const response = usersController.getAll();
14
15 sinon.assert.calledWith(findAll, 'users');
16 expect(response).to.be.eql(expectedDatabaseResponse);
17 findAll.restore();
18 });
19 });
Quando usamos stubs podemos descrever o comportamento esperado, como nessa parte do código:
1 findAll.withArgs('users').returns(expectedDatabaseResponse);
Quando a função findAll for chamada com o parâmetro users, retorna a resposta padrão.
Com stubs é possível ter vários comportamentos para a mesma função com base nos parâmetros que são passados, essa é uma das maiores diferenças entre stubs e spies.
Como dito anteriormente, stubs são spies que conseguem alterar o comportamento. É possível notar isso na asserção sinon.assert.calledWith(findAll, 'users') ela é a mesma asserção do spy anterior. Nesse teste são feitas duas asserções, isso é feito apenas para mostrar a semelhança com spies, múltiplas asserções em um mesmo caso de teste é considerado uma má prática.
Vantagens:
- Comportamento isolado
- Diversos comportamentos para uma mesma função
- Bom para testar código assíncrono
Desvantagens:
- Assim como spies não é possível fazer múltiplas verificações de comportamento
Quando usar stubs:
Stubs são perfeitos para utilizar quando a unidade tem uma dependência complexa, que possui múltiplos comportamentos. Além de serem totalmente isolados os stubs também tem o comportamento de spies o que permite verificar os mais diferentes tipos de comportamento.
Mock
Mocks e stubs são comumente confundidos pois ambos conseguem alterar comportamento e também armazenar informações. Mocks também podem ofuscar a necessidade de usar stubs pois eles podem fazer tudo que stubs fazem. O ponto de grande diferença entre mocks e stubs é sua responsabilidade: stubs tem a responsabilidade de se comportar de uma maneira que possibilite testar diversos caminhos do código, como por exemplo uma resposta de uma requisição http ou uma exceção; Já os mocks substituem uma dependência permitindo a verificação de múltiplos comportamentos ao mesmo tempo. O exemplo a seguir mostra a classe UsersController sendo testada utilizando Mock:
1 describe('UsersController getAll()', () => {
2 it('should call database with correct arguments', () => {
3 const databaseMock = sinon.mock(Database);
4 databaseMock.expects('findAll').once().withArgs('users');
5
6 const usersController = new UsersController(Database);
7 usersController.getAll();
8
9 databaseMock.verify();
10 databaseMock.restore();
11 });
12 });
A primeira coisa a se notar no código é a maneira de fazer asserções com Mocks, elas são descritas nessa parte:
1 databaseMock.expects('findAll').once().withArgs('users');
Nela são feitas duas asserções, a primeira para verificar se o método findAll foi chamado uma vez e na segunda se ele foi chamado com o argumento users, em seguida o código é executado e é chamada a função verify() do Mock que irá verificar se as expectativas foram atingidas.
Vantagens:
- Verificação interna de comportamento
- Diversas asserções ao mesmo tempo
Desvantagens:
- Diversas asserções ao mesmo tempo podem tornar o teste difícil de entender.
Quando usar mocks:
Mocks são úteis quando é necessário verificar múltiplos comportamentos de uma dependência. Isso também pode ser sinal de um design de código mal pensado, onde a unidade tem muita responsabilidade. É necessário ter muito cuidado ao usar Mocks já que eles podem tornar os testes pouco legíveis.
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 suites de testes separados, o que nos permite escolher a melhor combinação para a nossa necessidade (mas também pode criar confusão). Em primeiro lugar 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 executar os arquivos de teste e fazer o assert nem sempre é o suficiente. Pode ser necessário substituir dependências, subir servidores fake, alterar o DOM e etc. Para isso existem as bibliotecas de suporte. As bibliotecas de suporte se separam em diversas responsabilidades, como por exemplo: para fazer mocks e spys temos o SinonJS e o TestDoubleJS já para emular servidores existe o supertest.