Testes Unitários em Python


O que são testes unitários em Python?

Quando falamos em testes unitários, estamos nos referindo ao ato de testar funções.

O termo “teste unitário” (ou teste de unidade) nos sugere que estamos testando unidades dos nossos programas. Na prática, funções são as menores unidades que podemos testar de forma independente, então testes unitários se referem ao teste de funções individuais.

O que testar?

Uma função pode ser vista como uma computação que depende de uma entrada e produz um resultado com base na entrada fornecida. Em outras palavras, o comportamento de uma função é determinado pela entrada da função e pela lógica da função (conjunto de operações realizadas sobre a entrada). Se a entrada for válida e a lógica da função estiver correta, obteremos o resultado esperado.

Ao testar funções, nos concentramos em dois aspectos: validação da entrada, e checagem do resultado produzido. Se a entrada for válida e o resultado produzido for o esperado, então temos confiança de que a lógica da função (sua implementação) está correta. Tecnicamente falando, testes unitários só nos permitem dizer que a implementação da função está correta para as entradas específicas que testamos. Podem existir entradas para as quais a função não se comporta como esperado. Isso nos ensina duas coisas:

  1. Precisamos testar o máximo possível de entradas, dentro do possível/viável.
  2. Testes unitários nos dão confiança (mas não certeza) de nossa implementação está correta.

Dados os dois aspectos que iremos focar (validação de entradas e checagem do resultado produzido), vejamos como fazer isso na prática.

Validando entradas

Quando criamos uma função, temos em mente um conjunto de entradas para as quais a função irá funcionar. Suponha que a gente queira validar um campo de um formulário que recebe a idade de um usuário. Não faz sentido fornecer um número negativo como idade, então precisamos validar isso.

O ato de validar entradas de uma função é chamado de checagem de pré-condições. Como o nome sugere, pré-condições são condições esperadas para que uma função execute corretamente. Em Python, podemos chegar pré-condições com asserções.

Usando assert para checar pré-condições

O comando assert em Python é uma ferramenta poderosa para verificar se uma expressão é verdadeira. É frequentemente utilizado para verificar pré-condições em funções, garantindo que determinadas condições necessárias para o correto funcionamento do código sejam atendidas. Se a expressão fornecida ao comando assert for avaliada como falsa, uma exceção do tipo AssertionError será criada, abortando a execução do programa.

A sintaxe básica do assert é a seguinte:

assert expressao, "Mensagem de erro (opcional)"

A expressão é a condição que você espera ser verdadeira. Se for falsa, a mensagem de erro (opcional) será exibida na exceção AssertionError.

Vejamos como usar uma asserção para validar pré-condições de uma função. Para isso, criaremos uma função que calcula o Índice de Massa Corporal (IMC) de uma pessoa. Para calcular o IMC, divide-se o peso da pessoa pela sua altura elevada ao quadrado.

def imc(peso, altura):
    return peso / (altura * altura)

Você consegue ver o problema com o código acima? O que acontece se o valor da variável altura for zero? A execução da função será abortada com o seguinte erro: ZeroDivisionError: division by zero, pois é matematicamente impossível dividir por zero.

Uma forma de contornar isso é validar a entrada da função com uma asserção e emitir uma mensagem de erro para o usuário, como ilustrado abaixo:

def imc(peso, altura):
    assert altura > 0, "A altura precisa ser maior que zero!"

    return peso / (altura * altura)

Esse exemplo é bem simples e pequeno, mas você pode imaginar como a validação de entradas pode se tornar mais importante para funções mais complexas.

Validando saídas (resultados)

Ao criar funções, podemos usar asserções como uma forma rudimentar de se criar testes unitários. Por exemplo, antes de criar uma função, podemos escrever um conjunto de asserções para validar que nossa função irá produzir os resultados esperados para entradas específicas. Por exemplo, suponha que a gente precise implementar uma função que, dado um número nota representando a nota de um estudante, converta o valor de nota para um conceito (A, B, C, D, E e F). Esses conceitos funcionam assim:

  • notas entre 90 e 100 recebem o conceito A;
  • notas entre 80 e 89 recebem o coceito B;
  • notas entre 70 e 79 recembem o conceito C;
  • notas entre 60 e 69 recebem o conceito D;
  • notas entre 40 e 59 recebem o conceito E;
  • notas menores que 40 recebem o conceito F.

Antes de implementar a função, podemos criar uma série de asserções para validar o funcionamento da função. Para isso, precisamos criar a assinatura da função, e depois escrever o conjunto de asserções, como ilustrado abaixo:

def converte_nota_em_conceito(nota):
    pass # Indica que a implementação será feita depois

assert converte_nota_em_conceito(100) == "A"
assert converte_nota_em_conceito(90) == "A"
assert converte_nota_em_conceito(89) == "B"
assert converte_nota_em_conceito(75) == "C"
assert converte_nota_em_conceito(63) == "D"
assert converte_nota_em_conceito(41) == "E"
assert converte_nota_em_conceito(35) == "F"
print("Sucesso!")

O exemplo acima ilustra uma forma simples de testar nossa função. Cada uma das asserções pode ser vista como uma forma simples de se criar um “caso de teste”. Se o programa imprimir “Sucesso!”, significa que nenhuma das asserções falhou, portanto todos os “casos de teste” passaram com sucesso. Caso alguma das asserções falhe, obteremos uma mensagem indicando qual delas falhou, e então podemos corrigir a implementação da nossa função até que todos os testes passem.

Vejamos uma possível implementação da função converte_nota_em_conceito.

def converte_nota_em_conceito(nota):
    if nota >= 90:
      return "A"
    elif nota >= 80:
      return "B"
    elif nota >= 70:
      return "C"
    elif nota >= 60:
      return "D"
    elif nota >= 40:
      return "E"
    else:
      return "F"

assert converte_nota_em_conceito(100) == "A"
assert converte_nota_em_conceito(90) == "A"
assert converte_nota_em_conceito(89) == "B"
assert converte_nota_em_conceito(75) == "C"
assert converte_nota_em_conceito(63) == "D"
assert converte_nota_em_conceito(41) == "E"
assert converte_nota_em_conceito(35) == "F"
print("Sucesso!")
Sucesso!

Note que no exemplo acima, checamos o funcionamento da função para entradas válidas, mas não verificamos as pré-condições da função. Podemos melhorar nossa implementação se incluirmos asserções que garantem que a nota passada como parâmetro é positiva e menor ou igual a 100, como mostrado abaixo:

def converte_nota_em_conceito(nota):
    assert nota >= 0 and nota <= 100, "A nota precisa estar entre 0 e 100"

    if nota >= 90:
      return "A"
    elif nota >= 80:
      return "B"
    elif nota >= 70:
      return "C"
    elif nota >= 60:
      return "D"
    elif nota >= 40:
      return "E"
    else:
      return "F"

Com isso, nos guardamos contra entradas inválidas, e podemos focar em validar o resultado da função.

Frameworks de testes unitários

O tópico de testes unitários é imenso. Nessa seção focamos nos princípios básicos do que testar em como escrever testes bem básicos para funções. Porém, existem recursos mais avançados para testes unitários e é importante que você conheça bem pelo menos um framework. Isso será de grande valor quando você for desenvolver programas mais complexos e quando trabalhar como programador em um ambiente profissional.

Hoje em dia, o framework dominante para testes unitários em Python é o pytest.

A boa notícia é que a forma de testes que ensinamos aqui (por meio do commando assert) funciona muito bem com o pytest! Mas o pytest é um framework robusto e possui muitos outros recursos úteis.

Para dar um exemplo concreto de como usar o pytest, suponha que você possua um arquivo chamado notas.py com a definição da função converte_nota_em_conceito vista acima. Dado o que aprendemos nessa seção, para testar um programa com o pytest, você precisa criar um arquivo chamado “test_notas.py” com o seguinte conteúdo:


# Arquivo: test_notas.py

import pytest

from notas import converte_nota_em_conceito

def test_converte_nota_em_conceito():
    assert converte_nota_em_conceito(100) == "A"
    assert converte_nota_em_conceito(90) == "A"
    assert converte_nota_em_conceito(89) == "B"
    assert converte_nota_em_conceito(75) == "C"
    assert converte_nota_em_conceito(63) == "D"
    assert converte_nota_em_conceito(41) == "E"
    assert converte_nota_em_conceito(35) == "F"

Após isso, no diretório contento o arquivo “test_notas.py”, digite python -m pytest test_notas.py. Você deverá ver uma mensagem indicando que o teste passou.

Conclusão

O uso de asserções é útil para se verificar automaticamente se as pré-condições esperadas são atendidas. Isso ajuda a identificar rapidamente problemas em uma fase inicial do desenvolvimento, melhorando a robustez do código.

Asserções também são úteis para validar a saída de funções e são suportadas nativamente em frameworks de teste como o pytest. Se familiarize com o pytest: instale-o, leia o tutorial, e aprenda outros recursos além de asserções. Isso será muito útil em sua jornada de programação em Python!