Python Testes

A Ana escreveu uma função que calcula o preço com desconto. Para saber se ela funciona, rodou o programa, olhou o resultado e pensou "parece certo". Uma semana depois, mexeu na função para aceitar outra coisa e… será que o desconto antigo continua certo? Ela teria de conferir tudo de novo, de cabeça. Testes automáticos resolvem isso: pequenos programas que conferem os seus programas, sozinhos, em um segundo, quantas vezes você quiser.

Você vai aprender
  • O que é um teste: uma pergunta cuja resposta você já sabe.
  • A conferir com assert, e o que o AssertionError quer dizer.
  • A organizar testes com o módulo unittest: TestCase, assertEqual, assertRaises e companhia.
  • A rodar tudo com python3 -m unittest e a ler o relatório: os pontos, o F, o E.
  • A escrever exemplos nas docstrings que viram testes, com doctest.
  • Como é o pytest, a ferramenta de fora mais usada.
  • Os enganos comuns: arquivo com o nome errado, método sem test, sem self, centavos que não fecham.

A ideia: provar a receita antes de servir

Na padaria do bairro, a confeiteira não serve um bolo novo sem provar. E ela prova sempre do mesmo jeito: um pedaço, "está doce o bastante? a massa está fofa?". Ela sabe de antemão como o bolo certo tem de ser, e compara.

Um teste é isso: você chama a sua função com valores que você já sabe a resposta (100 reais com 10% de desconto têm de dar 90) e compara o que a função devolveu com o que devia devolver. Se bater, o teste passa. Se não bater, o teste falha e avisa onde.

FerramentaO que éVem com o Python?
assertUma palavra-chave: confere uma condição numa linhaSim (é da própria linguagem)
unittestUm módulo para organizar muitos testes e rodar todos de uma vezSim (biblioteca padrão)
doctestUm módulo que testa os exemplos escritos nas docstringsSim (biblioteca padrão)
pytestUma ferramenta de terceiros, muito popularNão: instala-se com o pip

1. A conferência mais simples: assert

Primeiro, o jeito "olhômetro": rodar e olhar.

def preco_com_desconto(preco, porcentagem):
    return preco - preco * porcentagem / 100

print(preco_com_desconto(100, 10))
print(preco_com_desconto(50, 20))
$ python3 desconto.py
90.0
40.0

Funciona, mas é você quem tem de olhar e decidir se 90.0 e 40.0 estão certos. O assert ("afirmar", "garantir") faz a comparação por você:

def preco_com_desconto(preco, porcentagem):
    return preco - preco * porcentagem / 100

assert preco_com_desconto(100, 10) == 90
assert preco_com_desconto(50, 20) == 40
assert preco_com_desconto(80, 0) == 80
print("Todos os testes passaram.")
assert
"Garanto que o que vem a seguir é verdade". Se for, o programa segue em silêncio. Se não for, ele para com um erro.
preco_com_desconto(100, 10) == 90
A condição: a função, chamada com 100 e 10, tem de devolver 90. O == compara (veja Comparações). 90.0 == 90 é True: um float e um int com o mesmo valor são iguais.
$ python3 com_assert.py
Todos os testes passaram.

Os três assert passaram calados, e o print do fim rodou. Agora, o que acontece se a função tiver um erro? A Ana, mexendo no código, esqueceu de multiplicar pelo preço:

def preco_com_desconto(preco, porcentagem):
    return preco - porcentagem / 100   # esqueci de multiplicar pelo preço

assert preco_com_desconto(100, 10) == 90
print("Todos os testes passaram.")
$ python3 assert_falha.py
Traceback (most recent call last):
  File "/home/ana/assert_falha.py", line 4, in <module>
    assert preco_com_desconto(100, 10) == 90
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
File "/home/ana/assert_falha.py", line 4
A linha do assert que não se confirmou.
^^^^^^^^^
As setinhas marcam a condição que deu False.
AssertionError
"Erro de afirmação": o que você garantiu não era verdade. O programa parou ali; o print nunca rodou.

O AssertionError sozinho não diz quanto a função devolveu. Dá para pôr uma mensagem depois de uma vírgula:

def preco_com_desconto(preco, porcentagem):
    return preco - porcentagem / 100

resultado = preco_com_desconto(100, 10)
assert resultado == 90, f"esperava 90, veio {resultado}"
$ python3 assert_msg.py
Traceback (most recent call last):
  File "/home/ana/assert_msg.py", line 5, in <module>
    assert resultado == 90, f"esperava 90, veio {resultado}"
           ^^^^^^^^^^^^^^^
AssertionError: esperava 90, veio 99.9

Agora a pista está na mensagem: veio 99.9, ou seja, a função tirou só 0,10 do preço (10 / 100), e não 10%.

O assert pode ser desligado

Quem roda o Python com a opção -O ("otimizar") faz todos os assert sumirem:

$ python3 assert_O.py
Traceback (most recent call last):
  File "/home/ana/assert_O.py", line 1, in <module>
    assert 1 == 2, "isso é falso"
           ^^^^^^
AssertionError: isso é falso
$ python3 -O assert_O.py
cheguei ao fim

Por isso, o assert serve para testes e para conferências do programador, nunca para validar o que o usuário digitou (para isso existem o if e o raise).

Missão: a função media() calcula a média das notas da turma, mas o primeiro assert falha. Leia o erro, descubra o engano na função e conserte (não mexa nos assert).

def media(notas):
    return sum(notas) / 2

assert media([8, 6]) == 7
assert media([10, 7, 4]) == 7
assert media([5]) == 5
print("Todos os testes passaram.")
def media(notas):
    return sum(notas) / len(notas)

assert media([8, 6]) == 7
assert media([10, 7, 4]) == 7
assert media([5]) == 5
print("Todos os testes passaram.")

(Repare: com duas notas, / 2 até acerta. Foi preciso um teste com três notas para pegar o erro. Bons testes experimentam casos diferentes.)


2. Organizando os testes: unittest

Com assert solto, o primeiro teste que falha para o programa, e você não fica sabendo dos outros. O módulo unittest ("teste de unidade": cada teste confere uma pequena parte, uma unidade) roda todos os testes, conta quantos passaram e mostra um relatório de cada falha.

O costume é separar em dois arquivos: o código de verdade e os testes dele. O código da lojinha:

"""Funções da lojinha da Ana."""


def preco_com_desconto(preco, porcentagem):
    """Devolve o preço depois de aplicar o desconto (em %)."""
    if not 0 <= porcentagem <= 100:
        raise ValueError("a porcentagem deve ficar entre 0 e 100")
    return preco - preco * porcentagem / 100


def total_do_carrinho(itens):
    """Soma preço × quantidade de cada item do carrinho."""
    return sum(preco * quantidade for preco, quantidade in itens)


def etiqueta(nome, preco):
    """Monta o texto da etiqueta de preço."""
    return f"{nome.upper()} - R$ {preco:.2f}".replace(".", ",")

E os testes, num arquivo cujo nome começa com test:

import unittest

from loja import etiqueta, preco_com_desconto, total_do_carrinho


class TestDesconto(unittest.TestCase):

    def test_dez_por_cento(self):
        self.assertEqual(preco_com_desconto(100, 10), 90)

    def test_sem_desconto(self):
        self.assertEqual(preco_com_desconto(80, 0), 80)

    def test_porcentagem_invalida(self):
        with self.assertRaises(ValueError):
            preco_com_desconto(100, 150)


class TestCarrinho(unittest.TestCase):

    def test_carrinho_vazio(self):
        self.assertEqual(total_do_carrinho([]), 0)

    def test_dois_itens(self):
        self.assertEqual(total_do_carrinho([(4.50, 2), (10.00, 1)]), 19.00)


class TestEtiqueta(unittest.TestCase):

    def test_etiqueta(self):
        self.assertEqual(etiqueta("café", 18.9), "CAFÉ - R$ 18,90")

    def test_comeca_com_o_nome(self):
        self.assertStartsWith(etiqueta("arroz", 27.9), "ARROZ")


if __name__ == "__main__":
    unittest.main()
from loja import …
Os testes importam as funções do loja.py, como qualquer módulo seu. Os dois arquivos ficam na mesma pasta.
class TestDesconto(unittest.TestCase):
Uma classe que herda de unittest.TestCase ("caso de teste"). Ela é só uma caixa para juntar testes do mesmo assunto. Você não precisa saber criar classes para usá-la: copie a forma.
def test_dez_por_cento(self):
Cada teste é um método cujo nome começa com test. É assim que o unittest o encontra. O self é obrigatório.
self.assertEqual(a, b)
"Confira que a é igual a b". Se não for, o teste falha e o relatório mostra os dois valores.
with self.assertRaises(ValueError):
"Confira que o código dentro do bloco lança um ValueError". Aqui, o teste passa quando dá erro: uma porcentagem de 150% tem de ser recusada.
self.assertStartsWith(texto, início) 3.14
"Confira que o texto começa com…". É novo no Python 3.14, junto com assertEndsWith; num Python mais antigo, use self.assertTrue(texto.startswith("ARROZ")).
unittest.main()
Roda os testes do arquivo quando você faz python3 test_loja.py. O if __name__ == "__main__": faz isso acontecer só quando o arquivo é rodado direto, e não quando é importado.

Rodando: python3 -m unittest

Na pasta dos dois arquivos, o comando python3 -m unittest procura sozinho todos os arquivos test*.py, todas as classes de teste e todos os métodos test…, e roda tudo:

$ python3 -m unittest
.......
----------------------------------------------------------------------
Ran 7 tests in 0.000s

OK
.......
Um ponto para cada teste que passou. Sete testes, sete pontos.
Ran 7 tests in 0.000s
"Rodei 7 testes em 0,000 segundo." O tempo muda de uma vez para outra.
OK
Todos passaram. No terminal do Python 3.14, os pontos e o OK aparecem em verde 3.14.

Com -v ("verboso", falante), aparece o nome de cada teste:

$ python3 -m unittest -v
test_carrinho_vazio (test_loja.TestCarrinho.test_carrinho_vazio) ... ok
test_dois_itens (test_loja.TestCarrinho.test_dois_itens) ... ok
test_dez_por_cento (test_loja.TestDesconto.test_dez_por_cento) ... ok
test_porcentagem_invalida (test_loja.TestDesconto.test_porcentagem_invalida) ... ok
test_sem_desconto (test_loja.TestDesconto.test_sem_desconto) ... ok
test_comeca_com_o_nome (test_loja.TestEtiqueta.test_comeca_com_o_nome) ... ok
test_etiqueta (test_loja.TestEtiqueta.test_etiqueta) ... ok

----------------------------------------------------------------------
Ran 7 tests in 0.000s

OK

Cada linha diz o teste, o arquivo, a classe e o resultado. (O unittest roda as classes e os testes em ordem alfabética, e não na ordem do arquivo; por isso um teste nunca deve depender de outro.)

Lendo o relatório quando algo falha

Agora a Ana "melhorou" o loja.py e, sem perceber, a soma do carrinho passou a ignorar a quantidade:

def total_do_carrinho(itens):
    """Soma preço × quantidade de cada item do carrinho."""
    return sum(preco for preco, quantidade in itens)   # esqueceu a quantidade

E, num teste, ela escreveu a porcentagem entre aspas: preco_com_desconto(80, "0"). Rodando os testes de novo:

$ python3 -m unittest
.F..E..
======================================================================
ERROR: test_sem_desconto (test_loja.TestDesconto.test_sem_desconto)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ana/test_loja.py", line 12, in test_sem_desconto
    self.assertEqual(preco_com_desconto(80, "0"), 80)
                     ~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/home/ana/loja.py", line 6, in preco_com_desconto
    if not 0 <= porcentagem <= 100:
           ^^^^^^^^^^^^^^^^^^^^^^^
TypeError: '<=' not supported between instances of 'int' and 'str'

======================================================================
FAIL: test_dois_itens (test_loja.TestCarrinho.test_dois_itens)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ana/test_loja.py", line 25, in test_dois_itens
    self.assertEqual(total_do_carrinho([(4.50, 2), (10.00, 1)]), 19.00)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 14.5 != 19.0

----------------------------------------------------------------------
Ran 7 tests in 0.001s

FAILED (failures=1, errors=1)
  1. .F..E..: um sinal por teste, na ordem em que rodaram. . passou; F (failure, "falha") quer dizer que um assert… não se confirmou; E (error, "erro") quer dizer que o teste nem chegou ao fim, porque estourou uma exceção no meio.
  2. ERROR: test_sem_desconto: o traceback mostra o caminho, de cima para baixo: o teste (linha 12 do test_loja.py) chamou a função, e dentro dela (linha 6 do loja.py) a comparação 0 <= "0" deu TypeError: "<= não funciona entre int e str". O erro está no teste, que passou um texto.
  3. FAIL: test_dois_itens: aqui a função rodou até o fim, mas o resultado não bateu: 14.5 != 19.0 ("14,5 é diferente de 19,0"). Do lado esquerdo, o que a função devolveu; do direito, o esperado. 4,50 + 10,00 = 14,50: a quantidade foi esquecida. O erro está no código.
  4. FAILED (failures=1, errors=1): o resumo final, "falhou: uma falha e um erro". Os outros cinco testes passaram e foram contados mesmo assim.
F ou E: onde procurar

Um F diz "a resposta veio errada": olhe o valor que veio, e descubra quem errou a conta (o código ou o valor esperado no teste). Um E diz "o teste quebrou no meio": leia o traceback como qualquer outro erro do Python (veja Lendo uma Mensagem de Erro).

Outras formas de rodar

$ python3 test_loja.py TestDesconto
..E
======================================================================
ERROR: test_sem_desconto (__main__.TestDesconto.test_sem_desconto)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ana/test_loja.py", line 12, in test_sem_desconto
    self.assertEqual(preco_com_desconto(80, "0"), 80)
                     ~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/home/ana/loja.py", line 6, in preco_com_desconto
    if not 0 <= porcentagem <= 100:
           ^^^^^^^^^^^^^^^^^^^^^^^
TypeError: '<=' not supported between instances of 'int' and 'str'

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (errors=1)

Rodando o arquivo direto (graças ao unittest.main()), e só a classe TestDesconto: 3 testes. O arquivo aparece como __main__, porque foi ele que você mandou rodar.

ComandoO que roda
python3 -m unittestTodos os arquivos test*.py da pasta atual (e das subpastas que são pacotes)
python3 -m unittest -vO mesmo, com o nome de cada teste
python3 -m unittest test_lojaSó o arquivo test_loja.py (sem o .py)
python3 -m unittest test_loja.TestDescontoSó uma classe
python3 -m unittest test_loja.TestDesconto.test_dez_por_centoSó um teste
python3 test_loja.pyO arquivo, se ele tiver unittest.main() no fim

O comando termina com o código de saída 0 quando tudo passa e 1 quando algo falha (o echo $? mostra o código; veja os e sys). É assim que outros programas, como os servidores que testam o código a cada mudança, sabem se deu certo.

Missão: a função parcelas() já tem um teste. Escreva mais dois: um que confira que parcelas(90, 3) dá 30.0, e outro, com assertRaises, que confira que parcelas(90, 0) lança ValueError. O relatório tem de dizer Ran 3 tests e OK.

import unittest


def parcelas(total, vezes):
    if vezes < 1:
        raise ValueError("o número de parcelas deve ser pelo menos 1")
    return total / vezes


class TestParcelas(unittest.TestCase):

    def test_duas_vezes(self):
        self.assertEqual(parcelas(100, 2), 50)

    # escreva aqui os outros dois testes


unittest.main()
import unittest


def parcelas(total, vezes):
    if vezes < 1:
        raise ValueError("o número de parcelas deve ser pelo menos 1")
    return total / vezes


class TestParcelas(unittest.TestCase):

    def test_duas_vezes(self):
        self.assertEqual(parcelas(100, 2), 50)

    def test_tres_vezes(self):
        self.assertEqual(parcelas(90, 3), 30.0)

    def test_zero_vezes(self):
        with self.assertRaises(ValueError):
            parcelas(90, 0)


unittest.main()

No editor, a função e os testes ficam no mesmo arquivo, e o unittest.main() no fim roda os testes. No seu computador, prefira separar em loja.py e test_loja.py.

As conferências mais usadas

MétodoPassa quando…
assertEqual(a, b) / assertNotEqual(a, b)a == b / a != b
assertTrue(x) / assertFalse(x)x é verdadeiro / falso
assertIs(a, b) / assertIsNone(x)a is b / x is None
assertIn(a, b) / assertNotIn(a, b)a in b / a not in b
assertIsInstance(x, tipo)x é do tipo dado
assertAlmostEqual(a, b)a e b são iguais até a 7ª casa decimal (para float)
assertGreater(a, b) / assertLess(a, b)a > b / a < b
with assertRaises(Erro):O bloco lança aquele erro
assertStartsWith(t, s) / assertEndsWith(t, s) 3.14O texto t começa / termina com s
assertHasAttr(obj, nome) 3.14O objeto tem o atributo com esse nome

Por que não usar assert dentro do TestCase? Funciona, mas os métodos assert… dão mensagens melhores: o assertEqual mostra os dois valores (14.5 != 19.0), o assertIn mostra o item e a lista.


3. Testes nos exemplos da docstring: doctest

Lembra das docstrings, os textos de explicação no começo das funções? Muita gente põe nelas um exemplo de uso, copiado do modo interativo (>>>). O módulo doctest transforma esses exemplos em testes: ele roda cada linha com >>> e confere se a resposta é exatamente a que está escrita embaixo.

def preco_com_desconto(preco, porcentagem):
    """Devolve o preço depois de aplicar o desconto (em %).

    >>> preco_com_desconto(100, 10)
    90.0
    >>> preco_com_desconto(50, 20)
    40.0
    >>> preco_com_desconto(100, 150)
    Traceback (most recent call last):
        ...
    ValueError: a porcentagem deve ficar entre 0 e 100
    """
    if not 0 <= porcentagem <= 100:
        raise ValueError("a porcentagem deve ficar entre 0 e 100")
    return preco - preco * porcentagem / 100


def parcelas(total, vezes):
    """Divide o total em parcelas iguais, arredondadas em centavos.

    >>> parcelas(100, 4)
    [25.0, 25.0, 25.0, 25.0]
    >>> parcelas(10, 3)
    [3.33, 3.33, 3.33]
    """
    return [round(total / vezes, 2)] * vezes


if __name__ == "__main__":
    import doctest
    doctest.testmod()
>>> preco_com_desconto(100, 10)
O que seria digitado no modo interativo. O >>> e um espaço, depois o código.
90.0
A resposta esperada, como o Python a mostraria: 90.0, e não 90. O doctest compara o texto.
Traceback (most recent call last): … ValueError: …
Para esperar um erro, escreva a primeira linha do traceback, ... no lugar do meio, e a última linha.
doctest.testmod()
"Teste o módulo": roda os exemplos de todas as docstrings deste arquivo.
$ python3 -m doctest precos.py
$ echo $?
0
$ python3 precos.py

Silêncio quer dizer que tudo passou. Tanto python3 -m doctest precos.py quanto python3 precos.py (que chama o testmod()) não mostram nada. Para ver o que ele fez, use -v:

$ python3 -m doctest -v precos.py
Trying:
    parcelas(100, 4)
Expecting:
    [25.0, 25.0, 25.0, 25.0]
ok
Trying:
    parcelas(10, 3)
Expecting:
    [3.33, 3.33, 3.33]
ok
Trying:
    preco_com_desconto(100, 10)
Expecting:
    90.0
ok
Trying:
    preco_com_desconto(50, 20)
Expecting:
    40.0
ok
Trying:
    preco_com_desconto(100, 150)
Expecting:
    Traceback (most recent call last):
        ...
    ValueError: a porcentagem deve ficar entre 0 e 100
ok
1 item had no tests:
    precos
2 items passed all tests:
   2 tests in precos.parcelas
   3 tests in precos.preco_com_desconto
5 tests in 3 items.
5 passed.
Test passed.

"Tentando… Esperando… ok", para cada exemplo. No fim: "1 item não tinha testes" (o próprio módulo, que não tem docstring), "2 itens passaram em todos os testes", "5 testes, 5 passaram".

Quando um exemplo não bate

A Ana reparou que 3 parcelas de 3,33 dão 9,99, e não 10. Ela escreveu na docstring o que queria (a última parcela com o centavo que sobra) e, por descuido, escreveu 25 em vez de 25.0 no outro exemplo:

def parcelas(total, vezes):
    """Divide o total em parcelas iguais, arredondadas em centavos.

    >>> parcelas(10, 3)
    [3.33, 3.33, 3.34]
    >>> parcelas(100, 4)
    [25, 25, 25, 25]
    """
    return [round(total / vezes, 2)] * vezes
$ python3 -m doctest precos_erro.py
**********************************************************************
File "/home/ana/precos_erro.py", line 4, in precos_erro.parcelas
Failed example:
    parcelas(10, 3)
Expected:
    [3.33, 3.33, 3.34]
Got:
    [3.33, 3.33, 3.33]
**********************************************************************
File "/home/ana/precos_erro.py", line 6, in precos_erro.parcelas
Failed example:
    parcelas(100, 4)
Expected:
    [25, 25, 25, 25]
Got:
    [25.0, 25.0, 25.0, 25.0]
**********************************************************************
1 item had failures:
   2 of   2 in precos_erro.parcelas
***Test Failed*** 2 failures.

Cada falha diz a linha, o exemplo, o esperado (Expected) e o que veio (Got). A primeira é uma falha "de verdade": a função ainda não faz o que a Ana quer. A segunda é só de escrita: [25, 25, 25, 25] e [25.0, 25.0, 25.0, 25.0] são iguais para o ==, mas não como texto, e o doctest compara texto. É a grande fraqueza dele: um espaço a mais, a ordem dos itens de um conjunto, um endereço de memória (0x7f…), e o teste falha.

Missão: o exemplo da docstring de etiqueta() está com a resposta errada, e o doctest reclama. Rode, veja o que veio (Got) e corrija a resposta esperada na docstring.

def etiqueta(nome, preco):
    """Monta o texto da etiqueta de preço.

    >>> etiqueta("café", 18.9)
    'Café - R$ 18.90'
    """
    return f"{nome.upper()} - R$ {preco:.2f}".replace(".", ",")


import doctest
doctest.testmod(verbose=True)
def etiqueta(nome, preco):
    """Monta o texto da etiqueta de preço.

    >>> etiqueta("café", 18.9)
    'CAFÉ - R$ 18,90'
    """
    return f"{nome.upper()} - R$ {preco:.2f}".replace(".", ",")


import doctest
doctest.testmod(verbose=True)

4. Uma espiada no pytest

O pytest não vem com o Python, mas é a ferramenta de testes mais usada nos projetos de hoje. Nele, um teste é uma função comum com nome começando por test_, e a conferência é um assert simples, sem classe e sem self.assertEqual:

from loja import preco_com_desconto, total_do_carrinho


def test_dez_por_cento():
    assert preco_com_desconto(100, 10) == 90


def test_dois_itens():
    assert total_do_carrinho([(4.50, 2), (10.00, 1)]) == 19.00


def test_desconto_grande():
    assert preco_com_desconto(200, 25) == 140

A Ana instalou o pytest num ambiente virtual (no Ubuntu, o pip fora de um ambiente virtual é bloqueado; veja pip) e rodou. O último teste está errado de propósito (25% de 200 é 50, e 200 − 50 = 150):

$ python3 -m venv .venv
$ .venv/bin/pip install -q pytest
$ .venv/bin/pytest --version
pytest 9.1.1
$ .venv/bin/pytest test_pytest.py
============================= test session starts ==============================
platform linux -- Python 3.14.4, pytest-9.1.1, pluggy-1.7.0
rootdir: /home/ana
collected 3 items

test_pytest.py ..F                                                       [100%]

=================================== FAILURES ===================================
_____________________________ test_desconto_grande _____________________________

    def test_desconto_grande():
>       assert preco_com_desconto(200, 25) == 140
E       assert 150.0 == 140
E        +  where 150.0 = preco_com_desconto(200, 25)

test_pytest.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_pytest.py::test_desconto_grande - assert 150.0 == 140
========================= 1 failed, 2 passed in 0.03s ==========================

Os mesmos pontos e o mesmo F. A diferença está no relatório: o > aponta a linha que falhou, e as linhas com E mostram de onde veio cada valor ("150.0, que é o que preco_com_desconto(200, 25) devolveu"), mesmo com um assert comum. O pytest também roda, sem mudar nada, os testes escritos com unittest (.venv/bin/pytest test_loja.py dá 7 passed). (A versão do pytest e o tempo mudam com o tempo; o que importa é o formato.) As missões deste guia usam o unittest, que vem com o Python.


Formas diferentes de testar

FormaVantagemDesvantagemQuando usar
assert soltoUma linha, nada para aprenderPara no primeiro erro; some com -OConferências rápidas, exercícios
unittestVem com o Python; roda tudo e mostra um relatórioMais "cerimônia": classe, self, assertEqualOs testes de um programa de verdade, sem instalar nada
doctestO exemplo da documentação nunca fica desatualizadoCompara texto: frágil com float, espaços, endereçosExemplos curtos nas docstrings
pytestassert simples com relatório detalhado; muitos recursosPrecisa instalarProjetos maiores; o mais usado no mercado

Quando dá errado

"NO TESTS RAN": o arquivo com o nome errado

A Ana guardou os testes num arquivo chamado verifica_loja.py e rodou:

$ python3 -m unittest

----------------------------------------------------------------------
Ran 0 tests in 0.000s

NO TESTS RAN
$ echo $?
5

"Rodei 0 testes. Nenhum teste rodou." E o código de saída é 5, o jeito do unittest avisar "não achei nada". A descoberta automática só olha arquivos cujo nome casa com test*.py. Como consertar: renomeie para test_loja.py (ou rode o arquivo pelo nome: python3 -m unittest verifica_loja). Curiosidade: testes_loja.py funciona, porque "testes" também começa com "test".

O teste que nunca roda
import unittest

from loja import preco_com_desconto


class TestDesconto(unittest.TestCase):

    def test_dez_por_cento(self):
        self.assertEqual(preco_com_desconto(100, 10), 90)

    def verifica_sem_desconto(self):
        self.assertEqual(preco_com_desconto(80, 0), 1000)   # errado de propósito
$ python3 -m unittest -v test_esquecido
test_dez_por_cento (test_esquecido.TestDesconto.test_dez_por_cento) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

OK, mas o segundo teste (que está errado de propósito: 80 sem desconto não dá 1000) nem rodou. O método se chama verifica_…, e o unittest só roda métodos que começam com test. É o erro mais perigoso: tudo parece verde. Como consertar: def test_sem_desconto(self):. Dica: confira se o número em Ran N tests é o número de testes que você escreveu.

Esqueceu o self
import unittest

from loja import preco_com_desconto


class TestDesconto(unittest.TestCase):

    def test_dez_por_cento():
        self.assertEqual(preco_com_desconto(100, 10), 90)
$ python3 -m unittest test_sem_self
E
======================================================================
ERROR: test_dez_por_cento (test_sem_self.TestDesconto.test_dez_por_cento)
----------------------------------------------------------------------
TypeError: TestDesconto.test_dez_por_cento() takes 0 positional arguments but 1 was given

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

"test_dez_por_cento() recebe 0 argumentos, mas foi dado 1." O unittest chama o método passando o objeto do teste (é isso que o self recebe), e o método não tinha lugar para ele. Repare que o resultado é E, não F: o teste quebrou antes de conferir qualquer coisa. Como consertar: def test_dez_por_cento(self):.

Centavos que não fecham: assertEqual com float
import unittest

from loja import preco_com_desconto


class TestCentavos(unittest.TestCase):

    def test_igual(self):
        self.assertEqual(0.1 + 0.2, 0.3)

    def test_quase_igual(self):
        self.assertAlmostEqual(0.1 + 0.2, 0.3)

    def test_desconto_quebrado(self):
        self.assertAlmostEqual(preco_com_desconto(19.90, 15), 16.915)
$ python3 -m unittest test_centavos
.F.
======================================================================
FAIL: test_igual (test_centavos.TestCentavos.test_igual)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ana/test_centavos.py", line 9, in test_igual
    self.assertEqual(0.1 + 0.2, 0.3)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
AssertionError: 0.30000000000000004 != 0.3

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

Para o computador, 0.1 + 0.2 dá 0.30000000000000004: os números com casas decimais são guardados com um errinho minúsculo (a aula Números explica por quê). O assertEqual exige igualdade exata e falha. Como consertar: para float, use assertAlmostEqual, que compara até a 7ª casa decimal; os outros dois testes passaram assim.

Missão: os testes acharam um erro em total_do_carrinho(). Leia o relatório (o F), descubra o que está errado na função e conserte. Os testes estão certos: não mexa neles.

import unittest


def total_do_carrinho(itens):
    """Soma preço × quantidade de cada item do carrinho."""
    total = 0
    for preco, quantidade in itens:
        total = preco * quantidade
    return total


class TestCarrinho(unittest.TestCase):

    def test_vazio(self):
        self.assertEqual(total_do_carrinho([]), 0)

    def test_um_item(self):
        self.assertEqual(total_do_carrinho([(4.50, 2)]), 9.00)

    def test_tres_itens(self):
        self.assertEqual(total_do_carrinho([(4.50, 2), (10.00, 1), (2.00, 3)]), 25.00)


unittest.main()
import unittest


def total_do_carrinho(itens):
    """Soma preço × quantidade de cada item do carrinho."""
    total = 0
    for preco, quantidade in itens:
        total += preco * quantidade
    return total


class TestCarrinho(unittest.TestCase):

    def test_vazio(self):
        self.assertEqual(total_do_carrinho([]), 0)

    def test_um_item(self):
        self.assertEqual(total_do_carrinho([(4.50, 2)]), 9.00)

    def test_tres_itens(self):
        self.assertEqual(total_do_carrinho([(4.50, 2), (10.00, 1), (2.00, 3)]), 25.00)


unittest.main()

Referência rápida

EscritaO que faz
assert condiçãoPara com AssertionError se a condição for falsa
assert condição, "mensagem"O mesmo, com uma mensagem
class TestAlgo(unittest.TestCase):Uma caixa de testes do unittest
def test_algo(self):Um teste (o nome tem de começar com test)
self.assertEqual(veio, esperado)Confere igualdade e mostra os dois valores se falhar
with self.assertRaises(ValueError):Confere que o bloco lança o erro
unittest.main()Roda os testes do arquivo
python3 -m unittest [-v] [alvo]Descobre e roda os testes (test*.py)
>>> código + resposta, na docstringUm exemplo que o doctest confere
python3 -m doctest [-v] arquivo.pyRoda os exemplos das docstrings (silêncio = passou)
doctest.testmod()O mesmo, de dentro do programa
No relatórioQuer dizer
.Um teste passou
F / FAIL:Falha: um assert… não se confirmou (a resposta veio errada)
E / ERROR:Erro: o teste estourou uma exceção antes de terminar
sTeste pulado de propósito (@unittest.skip)
OK / FAILED (failures=…, errors=…)Tudo passou / quantos falharam
NO TESTS RANNenhum teste encontrado: confira os nomes (test*.py, test…)
Expected: / Got:No doctest: o que estava escrito / o que veio

Exercícios

1. O método de igualdade

Complete o teste que confere que dobro(4) dá 8:

self.(dobro(4), 8)

2. O comando

Complete o comando que descobre e roda todos os testes da pasta:

python3 -m

3. O relatório mostrou ..E.. O que aconteceu no terceiro teste?

4. Num TestCase, qual destes métodos o unittest não roda?

5. Um doctest espera [25, 25] e a função devolve [25.0, 25.0]. O que acontece?

6. Para comparar 0.1 + 0.2 com 0.3 num teste, o melhor é:

Missão final: os testes da agenda

Missão: a função formatar_telefone() recebe só os números de um celular e devolve o telefone formatado. Escreva quatro testes para ela num TestCase: o exemplo do comentário; um telefone que já vem com espaços (" 86999990002 "); um com número de menos (tem de lançar ValueError); e um com letras (também ValueError). O relatório tem de dizer Ran 4 tests e OK.

import unittest


def formatar_telefone(numeros):
    """'86999990001' vira '(86) 99999-0001'."""
    numeros = numeros.strip()
    if len(numeros) != 11 or not numeros.isdigit():
        raise ValueError(f"telefone inválido: {numeros!r}")
    return f"({numeros[:2]}) {numeros[2:7]}-{numeros[7:]}"


# escreva aqui a classe com os quatro testes


unittest.main()
import unittest


def formatar_telefone(numeros):
    """'86999990001' vira '(86) 99999-0001'."""
    numeros = numeros.strip()
    if len(numeros) != 11 or not numeros.isdigit():
        raise ValueError(f"telefone inválido: {numeros!r}")
    return f"({numeros[:2]}) {numeros[2:7]}-{numeros[7:]}"


class TestTelefone(unittest.TestCase):

    def test_formata(self):
        self.assertEqual(formatar_telefone("86999990001"), "(86) 99999-0001")

    def test_com_espacos(self):
        self.assertEqual(formatar_telefone(" 86999990002 "), "(86) 99999-0002")

    def test_curto_demais(self):
        with self.assertRaises(ValueError):
            formatar_telefone("8699999")

    def test_com_letras(self):
        with self.assertRaises(ValueError):
            formatar_telefone("86abc990001")


unittest.main()

Agora faça uma experiência: "estrague" a função (troque numeros[7:] por numeros[8:]) e rode de novo. Os testes pegam o defeito na hora. É para isso que eles servem.


Resumo

  • Um teste chama o seu código com valores de resposta conhecida e compara. Passou, silêncio (ou um ponto); falhou, um relatório.
  • assert condição, "mensagem" é a conferência mais simples; para no primeiro erro e some com python3 -O.
  • unittest: classes que herdam de unittest.TestCase, métodos test…(self), conferências self.assertEqual, assertRaises, assertAlmostEqual…
  • python3 -m unittest acha os arquivos test*.py e roda tudo. No relatório: . passou, F resposta errada, E exceção no meio. Confira o Ran N tests.
  • doctest testa os exemplos >>> das docstrings, comparando o texto. Silêncio quer dizer que passou.
  • pytest (instalado num venv) usa funções e assert simples, com um relatório detalhado.