Browse Source

Post is done, time to translate

master
Julio Biason 4 years ago
parent
commit
3a11135824
  1. 4
      content/thoughts/_index.pt.md
  2. 32
      content/thoughts/reveries-about-testing.md
  3. 344
      content/thoughts/reveries-about-testing.pt.md

4
content/thoughts/_index.pt.md

@ -0,0 +1,4 @@
+++
transparent = true
title = "Thoughts"
+++

32
content/thoughts/reveries-about-testing.md

@ -5,3 +5,35 @@ date = 2020-01-13
[taxonomies]
tags = ["tests", "testing", "integration tests", "unit tests"]
+++
Today, a large number of developers use some testing methodology. But what are
tests? What are they for? What is the purpose of writing testes, anyway? Are
we testing the right things?
<!-- more -->
{% note() %}
This is a companion post for one my presentations, [Filosofando Sobre
Testes](https://presentations.juliobiason.me/filosofando-testes.html), which
is in Portuguese.
{% end %}
Before we start, let me give you some disclaimers:
1. **I'm non-orthodox about tests**. What I mean by that is that some of stuff
I'll mention here are exactly the opposite of what everyone says and the
opposite of the way people work with tests.
2. In no way, consider this a set of rules. What I really want is to stop
people from writing tests without knowing why they are writing those tests.
3. You don't need to agree with anything here. Again, the idea is to sotp and
think what is being tested before writing tests.
What I want to discuss:
1. TDD, Kent Beck Style;
2. "Fast Tests, Slow Tests";
3. The Explosion of Slow Tests;
4. Coverage;
5. Mocks.

344
content/thoughts/reveries-about-testing.pt.md

@ -105,29 +105,337 @@ pra mim", eu ignorei o comportamento esperado por culpa da implementação.
De novo, "teste comportamentos, não implementação".
<!-- testes gerenciador de alertas -->
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.)
<!-- https://www.youtube.com/watch?v=RAxiiRPHS9k fast tests, slow tests -->
<!-- teste models, views, controllers ... soa similar? -->
<!-- "desenvolvedores podem testar suas alterações rapidamente" ... soa
familiar? -->
<!-- testes de aderência a arquitetura do projeto -->
<!-- qual o valor para o usuário desses testes? -->
## Fast Tests, Slow Tests
<!-- testes lentos -->
<!-- "testes de integração são lentos" -->
<!-- suite de testes tem que dar a opção de testar apenas o comportamento
alterado -->
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).
<!-- coverage -->
<!-- 100% de cobertura é possível, removendo código -->
<!-- testes de integração do gerenciador de alertas mostrou que tínhamos
código morto -->
<!-- exemplo validação nomes -->
O que o Bernhardt sugere é escrever testes apenas para os models, sem conexão
com o banco ou views; testes de controllers sem conexão com os models ou
views; e testes de views sem controllers.
<!-- mocks -->
<!-- "coisas externas" vs "coisas fora do nosso controle" -->
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 funcionando 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 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[^2]:
```python
class Client:
def __init__(self, name):
self.name = name
```
Entretanto, 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[^3], 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[^4].
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[^5] 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, por exemplo. Nesses casos, a entrada (ou a
saída, ou ambos) é afetada e, portanto, qualquer coisa que ignore os
middlewares não vai representar a entrada (ou saída, ou ambos) do sistema
corretamente. E aí podemos perguntar se o teste é válido por gerar estados que
não devem existir 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 -- mas 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 de abstrai toda a parte de banco de
dados (por exemplo, Django), então o banco não é mais uma entidade externa, e
sim interna do sistema e que, portanto, não deveria ser mockada -- mas como o
sistema 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 casos de testes que você tem para
"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]: 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...
[^2]: Uma classe de entidades de clientes deveria ser bem mais completa que
essa, mas vamos deixar ela simples assim para fins de exemplo.
[^3]: E não, isso também não é SOLID de verdade, mas vamos deixar assim de
novo para fins de exemplo.
[^4]: E alguém vai me passar o link do "Falácias que Desenvolvedores Acreditam
Sobre Nomes de Usuários" por causa disso.
[^5]: E sim, deveria alterar só o `_validate_name`, mas assim fica mais claro
onde está o problema.

Loading…
Cancel
Save