Testando o código
Vimos no artigo Testando o Código algumas abordagens iniciais para realizar testes no código, tais como usar a declaração assert e as docstrings. Recursos mais avançados epoderosos estão disponíveis, como é o caso do módulo unittest, que veremos agora.
Testes unitários com unittest
Testes unitários permitem que unidades de código possam ser testadas em diversas de suas características. Uma unidade pode ser uma função individual, um método ou procedimento de uma classe ou objeto. Ele é feito durante o desenvolvimento pelo programador.
O módulo unittest, incluído na biblioteca padrão, fornece ferramentas para testes unitários. Com ele podemos projetar um conjunto de testes que verificam se uma função (por exemplo) se comporta como o esperado sob situações variadas. Um bom conjunto de testes considera os possíveis tipos de entrada que uma função pode receber, incluindo testes de cada dessas situações. Uma cobertura completa de testes, em um projeto grande, pode ser muito difícil e, nesses casos pode ser considerado suficiente cobrir os casos críticos de uso do bloco testado. Diversos editores e IDEs, incluindo Jupyter Notebook, PyCharm e VSCode, podem usar unittest integrado.
Para usar unittest vamos escrever uma função a ser testada. Em seguida importamos o módulo unittest e criamos uma classe que herda de unittest.TestCase
. Objetos dessa classe chamam e verificam o comportamento dessa função testada ao serem inicializados. Métodos diversos podem ser inseridos para verificar o funcionamento da função sob a inserção de parâmetros diferentes.
Para observar o funcionamento dos testes unitários vamos gravar dois arquivos do python, formata_nomes.py e nomes.py. O primeiro contém a função que queremos testar, o segundo chama essa função.
#formata_nomes.py def ler_nome_formatado(nomes): msg = "" if nomes.strip() == "": msg = "" partes = nomes.split() nome = partes[0].title() msg = f"Primeiro nome: {nome}" if len(partes) > 1: sobre = " ".join(partes[1:]).title() msg = f"{msg}, Sobrenome: {sobre}" return msg
Essa função recebe nomes e sobrenomes separados por espaços e retorna esse nome formatado como Primeiro nome: nome, Sobrenome: sobrenomes
. Ela considera todas as palavras após a primeira como sobrenome. Para usar essa função gravamos e executamos o arquivo nomes.py.
from formata_nome import ler_nome_formatado as nf print("Digite nome e sobrenomes.") print("Deixe em branco para terminar.") while True: nomes = input("\nDigite o nome completo: ") formatado = nf(nomes) if formatado=="": break print(f"\tNome formatado: {formatado}.")
Podemos iniciar uma sessão no console (terminal) e executar python nomes.py
. O output aparece no código abaixo.
$ python nomes.py Digite nome e sobrenomes. Deixe em branco para terminar. Digite o nome completo: PEDRO Nome formatado: Primeiro nome: Pedro. Digite o nome completo: pedro de alcantara Nome formatado: Primeiro nome: Pedro, Sobrenome: De Alcantara. Digite o nome completo: pedro II Nome formatado: Primeiro nome: Pedro, Sobrenome: Ii. Digite o nome completo:
Na última linha foi inserida uma string vazia, o que termina o loop. Aparentemente a função retorna o que se espera. Mesmo assim vamos testar nossa função: em um novo módulo importamos unittest e a função que pretendemos testar. Depois criamos uma classe que herda de unittest.TestCase
e acrescentamos diversos métodos para verificar aspectos diferentes da função. Cada um dos métodos test_1, test_2, test_3 verifica um comportamento da função para diferentes tipos de inputs.
# teste_formata_nomes.py import unittest from formata_nome import ler_nome_formatado as nf class TestaFormataNomes(unittest.TestCase): """Testes para 'formata_nome.py'.""" def test_1(self): """testando o nome 'palito'.""" formatado = nf('palito') self.assertEqual(formatado, 'Primeiro nome: Palito') def test_2(self): """testando nomes com maísculas.""" formatado = nf('MARCO POLO') self.assertEqual(formatado, 'Primeiro nome: Marco, Sobrenome: Polo') def test_3(self): """testando strings vazias.""" formatado = nf('') self.assertEqual(formatado, '') if __name__ == '__main__': unittest.main()
Ao rodar esse teste observamos o output:
$ python teste_formata_nome.py ... ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK
Nenhum erro foi encontrado em nenhum dos três testes, como mostrado no console. Essa classe pode ter qualquer nome embora seja boa ideia dar um nome representativo de seu objetivo. Ela contém três métodos para testar a função formata_nome.ler_nome_formatado. Qualquer classe que herda de unittest.TestCase executa automaticamente todos os seus métodos que começam com test_ quando é invocada. O retorno da função testada é comparado com o resultado na linha self.assertEqual(formatado, 'string esperada')
(um dos método de unittest.TestCase
) e gera a informação sobre se o teste foi bem sucedido ou não, com as devidas mensagens.
O bloco if
no final, como já vimos, verifica o valor da variável especial __name__
. Se o arquivo estiver sendo rodado como programa principal, como ocorreu no nosso caso, ela assume o valor __name__ = "__main__"
. Nesse caso unittest.main()
é chamado e os testes executados.
Suponha que queremos expandir nossa função ler_nome_formatado
para que ela retorne uma mensagem de erro caso algum dígito esteja entre os caracteres dos nomes. Se um nome for digitado como “lu1s quinze” a função deve retornar: “Erro: dígito encontrado!”
Vamos então acrescentar um teste em teste_formata_nomes.py
. O código abaixo mostra só o acréscimo ao arquivo.
# teste_formata_nomes.py ... def test_4(self): """testando dígitos no nome.""" formatado = nf('lu1z paulo') self.assertEqual(formatado, 'Erro: dígito encontrado!') ...
Rodamos o teste novamente: desta vez um nome inserido com um dígito não retorna o resultado correto e uma mensagem de erro informa qual o teste falhou, onde e porque.
$ python teste_formata_nome.py ...F ====================================================================== FAIL: test_4 (__main__.TestaFormataNomes) testando dígitos no nome. ---------------------------------------------------------------------- Traceback (most recent call last): File "teste_formata_nome.py", line 29, in test_4 self.assertEqual(formatado, 'Erro: dígito encontrado!') AssertionError: 'Lu1Z Paulo' != 'Erro: dígito encontrado!' - Lu1Z Paulo + Erro: dígito encontrado! ---------------------------------------------------------------------- Ran 4 tests in 0.001s FAILED (failures=1)
Claro que esse defeito deve ser corrigido em formata_nomes.py
. Alteramos o código da seguinte forma:
# formata_nomes.py def ler_nome_formatado(nomes): msg = "" if nomes.strip() == "": msg = "" elif True in [i.isdigit() for i in nomes]: msg = "Erro: dígito encontrado!" else: partes = nomes.split() nome = partes[0].title() msg = f"Primeiro nome: {nome}" if len(partes) > 1: sobre = " ".join(partes[1:]).title() msg = f"{msg}, Sobrenome: {sobre}" return msg
No código temos a condição True in [i.isdigit() for i in nomes]
que testa cada caracter da variável de string nomes, retornando uma lista de valores booleanos. O teste resulta verdadeiro se uma ou mais das entradas dessa lista for True, ou seja, se existirem dígitos no nome. Com essa alteração rodarmos o teste mais uma vez e veremos que todas as condições testadas foram satisfeitas.
$ python teste_formata_nome.py ... ---------------------------------------------------------------------- Ran 4 tests in 0.000s OK
Para ignorar um dos testes podemos decorar a função com @unittest.skip
.
... @unittest.skip('Esse teste já foi executado!') def test_4(self): formatado = nf('lu1z paulo') self.assertEqual(formatado, 'Erro: dígito encontrado!') ...
Uma classe inteira pode ser ignorada.
@unittest.skip("Uma classe a ser ignorada") class Classe_de_Teste(unittest.TestCase): def um_metodo_qualquer(self): pass
Os seguintes decoradores estão disponíveis na classe:
@unittest.skip(msg) |
ignore o teste em qualquer caso, |
@unittest.skipIf(bool, msg) |
ignore o teste se bool==True, |
@unittest.skipUnless(bool, msg) |
ignore o teste, exceto se bool==True, |
@unittest.expectedFailure |
marca o teste como falha esperada. Se o teste falhar mensagem de sucesso é emitida e, se passar um erro é lançado, |
exception unittest.SkipTest(msg) |
Uma exceção é levantada ao ignorar em teste, |
msg
é a mensagem retornada com a exceção, que deve ser descritiva do problema ocorrido. bool
é qualquer expressão que retorne um booleano.
Métodos setUp() e tearDown()
Os métodos setUp()
e tearDown()
são usados para definir instruções executadas antes e depois dos testes definidos. setUp()
é executado antes de cada teste no módulo e tearDown()
depois de cada um deles. Eles podem ser usados, por exemplo, definir variáveis, abrir e fechar uma conexão com banco de dados ou ler dados em um arquivo.
Erros levantados dentro setUp()
ou tearDown()
serão considerados erros comuns e não uma falha do teste. A implementação default não realiza nenhuma ação (como em pass). Por exemplo, suponha que pretendemos testar nossa classe Calculadora onde
# classe Calculadora class Calculadora: def __init__(self): pass def soma(self, a, b): return a + b def subtrai(self, a, b): return a - b def muliplica(self, a, b): return a * b def divide(self, a, b): if b != 0: return a / b
Na classe de teste teríamos que inicializar uma calculadora para cada teste. Alternativamente podemos inicializar uma calculadora no método setUp()
.
class TestCalculadora(unittest.TestCase): def setUp(self): self.calc = Calculadora() def tearDown(self): self.calc = None def test_soma(self): self.assertEqual(self.calc.add(4, 7), 11) def test_subtrai(self): self.assertEqual(self.calc.sub(10, 5), 5) def test_multiplica(self): self.assertEqual(self.calc.mul(3, 7), 21) def test_divide(self): self.assertEqual(self.calc.div(10, 2), 5)
Também podemos usar métodos semelhantes para classes e módulos: isso é feito com setUpClass()
e tearDownClass()
em classes, e setUpModule()
e tearDownModule()
em módulos.
Testes ignorados não acionam setUp()
nem tearDown()
, caso estejam definidos. Da mesma forma classes ignoradas não acionam setUpClass()
nem tearDownClass()
. Módulos ignorados não acionam setUpModule()
nem tearDownModule()
.
Para ver uma lista de opções de uso do unittest
podemos digitar:
python -m unittest -h
Métodos assert em unittest.TestCase
Nos testes usando unittest.TestCase
podemos usar um assert puro ou um dos seguintes métodos definidos no módulo unittest.TestCase:
Método | levanta erro se a condição não se verifica |
---|---|
assertEqual(m, n) |
m == n |
assertNotEqual(m, n) |
m != n |
assertTrue(a) |
a é True |
assertFalse(a) |
a é False |
assertIn(item, lista) |
item está na lista |
assertNotIn(item, lista) |
item não está na lista |
assertIs(a, b) |
a is b |
assertIsNot(a, b) |
a is not b |
assertIsNone(x) |
x == None |
assertIsNotNone(x) |
x != None |
assertIsInstance(a, b) |
a é uma instância de b |
assertNotIsInstance(a, b) |
a não é uma instância de b |
assertAlmostEqual(a, b[, n]) |
se a == b, precisão de n decimais (default: n = 7) |
assertNotAlmostEqual(a, b[, n]) |
negação de assertAlmostEqual(a, b[, n]) |
assertGreater(a, b) |
a > b |
assertGreaterEqual(a, b) |
a >= b |
assertLess(a, b) |
a > b |
assertLessEqual(a, b) |
a <= b |
assertRegex(s, r) |
regex r.search(s) |
assertNotRegex(s, r) |
regex not r.search(s) |
assertCountEqual(a, b) |
a e b tem os mesmos elementos e em igual número, independente da ordem. |
Método | |
fail() |
sempre gera erro |
Os erros são levantados quando o teste for falso. Em todos os casos um parâmetro opcional pode ser usado para determinar a mensagem de erro mostrado, como em TestCase.assertEqual(m, n [, mensagem])
. Devemos nos lembrar, como dito acima, que um teste com assert
pode ser desligado com o ajuste da variável __debug__ = False
.
Simulações (Mocks)
Geralmente o estado de uma função, classe ou um de seus métodos depende de objetos externos para a coleta de dados ou outra interação qualquer, tais como arquivos em disco a serem lidos ou acesso a bancos de dados, ou uma peça de hardware a ser acionada. Como não é boa prática acessar em fase de desenvolvimento os objetos na produção desenvolveu-se a abordagem de criar “objetos simulados” ou mocks. Um objeto mock substitui e imita o comportamento de um objeto real, no ambiente de teste. Usando mocks fica mais fácil gerar situações que podem ser raras no ambiente real, por exemplo para o teste de blocos except ou testes condicionais if. Ainda ocorrem casos em que os objetos (que podem ser blocos de código) ainda não foram desenvolvidos ou oferecem respostas muito lentas para efeito de teste. Com esses objetos é possível verificar se e como um método foi chamado, e com qual frequência.
O módulo unittest inclui um subpacote chamado unittest.mock
com ferramentas úteis para essa simulação. Ele também oferece uma função patch()
que substitui os objetos reais no código por instâncias mocks. patch()
pode ser usado como um decorador ou gerenciador de contexto, facilitando a escolha de qual escopo será simulado. Ao final do teste patch()
retornará no código as referências aos objetos originais.
O objeto mock
Um objeto mock pode ser instanciado e a ele podemos atribuir métodos e propriedades.
from unittest.mock import Mock mock = Mock() print(mock) <Mock id='140292494179968'> # ao objeto podemos atribuir métodos e propriedades mock.propriedade mock.metodo()
Além da classe unittest.mock
(que é a base das classes simuladas) o módulo também contém uma subclasse unittest.mock.MagicMock
que fornece implementações de vários métodos mágicos como .__len__()
, __str__()
e .__iter__()
.
Por exemplo, gravamos o arquivo dia_semana.py
, que imprime fim de semana se o dia for sábado ou domingo, e dia da semana para os demais dias.
from datetime import datetime def is_fds(): dia_semana = datetime.today().weekday() return dia_semana > 4 print('fim de semana' if is_fds() else 'dia da semana')
O módulo datatime retorna weekday() = 0
para segunda feira, weekday() = 5, 6
para sábado e domingo. O resultado desse código depende do dia em que está sendo executado. Para uma execução feita na terça feira temos:
$ python dia_semana.py # é impresso no console dia da semana
É claro que seria interessante testar o código para outros dias, sem ter que esperar a data correta, nem alterar o relógio do computador. Para fazer isso fazemos um mock de datetime.
import datetime from unittest.mock import Mock # fixamos 2 dias para serem usados no teste ter = datetime.datetime(year=2022, month=3, day=1) # terça feira (1) sab = datetime.datetime(year=2022, month=3, day=5) # sábado (5) # Mock datetime para controlar a data datetime = Mock() def is_fds(): dia_semana = datetime.datetime.today().weekday() return dia_semana > 4 # força datetime para retornar a data em ter (terça feira) datetime.datetime.today.return_value = ter # teste para dia = terça print('fim de semana' if is_fds() else 'dia da semana') # força datetime para retornar a data em sab (sábado) datetime.datetime.today.return_value = sab # teste para dia = sábado print('fim de semana' if is_fds() else 'dia da semana')
Agora, ao executar o script temos duas respostas:
$ python dia_semana.py dia da semana fim de semana
Nesse exemplo, quando fazemos datetime = Mock()
tornamos datetime.datetime.today
um método simulado, que pode receber a propriedade datetime.datetime.today.return_value
a critério do programador. Com isso o método interno .today()
retorna a data especificada.
Bibliografia
Livros:
- Ceder, Vernon; Mcdonald, Kenneth: The Quick Python Book, 2nd. Ed., Manning, Greenwich, 2010.
- Hunt, John: Advanced Guide to Python 3 Programming, Springer, Suíça, 2019. Disponível em Academia.edu.
Sites:
- Campbell, Steve: Guru99: Python Unittesting Guide,
- Docs python: Unittest,
- The Practical Testing Book: Unittesting,
- Módulos alternativos para mocking:
- Pymox: Alternativa open source, Pymox,
- Mocktest: Outro módulo alternativo, Mocktest.
todos eles visitados em março de 2020.