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