Essa é a primeira parte da série de testes em Python.
A primeira parte tem por objetivo fazer uma introdução a testes automatizados utilizando o framework nativo do Python unnitest. Aqui, veremos alguns conceitos primordiais para escrever os primeiros casos de teste.
Pré-requisito
- Conhecimento básico de Python
Roteiro
- O que são testes
- Teste unitário (de unidade)
- Conceitos
- Exemplos
- Considerações adicionais
- Conclusão
1. O que são testes
1.1 Motivação
Ao escrever nossos programas, precisamos validar se o resultado que ele produz é o correto.
Se tratando dos primeiros programas que escrevemos, normalmente rodamos o nosso código e passamos diversos valores para verificar se o que ele retorna é o esperado. Quando isso não acontece, temos que fazer uma varredura pelo nosso código e tentar entender o que ocorreu. Pode ter sido exibido algum erro ou exceção ao usuário ou o programa retornar o valor errado e não exibir erro nenhum (ocasionado por erro de lógica na maioria das vezes). Nos dois casos, podemos usar o modo debug (que são facilitados por uma IDE¹) ou adicionar alguns outputs no terminal usando a função print. Com isso, podemos identificar o problema e corrigir nosso código para retornar o que é esperado. Isso é um teste manual.
IDE¹ (Integrated Development Environment) do português ambiente integrado de desenvolvimento. É um ambiente que nos ajuda a desenvolver nossos códigos sendo possível não só utilizar como editor de texto, mas também facilitar execução, debbug e integração com outras ferramentas. Em Python, o Pycharm é uma IDE muito utilizada e difundida.
Repare que isso se torna extremamente cansativo e está sujeito a falhas fazer validações dessa forma. Assim, não garantimos a qualidade do nosso programa de forma facilitada se mais funcionalidades forem adicionadas pois todas as vezes teríamos que executar nosso programa e verificar os cenários na mão.
1.2 Testes automatizados
Testes automatizados são cenários que escrevemos através de linha de código simulando os testes manuais que antes eram feitos. Poupando assim tempo e esforço nessas verificações.
Escrever testes automatizados vai muito além do que somente escrever cenários para validar que o código faça o que deveria fazer. O teste também nos ajuda a:
- Deixar o código mais limpo (ajuda na remoção de code smell)
- Garantir maior manutenção do código
- Servir como documentação: de forma visual conseguimos saber quais são os cenários esperados e os tratamentos em caso de erro olhando o arquivo de teste
- Evitar trabalho manual (um teste automatizado é muito melhor do que um teste manual com print)
- Evitar bug's
- Prover feedback para quem está desenvolvendo a aplicação: conseguimos saber se o programa está retornando o que é esperado mesmo alterando a lógica do programa principal.
2. Teste unitário
Um teste unitário é a maneira de você testar as pequenas unidades existentes em seu código. Também é chamado de teste de unidade.
Para nos ajudar com os testes, iremos utilizar o framework built-in (nativo) do Python chamado unittest.
Exemplo 1:
from unittest import main, TestCase
def square(x):
return x ** 2
class TestSquare(TestCase):
def test_if_returns_square_of_2(self):
result = square(2)
expected = 4
self.assertEqual(result, expected)
if __name__ == '__main__':
main()
No exemplo acima, estamos testando uma pequena unidade do nosso código, estamos validando um cenário da função chamada square.
Repare que todo o contexto de teste está sendo utilizado dentro de uma classe.
Mas, para entender melhor como funciona o exemplo acima, vamos primeiro entender alguns conceitos.
3. Conceitos
3.1 Preparação do teste
Nessa etapa é feita a preparação do ambiente para fazer o teste rodar. Essa preparação é chamada de fixture e consiste nos itens necessários para um ou mais testes serem executados.
- Exemplo: para conseguir testar uma função que lê um determinado arquivo precisamos de um arquivo no nosso ambiente de teste para ser possível fazer a validação
Pode conter o uso dos métodos setUp() e tearDown(). Isso são ações que são executadas antes e depois, respectivamente, da execução de cada um dos cenários de teste.
3.2 Caso de teste
É o conjunto de cenários que queremos testar.
Em um caso de teste agrupamos todos os pequenos cenários que queremos validar de forma unitária que fazem parte do mesmo contexto.
Com o framework unittest, usamos uma classe base chamada TestCase.
3.3 Asserções
Asserções servem para validar que o cenário do seu código ocorreu como o esperado.
O assert é um comando built-in (nativo) do Python. E podemos usar da seguinte forma:
Repare que na primeira asserção é comparada se a soma de 1+1 é igual a 2. Como o resultado é verdadeiro, nada é exibido no console.
Já na asserção seguinte, há uma falha e um erro é levantado do tipo AssertionError (erro de asserção).
O framework unittest facilita nessas asserções que vamos precisar fazer. As asserções completas podem ser vistas em sua própria documentação.
3.4 Test Runner
Permite rodar a execução dos testes. O test runner orquestra a execução dos testes e exibe para o usuário os resultados.
Além de utilizar o test runner do unittest outros podem ser utilizados como o pytest.
3.5 Coverage
Coverage remete a cobertura de testes, ou seja, o quanto o seu código está sendo testado.
Executando o coverage, conseguimos saber quais trechos de código foram testados e quais não foram.
ATENÇÃO: 100% de cobertura é diferente de ter todos os cenários testados! Além de testar o fluxo principal do programa também precisamos testar casos inesperados (veremos mais adiante na série "Testes em Python").
3.6 Mock
Mock é uma biblioteca utilizada em testes quando queremos simular um determinado comportamento. Mock em inglês é literalmente "imitar". Ele é bastante utilizado quando nosso código se comunica com elementos externos como por exemplo: conexão com o banco de dados e chamadas HTTP.
Se não for utilizado o mock, no momento que executarmos nosso código chamadas reais irão acontecer para a rede ou banco de dados, por exemplo (veremos mais adiante na série "Testes em Python").
4. Exemplos
Cat writing: show me the code!
4.1 Vamos entender o funcionamento do Exemplo 1
from unittest import main, TestCase
def square(x):
return x ** 2
class TestSquare(TestCase):
def test_if_returns_square_of_2(self):
result = square(2)
expected = 4
self.assertEqual(result, expected)
if __name__ == '__main__':
main()
Na primeira linha importamos os itens necessários do framework unittest.
main()
⇒ chamamos o Test Runner da biblioteca para ao rodar nosso código Python (python meu_arquivo.py).
TestCase
⇒ fornece a estrutura necessária para montar o caso de teste.
from unittest import main, TestCase
O próximo bloco se refere ao nosso código com o trecho que queremos validar
def square(x):
return x ** 2
Criamos uma classe de teste herdando o TestCase
do unittest e seus métodos são os casos de teste.
Repare que dentro da classe escrevemos de modo descritivo o cenário que está sendo testado (estamos testando se o nosso código retorna o quadrado de dois). Depois fazemos uma asserção usando assertEqual
comparando se a chamada da função square retorna o esperado, que é 4.
class TestSquare(TestCase):
def test_if_returns_square_of_2(self):
result = square(2)
expected = 4
self.assertEqual(result, expected)
Nota: Repare que o nome da classe e o cenário começam com a palavra "test". O nome "test" é obrigatório ser iniciado para as funções de teste, mas opcionais para a classe (no entanto é recomendado por questões de clareza).
Por fim, o último trecho chama o main() do unittest quando o arquivo python for executado.
if __name__ == '__main__':
main()
Para rodar o exemplo basta rodar como um arquivo python normalmente (no meu caso eu salvei em um arquivo my_first_test.py)
python my_first_test.py
No console será exibido que 1 teste foi executado e ele está com o status OK, ou seja, o teste passou.
Vamos adicionar outro cenário de teste para simular um erro
from unittest import main, TestCase
def square(x):
return x ** 2
class TestSquare(TestCase):
def test_if_returns_square_of_2(self):
result = square(2)
expected = 4
self.assertEqual(result, expected)
def test_if_returns_square_of_4(self):
result = square(4)
expected = 4
self.assertEqual(result, expected)
if __name__ == '__main__':
main()
Executando novamente o código acima temos o seguinte retorno:
Repare que adicionando o cenário test_if_returns_square_of_4 executamos no total 2 testes e justamente esse cenário falhou. Isso por que chamando a função square ela retorna 16 e estamos tentando validar que o esperado a ser retornado é 4.
Corrigindo o nosso código, ele deveria ficar da seguinte forma:
from unittest import main, TestCase
def square(x):
return x ** 2
class TestSquare(TestCase):
def test_if_returns_square_of_2(self):
result = square(2)
expected = 4
self.assertEqual(result, expected)
def test_if_returns_square_of_4(self):
result = square(4)
expected = 16
self.assertEqual(result, expected)
if __name__ == '__main__':
main()
Observação: Os testes nos ajudam a identificar problemas no nosso código principal, mas o próprio cenário de teste também está suscetível a erros. Devemos sempre validar se o teste está bem escrito e se realmente testando o cenário esperado.
Observação 2: Note que existe outra forma de calcular o quadrado de um número. No exemplo acima calculamos como x*x
mas perceba que se mudarmos a forma que é calculada para x ** 2
o resultado retornado é o mesmo e isso não afeta os nossos testes. Se executarmos novamente veremos que os cenários continuarão passando.
Um teste não deve enviesar a forma como é implementado o código. Por isso se efetuarmos refatorações² no código, rodando os testes conseguimos garantir se o comportamento é o mesmo retornado pelo código anterior.
Refatoração² é o efeito de você mudar a implementação do código sem afetar o seu retorno (comportamento externo). A implementação é mudada de forma que o código fique mais organizado, mais limpo e melhor estruturado.
Nota: É uma boa prática separar o código principal do código dos testes, veremos isso no próximo exemplo.
4.2 Detecção de divisão
O exemplo abaixo mostra uma função que detecta uma divisão. Se a divisão for possível deverá retornar verdadeiro, se não deverá retornar falso.
Essa função está armazenada dentro de um arquivo chamado division_detect.py
def division_detect(numerator: int, denominator: int) -> bool:
if numerator / denominator:
return True
return False
Código 4.2.1
Vamos escrever um cenário de testes para essa função. Para isso, vamos criar um arquivo chamado test_division_detect.py
Nota: Apesar dos testes rodarem independente do nome do arquivo, é uma boa prática escrever o arquivo de teste começando com a palavra "test" assim como nossa classe.
O arquivo de teste possui o seguinte esqueleto:
from unittest import TestCase
class TestDivisionDetect(TestCase):
def test_it_returns_true_if_division_by_number_is_successful(self):
pass
Código 4.2.2
Para escrever esse cenário de teste, queremos validar se a nossa função division_detect
retorna true
se a divisão for bem sucedida.
A questão é: qual número vamos usar para fazer esse teste? Poderíamos escolher dois números aleatórios como 10 para o numerador e 2 para o denominador. Importando a função e comparando se o resultado retornado é verdadeiro com a asserção assertTrue
temos o seguinte cenário de teste:
from unittest import TestCase
from division_detect import division_detect
class TestDivisionDetect(TestCase):
def test_it_returns_true_if_division_by_number_is_successful(self):
result = division_detect(
numerator=10, denominator=2
)
self.assertTrue(result)
Código 4.2.3
No teste acima, vamos validar se a divisão de 10/2
é considerada válida.
Vamos rodar o arquivo de teste com o comando abaixo:
python -m unittest test_division_detect.py
Como sabemos, o resultado da operação é 5 e bool(5)
é verdadeiro
Veremos que um teste rodou e o resultado foi OK (o cenário de teste passou)
Mas, o que aconteceria se a gente mudasse o código principal no arquivo division_detect.py para esse código:
def division_detect(numerator: int, denominator: int) -> bool:
if numerator == 10:
return True
return False
Código 4.2.4
Claro que aqui estamos forçando um pouco a barra e alterando completamente a lógica do código principal, mas se os testes forem executados novamente o teste vai passar.
Nesse caso o teste passou mas isso não é garantia que o cenário está sendo validado de fato. Para garantir que a divisão está sendo feita vamos gerar números aleatórios tanto para o numerador quanto para o denominador. Para isso vamos usar a função do python randint
from random import randint
from unittest import TestCase
from division_detect import division_detect
class TestDivisionDetect(TestCase):
def test_it_returns_true_if_division_by_number_is_successful(self):
result = division_detect(
numerator=randint(0, 100000), denominator=randint(0, 100000)
)
self.assertTrue(result)
Código 4.2.5
Repare que tanto para o numerador como para o denominador está sendo gerado números aleatórios entre 0 e 100000 e se executarmos novamente o arquivo division_detect.py que foi escrito no Código 4.2.4 vamos ver que o código não vai passar. Vamos voltar o código para que fique o mesmo do Código 4.2.1.
Olhando novamente para o cenário de teste, o que aconteceria se o denominador sorteado fosse 0?
É, aqui temos um problema. A divisão de qualquer número por zero é indefinida!*
*Divisão por zero é uma operação que tende ao infinito e portanto é dada como indefinida.
Vamos adicionar um novo cenário de teste para validar isso, dessa vez vamos forçar que o denominador seja zero.
from random import randint
from unittest import TestCase
from division_detect import division_detect
class TestDivisionDetect(TestCase):
def test_it_returns_true_if_division_by_number_is_successful(self):
result = division_detect(
numerator=randint(0, 100000), denominator=randint(0, 100000)
)
self.assertTrue(result)
def test_it_returns_false_if_division_by_number_is_not_possible(self):
result = division_detect(numerator=randint(0, 100000), denominator=0)
self.assertFalse(result)
Código 4.2.6
Rodando novamente esse arquivo de testes, vamos receber o seguinte retorno
Repare que dois testes foram executados, o que é correto pois escrevemos dois cenários de teste. Mas um dos testes, o test_it_returns_false_if_division_by_number_is_not_possible
, falhou. Isso pois aconteceu um erro chamado ZeroDivisionError
(https://docs.python.org/3/library/exceptions.html#ZeroDivisionError).
Olhando nosso código principal, vemos que não está sendo feito nenhum tipo de tratamento para esse erro. Vamos refatorar então o nosso código para lidar com esse erro, para isso vamos usar o statement try/except do Python.
def division_detect(numerator: int, denominator: int) -> bool:
try:
numerator / denominator
except ZeroDivisionError:
return False
else:
return True
Código 4.2.7
No código acima primeiro tentamos fazer a divisão entre os dois parâmetros recebidos, se a operação for bem sucedida irá retornar True
, mas se acontecer uma exceção do tipo ZeroDivisionError
irá retornar Falso
.
Rodando novamente os testes com python -m unittest test_division_detect.py
vamos ver que os dois cenários passaram.
Nota: Repare que nosso código original não estava preparado para uma divisão por zero. Isso é chamado de corner case, ou seja, são cenários que podem acontecer fora do padrão esperado. Para tratar esse cenário tivemos que adicionar um tratamento com as exceções do Python.
No arquivo de teste, para os dois cenários que estamos testando, estamos repetindo randint(0, 100000)
. Como queremos pegar sempre um numerador aleatório para todos os testes, podemos utilizar o setUp()
(https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp) que é uma inicialização que é executada sempre no início de cada cenário de teste.
Por fim, chegaremos ao código abaixo:
from random import randint
from unittest import TestCase
from division_detect import division_detect
class TestDivisionDetect(TestCase):
def setUp(self) -> None:
self.random_numerator = randint(0, 100000)
def test_it_returns_true_if_division_by_number_is_successful(self):
result = division_detect(
numerator=self.random_numerator, denominator=randint(1, 100000)
)
self.assertTrue(result)
def test_it_returns_false_if_division_by_number_is_not_possible(self):
result = division_detect(numerator=self.random_numerator, denominator=0)
self.assertFalse(result)
Repare que o denominador no primeiro cenário sorteia um número entre 1 e 100000, pois estamos querendo validar um cenário onde a divisão é bem sucedida.
5. Considerações adicionais
Aqui, listo alguns pontos que devem ser levados em consideração no momento de programar e testar seus códigos:
- Divida para conquistar: Mantenha a estrutura de arquivos organizadas. Se um projeto é grande, é sempre uma boa prática dividir em arquivos menores (isso facilita a manutenção e legibilidade)
- Um teste também deve ser limpo igual ao código principal (Clean Code - Robert C. Martin)
- Use nomes descritivos para as funções de teste, mesmo que seja um nome muito longo
- Pense em corner cases (cenários fora do padrão esperado), igual fizemos na divisão por zero no exemplo 2
- Um teste não deve engessar a implementação do seu código, igual vimos no exemplo 1 (o quadrado de um número pode ser feito de duas formas)
- Escrever em pequenas unidades ajudam a testar o código e a melhorar sua clareza
- Refatore: Sempre que puder melhorar seu código melhore! (Lema de escoteiro)
De acordo com a PEP20³:
Errors should never pass silently
PEP³ (do inglês (Python Enhancement Proposals) se refere a propostas de como utilizar o Python da melhor forma: https://www.python.org/dev/peps/
6. Conclusão
Testes são uma maneira de garantir que o seu programa retorna o resultado esperado. Além disso, garante maior qualidade no produto que está sendo entregue. Os testes também ajudam que a equipe de desenvolvedores entendam os cenários que acontecem na aplicação e ajudam a identificar cenários fora do padrão. Testando nosso código, conseguimos encontrar também maneiras de deixar nosso código mais limpo e conciso, facilitando assim na manutenção futura e a evitar bugs que possam ocorrer. É melhor que um teste pegue o erro do que o cliente usando o seu produto :)
Essa foi a primeira série de Testes em Python, espero que tenha ficado claro o entendimento dessa introdução e em breve teremos mais publicações sobre esse assunto. Dúvidas podem ser colocadas no comentário e lembre-se que testar faz parte do processo de escrever um bom código, segundo o programador Pete Goodliffe
"Um bom código não surge do nada. [...] Para ter um bom código é preciso trabalhar nele. Arduamente. E você só terá um código bom se realmente se importar com códigos bons."
Como Ser Um Programador Melhor: um Manual Para Programadores que se Importam com Código (Pete Goodliffe)