Testes Unitários


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:

todos eles visitados em março de 2020.

Testando o Código


Erros no código

É natural que ao escrever código, principalmente em projetos grandes, sempre sejam introduzidos erros. Existem três tipos básicos de erros:

  1. erros de sintaxe são o tipo mais básico e os mais fáceis de serem encontrados. Eles ocorrem quando uma ou mais linhas de código estão escritas incorretamente de forma que o interpretador do Python não consegue processá-las. Eles são quase sempre fatais, impedindo que o código seja executado. Eles são, em geral, erros de digitação, indentação incorreta ou argumentos incorretos passados para funções e classes. Por ex.: print "esqueci o parênteses" não é uma linha válida no python 3.x (embora esteja correta no python 2.x).
  2. erros de tempo de execução ocorrem quando a sintaxe está correta mas o interpretador não pode executar a ação. Isso pode ser causado pelo uso de uma variável não definida, um loop infinito, uma divisão por zero, etc.
  3. erros lógicos são os mais difíceis de serem detectados. O código roda sem interrupções mas não executa a tarefa proposta. Ele pode envolver uma operação matemática incorreta, uso incorreto de índices (como começar no indíce 1, e não 0), um loop interrompido prematuramente, etc.


IDES com preenchimento de código e realce de sintaxe ajudam bastante a evitar os dois primeiros tipos de erros. Frameworks de testes podem ser úteis na depuração de erros lógicos.

O processo de se buscar erros (bugs) no código pode (e deve) ser usado várias vezes durante sua construção. Mas, uma vez finalizado, pelo menos em etapa, o código deve ser testado para verificação de que está realizando corretamente a tarefa proposta. Esses últimos testes devem ser os mais amplos possíveis pois sempre podem aparecer erros não cobertos pelas tentativas prévias de checagem. Mesmo projetos completos e bem testados podem necessitar de novas versões com a inserção de novas funcionalidades ou aprimoramento das que existem. Inserir código em um projeto que já existe é uma prática sensível à introdução de erros.

Embora possam excluir muitos possíveis erros, os teste não são capazes de detectar todos os erros de um código porque é praticamente impossível avaliar seu comportamento sob todos os caminhos de execução, em projetos não triviais. Por isso é importante projetar testes que verificam aspectos do código tão amplos quanto possível.

Além dos testes de correção lógica do código, diversos outros aspectos devem ser testados:

  • performance de execução,
  • robustez do aplicativo sob alta demanda ou uso prolongado, principalmente em aplicativos web,
  • capacidade de implantação e instalação nas plataformas alvo,
  • adaptabilidade às atualizações de versões (quando existirem),
  • habilidade realização de backups de dados e do próprio aplicativo e seu estado,
  • segurança do sistema sob ação do aplicativo e capacidade de recuperação em caso de falha.

Existem muitos recursos disponíveis aos desenvolvedores para testes de código em python. A mais simples delas consiste em inserir verificações assert em pontos críticos do código.

Declaração ASSERT


Vimos na seção sobre tratamentos de erros como levantar uma exceção com assert. Podemos usar assert para inserir no código um teste que gera uma exceção caso uma expressão não seja verdadeira.

A sintaxe é: assert teste_booleano [, mensagem], que lança um AssertionError com uma mensagem opcional.

Para recordar o comportamento de assert definimos uma função que lança um erro quando seu argumento é maior que 3, emitindo uma mensagem de erro.

def menor_que_4(i):
    assert i < 4, 'O número deve ser menor que 4'
    dic={1:'um', 2:'dois', 3:'três'}
    return dic[i]

for i in range(1,5):
    try:
        print (menor_que_4(i))
    except AssertionError as msg:
        print(msg)
        
# esse código gera o output
  um
  dois
  três
  O número deve ser menor que 4

Essa é uma forma de se assegurar que um valor está em conformidade com o esperado e, caso contrário, descobrir que valor ofendeu a condição imposta. Se temos vários asserts no código saberemos também em que módulo e linha o erro ocorreu.

Variável __debug__: Caso existam muitas declarações assert no código, além de torná-lo mais extenso e menos legível, pode haver impacto no desempenho do aplicativo. Você pode remover ou comentar todas as declarações (o que não é difícil com um bom editor ou IDE) mas, nesse caso, terá que retornar com todas elas se precisar modificar o projeto.

Declarações assert funcionam junto com a variável interna (built-in) do Python __debug__, que é True por default. Internamente a declaração assert i < 4 é equivalente a:

if __debug__:
    if not i < 4:
        raise AssertionError

Se marcarmos __debug__ == False o teste de assert não será executado. Ocorre que não podemos atribuir valores a essa variável no código, como __debug__ == False. Para isso é necessário definir a variável de ambiente PYTHONOPTIMIZE ou executar o Python com a opção – O. Assim podemos ter todos os testes de assert ativos na fase de desenvolvimento e desligados na produção. Também é possível remover as instruções assert e as docstrings ao compilar o código com compileall. (Leia sobre compilação em Módulos e Pacotes.)

Docstrings

Um mecanismo mais poderoso que lançar erros em pontos específicos consiste em usar docstrings contendo testes e seus resultados, e usar o módulo doctest. (Leia mais sobre doscstrings.)

O módulo doctest procura por trechos na docstring com o formato de sessões interativas do Python e executa essas linhas de comando para conferir o output proposto. O doctest pode:

  • verificar se as docstrings estão atualizadas, conferindo se os exemplos interativos funcionam como documentado.
  • realizar testes de regressão para verificar se os exemplos interativos de um arquivo (ou objeto) em teste funcionam conforme o esperado.
  • facilitar a composição de documentos tutoriais sobre um pacote com com exemplos de entrada-saída. Se corretamente estruturados esses documentos podem ser considerados uma “documentação executável”.

Por exemplo, gravamos o arquivo testando.py, incluindo as linhas import doctest e doctest.testmod() no bloco de inicialização.

# testando.py
def fatorial(n):
    """Retorna fatorial de n inteiro, onde n >= 0.

    >>> [fatorial(n) for n in range(6)]
    [1, 1, 2, 6, 24, 120]
    >>> fatorial(30)
    265252859812191058636308480000000
    """

    if n ≤ 1:
        return 1
    else:
        return n * fatorial(n-1)

if __name__ == "__main__":
    import doctest
    doctest.testmod()

Quando esse script é executado diretamente o módulo doctest é importado e a documentação é “executada” em suas linhas de código e conferida com os outputs fornecidos. Se todos os testes forem bem sucedidos nenhuma mensagem será exibida.

# 1º teste
$ python testando.py

Introduzindo erros: Para efeito de teste vamos introduzir um erro proposital, alterando a linha >>> fatorial(30) para >>> fatorial(20) nas linhas do docstring. O output proposto agora está incorreto, e isso será mostrado no output de doctest.

# 2º teste
$ python testando.py
  **********************************************************************
  File "testando.py", line 6, in __main__.fatorial
  Failed example:
      fatorial(20)
  Expected:
      265252859812191058636308480000000
  Got:
      2432902008176640000
  **********************************************************************
  1 items had failures:
     1 of   2 in __main__.fatorial
  ***Test Failed*** 1 failures.

O resultado aponta como errônea a linha 6 do “testando.py”, como seria esperado.
Módulos executados com

if __name__ == "__main__":
    import doctest
    doctest.testmod()

executarão todas as suas docstrings. Alternativamente, podemos executar o arquivo com o sinalizador python testando.py -v, o que resultará em um output mais extenso e pormenorizado.

Outra forma interessante de se usar o doctest é passando um arquivo de texto como parâmetro para análise de suas linhas de código e output. Um arquivo de texto, digamos que sobre_python.txt (que pode ser parte de um livro, digamos) é gravado com testes idênticos aos de um docstring. Em seguida fazemos:

import doctest
doctest.testfile("sobre_python.txt")

O arquivo sobre_python.txt não precisa ser completo nem conter todas as definições de funções ou módulos usados. Nesse caso as funções e módulos testadas (ou necessárias para o teste) devem ser importadas.

    Sobre o módulo "testando"
    =========================
    Uso da função ``fatorial``
    -------------------
    Importe o módulo e função
    >>> from testando import fatorial
    Agora você pode usar:
    >>> fatorial(6)
    120

Existem algumas formas de alterar a forma como doctest lê as docstrings. As mais comuns são +ELLIPSIS (significando que um sinal de reticência representa qualquer substring) e +NORMALIZE_WHITESPACE (que força o tratamento de qualquer sequência de espaços em branco da mesma forma). Isso fazer isso basta inserir um comentário com a forma de # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE, onde o sinal + ativa a instrução. Os testes abaixos são bem sucedidos:

>>> lista = [2, 4, 6, 8, 10]
>>> lista # doctest: +ELLIPSIS
    [2, ..., 10]
>>> lista # doctest: +NORMALIZE_WHITESPACE
    [2,4,   6,8,     10]

Para desativar as instruções usamos , como em # doctest: -ELLIPSIS, -NORMALIZE_WHITESPACE. As instruções podem ser usadas em conjunto.

Ao realizar testes com doctest alguns cuidados devem ser tomados quando se compara objetos que podem ser retornados em diferentes ordens. Por exemplo, dicionários não são objetos ordenados, e um teste supondo uma ordem específica pode falhar.

# Testando valores em um dicionário
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}
>>> capitais
{"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

O teste acima falhará se o dicionário for retornado em ordem diferente. Uma solução consiste em testar por cada chave ou ordenar o dicionário a ser
testado. Como dicionários não possuem elementos com chaves repetidas, o mais apropriado é ordenar por chaves (key).

# Testando valores em um dicionário com ordenação
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

>>> ordenado = dict(sorted(capitais.items(), key=lambda i: i[0]))
>>> ordenado
{"Belo Horizonte":"MG", "Rio de Janeiro":"RJ", "São Paulo": "SP"}

Além disso, como linhas vazias são consideradas marcas para terminar o processamento doctest, se linhas em branco fazem parte do output esperado é necessário inserir uma linha com <BLANKLINE>. Para inserir caracteres \ como escape de outro caracter ou para marcar continuação de linha, o string do docteste deve ser raw, ou seja, precedido por r.

Doctestes são úteis e devem ser usados, mas podem ficar grandes e pesados para projetos maiores. Outras formas de testagem estão disponíveis no python.

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()

Podemos rodar esse teste e observar seu 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.

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.
  • Romano, F., Phillips, D., Hattem, R.: Python: Journey from Novice to Expert, Packt Publishing, 2016.

Sites:

todos eles visitados em março de 2020.




Testes: unittest