The source content for blog.juliobiason.me
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

441 lines
19 KiB

+++
title = "Devaneios Sobre Testes"
date = 2020-01-13
[taxonomies]
tags = ["testes", "testes de integração", "testes unitários", "companion post"]
+++
Hoje em dia, boa parte dos desenvolvedores utiliza alguma metodologia de
testes. Mas o que são os testes? Para que servem? Qual o objetivo de se
testar? Estamos testando as coisas certas?
<!-- more -->
{% note() %}
Esse post acompanha a minha apresentação de [Filosofando Sobre
Testes](https://presentations.juliobiason.me/filosofando-testes.html).
{% end %}
Antes de começar, alguns avisos:
1. **Eu sou não ortodoxo com relação a testes**. Com isso eu quero dizer que
muitas das coisas que eu vou comentar aqui são exatamente contrárias do que
todo mundo fala e da forma como muitos trabalham com testes.
2. De forma alguma, considere esse conteúdo como regras. O que eu quero é que
as pessoas parem de sair criando testes sem saber porque estão fazendo
esses testes.
3. Ainda, de forma alguma você precisa concordar com alguma coisa aqui. De
novo, a ideia é parar para pensar no que está sendo testado antes de sair
testando.
Agenda de coisas que eu vou comentar:
1. TDD no estilo Kent Beck;
2. "Fast Tests, Slow Tests";
3. Explosão de Testes Lentos;
4. Coverage;
5. Mocking.
## TDD no Estilo Kent Beck
O que me levou a repensar a forma como eu escrevia testes foi um vídeo do Ian
Cooper chamado ["TDD, where it all go wrong"](https://vimeo.com/68375232)
("TDD, aonde é que a coisa deu errado"). No vídeo, Cooper coloca que o livro que
Beck escreveu (que deu origem a toda a revolução do TDD) diz duas coisas:
1. Testes devem ser executados de forma isolada, nada mais, nada menos.
2. Evite testar detalhes de implementação, teste comportamentos.
O primeiro ponto é o que fala sobre "unit tests", significando "rodam de forma
isolada", no sentido em que um teste não depende de outro. Dessa forma, "unit
tests" seriam traduzidos como "testes unitários", não "testes de unidade" --
não há "unidade", o teste em si é uma unidade única que não depende de outras
coisas.
O segundo ponto é que deve ser testado o comportamento, não a implementação.
Esse é um ponto que eu vejo falhar um bocado quando pensamos em testar todo e
qualquer classe e/ou função: E se o comportamento esperado é a combinação de
duas classes? Vale a pena escrever testes para as duas, sendo que a questão de
separar em duas classes diferentes (ou duas funções diferentes) é apenas uma
questão de implementação/simplicidade de código?
Ainda, outro questionamento sobre testar todas as funções e todas as classes:
o que sabemos de uma aplicação são os canais de entrada -- que pode ser por um
botão em uma interface gráfica, um texto digitado na linha de comando ou uma
requisição web -- e os canais de saída; assim, o _comportamento_ esperado é
"dado essa entrada pelo canal de entrada, quero ter essa saída", e qualquer
coisa no meio é implementação. E para fazer a transformação de uma
entrada para uma saída específica, eu posso precisar de mais de uma função
e/ou classe; se eu estiver testando cada uma das funções, eu estou realmente
testando o comportamento ou a implementação?
"Mas isso é muito parecido com BDD!", você deve estar pensando. Cooper coloca
isso no vídeo acima: Como a ideia de "testar cada função/classe" se tornou a
norma do TDD, a questão do comportamento teve que ser colocado em outro
formato, o que deu origem ao ATDD (Acceptance-Test Driven Development,
Desenvolvimento Guiado por Testes de Aceitação) e BDD (Behaviour Driven
Development, Desenvolvimento Guiado por Comportamentos).
Um exemplo de testes de comportamento: No Subreddit do Django, foi criada
uma pergunta: [Devo Escrever Testes Para os Tipos Built-In do
Django?](https://www.reddit.com/r/django/comments/5bearg/should_i_write_unit_tests_for_djangos_built_in/)
A questão se resume ao seguinte: Sabendo que no Django eu tenho tipos
definidos para meus dados no banco, e a partir dessas definições eu posso
criar formulários para colocar nos meus templates e esses formulários também
servem para validar os dados de entrada; assim, se eu defini que há um campo
no meu banco chamado "Ano de nascimento" -- que só pode receber números
inteiros -- e eu crio o formulário a partir do banco, coloco no meu template,
recebo os dados de volta e o próprio Django vai garantir, pelo tipo do dado no
banco, que o valor do campo é um número inteiro. Eu ainda preciso escrever um
teste para isso?
A resposta, no entanto, está em dar um passo atrás e fazer a seguinte
pergunta: _Por que_ o ano é um inteiro? Obviamente, porque anos são definidos
como números[^1] e, portanto, o comportamento do campo foi definido bem antes do
campo ser adicionado na tabela. Ainda, imagine que, por algum acidente do
destino, eu precise guardar o ano como uma string[^2]; se o tipo foi alterado,
o _comportamento_ vai ser alterado também? Provavelmente não.
Quando eu ignorei que ano deve ser um número porque "o framework cuida disso
pra mim", eu ignorei o comportamento esperado por culpa da implementação.
E "teste comportamentos, não implementação".
Embora não factual, uma anedota: Num projeto, tínhamos um "gerenciador de
alarmes" onde, a partir de um evento, poderia ser gerado simplesmente um log,
ser gerado um log e enviado um sinal SNMP ou, dependendo da configuração do
usuário, log, SNMP e ativação de um LED no painel frontal do equipamento.
Assim, criamos um módulo com a geração do log, um módulo com o envio do SNMP e
um módulo com a ativação/desativação do LED. Embora tudo tivesse testes, nós
ainda não nos sentíamos tranquilos com aquilo. Foi quando sugeri que
escrevêssemos um teste que levantasse o serviço e enviasse os eventos e ver o
que acontecia. E, finalmente, os testes fizeram sentido. (Eu ainda vou fazer
referências a esses testes na parte de cobertura.)
## Fast Tests, Slow Tests
O contraponto do que eu comentei acima pode ser algo parecido com [Fast Tests,
Slow Tests](https://www.youtube.com/watch?v=RAxiiRPHS9k), do Gary Bernhardt.
Em resumo, nessa apresentação, Bernhardt comenta que mudaram os testes e que
agora era possível executar centenas de testes em menos de 1 segundo (um
exemplo mostra aproximadamente 600 testes em 1.5 segundos).
O que o Bernhardt sugere é escrever testes apenas para os models, sem conexão
com o banco ou controllers; testes de controllers sem conexão com os models ou
views; e testes de views sem controllers.
Soa familiar (principalmente em frameworks MVC que separam cada um destes em
classes separadas)?
Ainda sobre esses testes, Bernhardt aponta que essas "execuções rápidas"
ajudam os desenvolvedores a testar suas alterações rapidamente (ainda soa
familiar?) mas que esses testes não substituem os testes de integração.
Nesse ponto é preciso se perguntar: se são escritos testes que verificam a
execução de um controller de forma isolada do resto do sistema, mas ainda é
necessário escrever os (chamados) testes de integração para garantir que o
projeto está entregando o que foi prometido entregar, o que é que está
realmente sendo testado? A impressão que deixa é que o estilo de teste pregado
por Bernhardt está mais para _aderência à arquitetura_ do que um teste de
qualidade do sistema: Esse controller segue a estrutura de não ter conexões de
banco em si? Esse model tem apenas as funções relacionadas com o
armazenamento e recuperação dos dados, sem qualquer lógica? Se é isso, qual o
valor para meu usuário se o controller não faz nenhuma gravação no banco de
dados?
Não que eu acredite que testes desse tipo sejam desnecessários, mas eles
deixam a impressão que, a longo prazo, eles tendem a se parecerem,
estruturalmente, muito parecidos, enquanto que (assim chamados) testes de
integração tendem a dar mais retorno a longo prazo para a qualidade do
projeto: Testes que definem uma entrada e um resultado esperado tendem a
garantir que, a longo prazo, o funcionamento do sistema continuará sendo igual.
## Explosão de Testes Lentos
A primeira consideração que surge numa declaração como a de cima é que "testes
de integração são lentos e isso vai tornar os testes lentos e reduzir a
produtividade dos desenvolvedores."
Sim, testes de integração são lentos, principalmente porque há um bom trabalho
em criar o estado inicial esperado, todas as entradas conforme esperado pelo sistema
de I/O (de novo, interface gráfica, linha de comando, web), percorrer todo o
percurso do processamento e verificar a saída. E sim, esperar esse tempo de
execução pode acabar distraindo o desenvolvedor.
Entretanto, quando um desenvolvedor está trabalhando num tratamento de algum
dado, se for uma nova funcionalidade/comportamento esperado, obviamente um
teste desse comportamento deve ser criado; se é uma alteração de
comportamento, deve haver um teste do comportamento esperado e esse deve ser
corrigido. Executar _apenas_ esse teste é o suficiente? Não, mas já dá boas
indicações de que a funcionalidade está funcionando como prevista. Depois de
garantir que a funcionalidade está correta, o desenvolvedor pode executar a
suite de testes do elemento sendo alterado e deixar o resto para o CI.
Por exemplo, se eu estiver trabalhando numa funcionalidade nova de mostrar uma
mensagem de erro caso seja feito um pedido quando o produto pedido não exista
no estoque, eu tenho que escrever um novo teste que crie um produto, deixe-o
com uma quantidade 0 em estoque, faça o pedido de compra e verifique que houve
erro. Uma vez que esse teste confirme a funcionalidade, eu posso rodar os
demais testes de pedidos, e depois disso deixar o CI validar que eu não
quebrei nada no gerenciamento de estoque ou cadastro de clientes (por algum
motivo).
E note que provavelmente para fazer todas essas validações, eu ainda vou
precisar de várias funções/classes e testar cada uma em separado não garante a
funcionalidade, mas eu vou voltar a esse tópico quando tiver falando de
cobertura.
Isso me parece o mais complicado pois parece haver, ao mesmo tempo, uma
interface muito ruim das ferramentas de testes para executar suites de testes
(somente os testes de pedidos, no exemplo anterior) e preguiça em executar
apenas os testes da suite (é mais fácil chamar o comando que roda todos os
testes que lembrar do caminho específico da suite -- sem contar organização de
suites para isso).
## Coverage
Ao contrário de que muitos comentam por aí, eu realmente acredito que seja bem
viável chegar a 100% de cobertura de testes: Basta apagar código.
A ideia é bem simples, na verdade: Se meus testes testam o comportamento do
sistema, e eu estou garantindo que esses testes passam, qualquer coisa que não
tenha cobertura indica que o código não é necessário e que, portanto, pode ser
removido.
Entretanto, não é qualquer código que possa ser apagado. No exemplo do
gerenciador de alarmes, apesar dos "testes unitários" cobrirem todas as
funcionalidades, aconteceu que no "teste de integração" surgiu um bloco de
código que nunca era executado. Esse bloco era responsável por validar a
entrada de um dos módulos (garantindo que não seria possível enviar um SNMP
sem mensagem, por exemplo). Entretanto, ao examinar o código durante a
execução, nós percebemos que o módulo base já estava fazendo essa validação e
que o código de proteção do módulo jamais seria chamado. Obviamente, essa é
uma questão sobre qual dos dois testes deveria ser eliminado. Mas nós tínhamos
"código morto", considerado "vivo" porque um "teste unitário" estava passando
pelas duas validações.
Um exemplo mais prático. Imagine uma classe que guarde dados de clientes de
um serviço web de compras[^3]:
```python
class Client:
def __init__(self, name):
self.name = name
```
Depois de um tempo, surge um novo requisito: Um tal de "Zézinho"
está criando usuários sem parar, sem fazer compras, só pra sujar o banco;
devemos bloquear todos os cadastros que tenham como nome do usuário apenas um
nome.
Aí, pensando em SOLID[^4], o desenvolvedor altera o código para o seguinte:
```python
def _multiple_names(name):
split_names = name.split(' ')
return len(split_names) > 1
def _validate_name(name):
if not _multiple_names(name):
raise Exception("Invalid name")
return name
class Client:
def __init__(self, name):
self.name = _validate_name(name)
```
Agora o que acontece é que quando um cliente é criado, são passadas as
validações sobre o nome, e uma dessas é que o nome deve ter múltiplos
nomes[^5].
Nova funcionalidade, precisamos de novos testes, certo?
```python
import pytest
def test_single_name():
"""'Cher' não tem multiplos nomes."""
assert not _multiple_names('Cher')
def test_multiple_name():
"""'Julio Biason' tem múltiplos nomes."""
assert _multiple_names('Julio Biason')
def test_valid_name():
"""'Julio Biason' é um nome válido."""
_validate_name('Julio Biason')
def test_invalid_name():
"""'Cher' não é um nome válido e por isso levanta uma exceção."""
with pytest.raises(Exception):
_validate_name('Cher')
def test_client_error():
"""Se tentar criar uma conta com 'Cher', deve dar erro."""
with pytest.raises(Exception):
Client(name='Cher')
def test_client():
"""Uma conta com nome 'Julio Biason' deve funcionar."""
Client(name='Julio Biason')
```
E ao rodar os testes:
```
$ pytest --cov=client client.py
==== test session starts ====
plugins: cov-2.4.0
collected 6 items
client.py ......
---- coverage: platform linux, python 3.4.3-final-0 ----
Name Stmts Miss Cover
-------------------------------
client.py 25 0 100%
==== 6 passed in 0.11 seconds ====
```
100% de cobertura e funcionalidade implantada! O desenvolvedor se dá uns
tapinhas nas costas e vai pra casa.
Entretanto, durante a noite, acontece de um dos gerentes ser amigo da Xuxa,
que tentou fazer uma compra e não conseguiu. O desenvolvedor chega de manhã e
vê o email do gerente e sai a corrigir o código:
```python
class Client:
def __init__(self, name):
self.name = name
```
Pronto, não tem mais validação[^6] e agora a Xuxa pode comprar. Mas ao rodar
os testes:
```
==== FAILURES ====
____ test_client_error ____
def test_client_error():
with pytest.raises(Exception):
> Client(name='Cher')
E Failed: DID NOT RAISE <class 'Exception'>
client.py:37: Failed
==== 1 failed, 5 passed in 0.63 seconds ====
```
A, é claro! Agora Cher é um nome válido e o comportamento testado é invalido.
Vamos mudar o teste para o comportamento esperado para a Cher:
```python
def test_client_error():
"""Se tentar criar uma conta com 'Cher', deve funcionar."""
Client(name='Cher')
```
E rodando os testes de novo:
```
$ pytest --cov=client client.py
==== test session starts ====
rootdir: /home/jbiason/unitt, inifile:
plugins: cov-2.4.0
collected 6 items
client.py ......
---- coverage: platform linux, python 3.4.3-final-0 ----
Name Stmts Miss Cover
-------------------------------
client.py 24 0 100%
==== 6 passed in 0.12 seconds ====
```
Maravilhoso, tudo está funcionando com o comportamento esperado e ainda temos
100% de cobertura.
Mas você consegue ver onde está o problema desse código?
O problema é que `_multiple_names` não é mais usado em lugar algum, mas o
mesmo se mostra "vivo" porque um teste perdido continua indicando que o código
está vivo. Se tivéssemos começado com os testes de comportamento desde o
começo -- considerando entradas e saídas -- de cara veríamos que a função não
é mais necessária -- e se, num futuro, ela for... bom, é pra isso que sistemas
de controle de versão existem.
Embora esse possa parecer um exemplo bobo, existem outros casos em que o fluxo
de processamento dos dados pode ser alterado pelo próprio ambiente. Por
exemplo, no Django, é possível ter classes "middleware", que são capazes de
interceptar Requisições ou Respostas e alterar o resultado o mesmo. O exemplo
mais comum é o middleware de Autenticação, que adiciona informações do usuário
logado na Requisição; mas essas alterações podem ser mais profundas, como
alterar os dados do formulário. Nesses casos, a entrada (ou a
saída, ou ambos) é afetada e, portanto, escrever testes que ignorem os
middlewares não vão representar a entrada (ou saída, ou ambos) do sistema
corretamente. E aí podemos perguntar se o teste é válido por usar estados que
não existem naturalmente no sistema.
## Mocks
Há um tempo, eu indicava que "mocks" deveriam ser usados para coisas externas
ao sistema. Entretanto, eu percebi que essa definição não é bem precisa --
existem coisas externas que não devem ser mockadas -- e que uma definição
melhor para o que deve ser mockado é "qualquer coisa que esteja fora do seu
controle".
Por exemplo, se você está escrevendo um sistema que faz geolocalização de IPs
através de um serviço externo, você provavelmente irá mockar a chamada para o
serviço, já que ele está fora do seu controle. Mas uma chamada para o banco de
dados, quando você já utiliza um sistema/framework de abstrai toda a parte de
banco de dados (por exemplo, Django), então o banco, apesar de ser uma entidade
externa, ainda está sob seu controle e, portanto, não deveria ser mockada --
mas como o sistema/framework oferece uma abstração do banco, então usar
qualquer banco não deve afetar o resultado.
Outro exemplo são microserviços. Mesmo microserviços dentro da mesma empresa
ou mesmo grupo são externos e fora do controle do projeto e, portanto,
mockados. "Mas são da mesma equipe!" Sim, mas não são do mesmo projeto, já que
a) a ideia de microserviços é justamente desacoplar esses serviços e/ou b)
estão em árvores de diretórios separados. Uma das vantagens de microserviços
da mesma equipe é que o contrato esperado por um é conhecido pela equipe e
isso pode ser mockado de forma fácil (a equipe sabe que, chamando um serviço
com dados X, haverá a resposta Y -- ou erro).
# Conclusão
De novo, a ideia não é reescrever todos os testes que você tem porque
"o jeito certo, que é o meu jeito". Entretanto, eu realmente vejo muitos
testes sendo escritos "a revelia", considerando a simples métrica de "um teste
por função/classe" e, em muitos casos, isso não faz sentido e que precisariam
ser repensados. Expondo esses "pensamentos impuros" sobre testes, minha ideia
era realmente fazer com que as pessoas repensassem a forma como os testes
estão sendo criados.
---
[^1]: A não ser que você use anos com números romanos.
[^2]: O porque vai ser uma string pode ser variado: por causa de um plugin de
segurança, porque é feito um armazenamento num banco que não trabalha bem
com inteiros, por causa de uma integração com sistema legado...
[^3]: Uma classe de entidades de clientes deveria ser bem mais completa que
essa, mas vamos deixar ela simples assim para fins de exemplo.
[^4]: E não, isso também não é SOLID de verdade, mas vamos deixar assim de
novo para fins de exemplo.
[^5]: E alguém vai me passar o link do "Falácias que Desenvolvedores Acreditam
Sobre Nomes de Usuários" por causa disso.
[^6]: E sim, deveria alterar só o `_validate_name`, mas assim fica mais claro
onde está o problema.