“Um framework de testes que executa arquivos e funções, testando o comportamento real do seu projeto.”
Pytest — Testing Framework
Pytest é um framework profissional para testes em Python. Ele percorre o projeto em busca de arquivos e funções de teste, executa cada um e reporta o que passou e o que falhou — a base de qualquer fluxo sério de qualidade de código.
Instalação
A instalação pode ser feita com o uv, adicionando o Pytest como dependência de desenvolvimento, ou via pip. Para confirmar que a instalação funcionou, verificamos a versão instalada.
uv add --dev pytest# oupip install pytestpytest --version# ouuv run pytest --version
Como o pytest encontra os testes
Para o Pytest encontrar os testes dentro do projeto, o arquivo precisa começar com test_, e as funções de teste também precisam começar com test_. Abaixo, um arquivo test_sample.py com uma função func que recebe um parâmetro x e retorna x + 1, e um teste que chama func(3) e verifica (assert) se o retorno é igual a 4.
def func(x):return x + 1def test_answer():assert func(3) == 4
Testes unitários e o vocabulário básico
Testes unitários têm por finalidade testar funções, classes e módulos do software de forma isolada, garantindo que os menores trechos de código também estejam funcionando corretamente. Antes de ir além do que já vimos, vale conhecer um termo que aparece o tempo todo: SUT, sigla para system under test — o sistema sob teste. De forma resumida, é o componente ou objeto que está sendo testado, a coisa que vai ser testada de fato. Dentro da terminologia de testes, existem duas taxonomias conhecidas para estruturar um teste: o modelo AAA e o modelo de 4 fases.
AAA versus o modelo de 4 fases
AAA significa Arrange, Act, Assert. De forma didática: o Arrange é o arranjo geral do teste, a montagem ou setup; o Act é a execução de fato, o exercício; e o Assert é a verificação final. Por outro lado, existe o modelo de 4 fases: Setup, Exercise, Verify e Teardown. As três primeiras fases fazem basicamente a mesma coisa que o modelo AAA, porém com uma fase extra de Teardown, que consiste numa limpeza pós-verificação — por exemplo, apagar um usuário de teste que havia sido criado no banco de dados. Por isso, geralmente usamos o modelo AAA em testes unitários, enquanto o modelo de 4 fases aparece mais em testes de integração, para limpar os 'lixos' deixados após o teste, como no exemplo do banco de dados.
Colocando o AAA em prática
Para exemplificar, vamos escrever uma função simples: ela recebe o código de uma resposta HTTP e devolve uma categoria — 'Sucesso' para códigos entre 200 e 299, 'Erro do cliente' para 400 a 499, 'Erro do servidor' para 500 a 599, e 'Desconhecido' para qualquer outro valor.
def classificar_status_http(codigo: int) -> str:"""Recebe o código de uma resposta HTTP e devolve uma categoria:- 200 a 299 -> "Sucesso"- 400 a 499 -> "Erro do cliente"- 500 a 599 -> "Erro do servidor"- qualquer outro valor -> "Desconhecido""""if 200 <= codigo <= 299:return "Sucesso"elif 400 <= codigo <= 499:return "Erro do cliente"elif 500 <= codigo <= 599:return "Erro do servidor"else:return "Desconhecido"
Testando o caminho de sucesso
No Arrange, montamos os valores iniciais: codigo_de_input, o que vamos passar para a função, e esperado, o que esperamos receber de volta. A variável resultado é o Act: ela realmente chama classificar_status_http passando o código, e a função é executada. Por fim, o Assert é a verificação: confere se resultado bate com esperado.
def test_classificar_status_http_deve_retornar_sucesso():# Arrangecodigo_de_input = 200esperado = "Sucesso"# Actresultado = classificar_status_http(codigo_de_input)# Assertassert resultado == esperado
Testando um erro do cliente
Mesma estrutura, só muda o Arrange: dessa vez o código de entrada é 404, e o esperado passa a ser 'Erro do cliente'. O Act e o Assert seguem exatamente a mesma lógica do teste anterior.
def test_classificar_status_http_deve_retornar_erro_do_cliente():# Arrangecodigo_de_input = 404esperado = "Erro do cliente"# Actresultado = classificar_status_http(codigo_de_input)# Assertassert resultado == esperado
Testando orientação a objetos
Num arquivo pedido.py, temos uma classe ItemCardapio (com nome e preço) e uma classe Pedido, que guarda uma lista de itens e tem métodos para adicionar item e calcular o total — com um cupom de desconto percentual opcional.
class ItemCardapio:def __init__(self, nome, preco):self.nome = nomeself.preco = precoclass Pedido:def __init__(self):self.itens = []def adicionar_item(self, item):self.itens.append(item)def calcular_total(self, cupom_percentual=0):total = sum(item.preco for item in self.itens)valor_desconto = total * (cupom_percentual / 100)return total - valor_desconto
Testando o total sem cupom
Montamos um pedido, criamos dois itens (10 e 20 reais), adicionamos os dois e chamamos calcular_total(). Se o resultado bater com 30.0 — a soma dos dois —, o teste passa.
from pedido import ItemCardapio, Pedidodef test_calcular_total_sem_cupom():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)total = pedido.calcular_total()assert total == 30.0
Testando o total com cupom
Mesma ideia, mas passando cupom_percentual=10. 10% de 30 reais é 3, então o esperado com desconto é 27.0 — confirmando que a conta do desconto também está certa.
def test_calcular_total_com_cupom():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)total_com_cupom = pedido.calcular_total(cupom_percentual=10)assert total_com_cupom == 27.0
Rodando os testes
Rodamos com uv run pytest ou apenas pytest, dependendo de como foi instalado. O Pytest percorre o diretório atual em busca de arquivos test_*.py e funções test_*, executa cada um e reporta o resultado. Os dois pontinhos (..) representam os dois testes que passaram.
uv run pytest# ================================= test session starts =================================# platform linux -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0# rootdir: /home/dan/projects/pytest# configfile: pyproject.toml# collected 2 items## test_pedido.py .. [100%]## ================================== 2 passed in 0.01s ==================================
Testando regra de negócio
Pytest não serve só para conferir se a função devolve o valor certo — também dá para testar regra de negócio. Imagine alguém aplicando um cupom de 120%: do jeito que calcular_total está, ela aceita numa boa e o pedido fica com valor negativo, o que não faz sentido.
def test_cupom_alto_nao_gera_valor_negativo():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)total_pedido = pedido.calcular_total(cupom_percentual=90)assert total_pedido >= 0
O que esse teste expõe
Os dois itens somam 30 reais. Aqui usamos assert para checar se total_pedido é maior ou igual a 0. Com 90% ainda dá certo, mas se alguém testar com 120%, o resultado vira -6 e o teste falharia — expondo que a função precisa de uma validação.
# com cupom_percentual=120:# total_pedido = -6.0# assert total_pedido >= 0 -> AssertionError
Testando uma exceção com pytest.raises
Ajustamos calcular_total para bloquear cupom fora do intervalo 0-100:
def calcular_total(self, cupom_percentual=0):total = sum(item.preco for item in self.itens)if cupom_percentual < 0 or cupom_percentual > 100:raise ValueError("O cupom de desconto deve estar entre 0 e 100.")valor_desconto = total * (cupom_percentual / 100)return total - valor_desconto
Confirmando que a exceção é lançada
Para testar que o erro é realmente lançado quando o valor é inválido, a gente não usa assert — usa pytest.raises dentro de um with. O with pytest.raises(ValueError) diz 'dentro desse bloco, espero que um ValueError aconteça'. Se a exceção for lançada, o teste passa; se não for (ou for de outro tipo), falha com DID NOT RAISE.
import pytestfrom pedido import ItemCardapio, Pedidodef test_cupom_fora_do_intervalo():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)with pytest.raises(ValueError):pedido.calcular_total(cupom_percentual=120)
Vale só uma chamada por bloco: assim que a exceção é lançada, o with é interrompido na hora. Se você colocar mais de uma chamada ali dentro e a primeira já lançar o erro, a segunda nunca roda — e você nem percebe.
Capturando a mensagem da exceção com as
Dá para ir além de só confirmar que 'algum ValueError' foi lançado — também dá para capturar a exceção e conferir a mensagem dela. exc_info guarda a exceção capturada; exc_info.value é o objeto da exceção em si, então dá para usar assert normal em cima da mensagem.
def test_cupom_fora_do_intervalo_mensagem_especifica():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)with pytest.raises(ValueError) as exc_info:pedido.calcular_total(cupom_percentual=120)assert "cupom" in str(exc_info.value)
Isso importa porque um pytest.raises(ValueError) genérico pode mascarar o erro errado: se o código tivesse dois ValueError diferentes em pontos diferentes da função, o teste passaria mesmo pegando o erro errado. Checar a mensagem garante que foi exatamente aquela validação que disparou.
Fixture: evitando repetição entre testes
Repara que, em quase todo teste acima, a gente repete o mesmo bloco: cria um Pedido, cria dois ItemCardapio, adiciona os dois — é o mesmo código copiado em cada função. O pytest resolve isso com fixture: uma função especial que prepara um 'estado' pronto (dados, objetos, conexões etc.) e entrega para quem precisar, mantendo cada teste isolado — cada um recebe sua própria instância nova, sem compartilhar estado com os outros. Para virar fixture, usa o decorator @pytest.fixture:
import pytestfrom pedido import ItemCardapio, Pedido@pytest.fixturedef pedido_completo():pedido = Pedido()item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)pedido.adicionar_item(item1)pedido.adicionar_item(item2)return pedido
Usando a fixture nos testes
Para usar, basta declarar o nome da fixture como parâmetro da função de teste — o pytest reconhece pelo nome e injeta o retorno dela automaticamente. Toda função que for usar esse recurso recebe pedido_completo no parâmetro e acessa as propriedades normalmente.
def test_calcular_total_sem_cupom(pedido_completo):total = pedido_completo.calcular_total()assert total == 30.0def test_calcular_total_com_cupom(pedido_completo):total_com_cupom = pedido_completo.calcular_total(cupom_percentual=10)assert total_com_cupom == 27.0def test_cupom_fora_do_intervalo(pedido_completo):with pytest.raises(ValueError):pedido_completo.calcular_total(cupom_percentual=120)
Repare como as funções ficam muito mais limpas — usar fixtures é boa prática para reduzir repetição nos testes. E vale reforçar: bons nomes de teste, que 'falam' exatamente o que será feito, evitam ambiguidade.
Fixture usando outra fixture
Às vezes, dentro das nossas fixtures, uma fixture precisa de outra fixture — nesse caso, ela recebe o nome da outra fixture como parâmetro, do mesmo jeito que uma função de teste recebe uma fixture, só que agora é uma fixture recebendo outra. O pytest reconhece esse nome, executa a fixture 'de baixo' primeiro, e injeta o retorno dela automaticamente; você não chama a fixture como uma função normal, só declara o nome dela como parâmetro e o pytest resolve sozinho. Repare que pedido_completo, lá de trás, cria os itens direto dentro dela mesma — dá para melhorar isso separando os itens em uma fixture própria:
@pytest.fixturedef itens_padrao():item1 = ItemCardapio("Item 1", 10.0)item2 = ItemCardapio("Item 2", 20.0)return [item1, item2]@pytest.fixturedef pedido_completo(itens_padrao):pedido = Pedido()for item in itens_padrao:pedido.adicionar_item(item)return pedido
Por que separar em duas fixtures
pedido_completo declara itens_padrao como parâmetro — o pytest executa itens_padrao primeiro, entrega a lista de itens pronta, e pedido_completo usa essa lista para montar o pedido. Se algum outro teste precisar só da lista de itens, sem nenhum pedido montado em cima, ele pode pedir itens_padrao diretamente, sem precisar passar por pedido_completo.
def test_itens_padrao_tem_dois_itens(itens_padrao):assert len(itens_padrao) == 2assert itens_padrao[0].nome == "Item 1"
Esse teste usa itens_padrao sozinha, sem nenhum Pedido envolvido — e os testes que já usavam pedido_completo continuam funcionando exatamente igual, porque o pytest resolve a cadeia de fixtures por trás dos panos. Mantendo as duas fixtures separadas, itens_padrao pode tanto ser puxada direto por um teste quanto ser reaproveitada por pedido_completo ao mesmo tempo.
Parametrize: testando várias entradas de uma vez
Quando você quer passar parâmetros diferentes para uma mesma função de teste sem reescrevê-la, usa parametrize:
@pytest.mark.parametrize("valor_desconto, valor_esperado", [(10, 27.0),(20, 24.0),(30, 21.0),])def test_calcular_total_com_cupom(pedido_completo, valor_desconto, valor_esperado):total_com_cupom = pedido_completo.calcular_total(cupom_percentual=valor_desconto)assert total_com_cupom == valor_esperado
Rodando com -v para ver cada chamada
valor_desconto e valor_esperado são os parâmetros passados para a função, nessa ordem. A lista logo acima tem 3 tuplas — o pytest desempacota cada uma nas variáveis e chama o teste uma vez por tupla. Na primeira chamada, valor_desconto=10 e valor_esperado=27.0; nesse exemplo, o teste roda 3 vezes.
uv run pytest -v# test_pedido.py::test_calcular_total_sem_cupom PASSED [ 14%]# test_pedido.py::test_calcular_total_com_cupom[10-27.0] PASSED [ 28%]# test_pedido.py::test_calcular_total_com_cupom[20-24.0] PASSED [ 42%]# test_pedido.py::test_calcular_total_com_cupom[30-21.0] PASSED [ 57%]# test_pedido.py::test_cupom_alto_nao_gera_valor_negativo PASSED [ 71%]# test_pedido.py::test_cupom_fora_do_intervalo PASSED [ 85%]# test_pedido.py::test_cupom_fora_do_intervalo_mensagem_especifica PASSED [100%]
Repare que os testes rodaram passando os valores na mesma ordem em que preenchemos a lista de tuplas.
Mock: testando dependências externas
Até agora usamos só exemplos internos do nosso próprio código. Mas frequentemente vamos testar funções que dependem de algo externo — um banco de dados, uma API, um e-mail — e essas chamadas podem demorar ou ter efeitos reais, como uma cobrança num cartão. Imagine testar uma função que chama uma API de pagamento: cada teste rodado faria uma chamada real, gerando tráfego indesejado e, dependendo da quantidade, até um bloqueio. É para isso que existe o Mock. Veja o exemplo, continuando do que já vínhamos construindo, em finalizacao.py:
import timeclass ProcessadorDePagamentos:def cobrar(self, valor, cartao):"""Simula uma chamada real a uma API de pagamento"""print(f"Conectando ao processador para cobrar R${valor} do cartão {cartao}...")time.sleep(5) # Simula o tempo de processamentoreturn Trueclass FinalizadorDePedido:def __init__(self, processador: ProcessadorDePagamentos):self.processador = processadordef finalizar(self, pedido, cartao):total = pedido.calcular_total()if total <= 0:return "Sucesso: pedido gratuito"aprovado = self.processador.cobrar(total, cartao)if aprovado:return "Sucesso: pagamento aprovado"else:raise ValueError("Pagamento recusado pelo banco")
O que é o Mock
A função cobrar de ProcessadorDePagamentos simula uma chamada real a uma API: imprime uma mensagem, espera 5 segundos e devolve True. FinalizadorDePedido recebe o próprio ProcessadorDePagamentos por parâmetro — o que chamamos de injeção de dependência — e sua função finalizar calcula o total do pedido, chama cobrar passando o valor e o cartão, e verifica se foi aprovado. Mock é um objeto 'dublê': um substituto falso que usamos no lugar de uma dependência externa que não controlamos, com comportamento definido por nós — sem depender do comportamento real dela. Além de nativo do Python, o Mock deixa a gente controlar exatamente as situações que queremos testar.
from unittest.mock import Mockmock_processador = Mock(ProcessadorDePagamentos)# mock_processador sabe que existe um método "cobrar"# (tentar mock_processador.devolver, por exemplo, dá erro)
Simulando uma compra com sucesso
Atribuímos return_value = True para que, toda vez que alguém chamar mock_processador.cobrar, o retorno seja sempre True. Passamos o mock para o FinalizadorDePedido (nossa injeção de dependência) e chamamos finalizar com o pedido da fixture e um cartão falso.
def test_compra_com_sucesso(pedido_completo):mock_processador = Mock(ProcessadorDePagamentos)mock_processador.cobrar.return_value = Truefinalizador = FinalizadorDePedido(mock_processador)cartao_falso = '1234-5678-9012-3456'resultado = finalizador.finalizar(pedido_completo, cartao_falso)assert resultado == "Sucesso: pagamento aprovado"
O fluxo: finalizar chama cobrar do processador por dentro; como o processador é o mock, a resposta é o valor configurado (True); cai no if aprovado, devolve a mensagem de sucesso, e o assert passa.
Simulando uma compra com erro
Mesmo raciocínio, mas configurando return_value = False, forçando o caminho do erro.
def test_compra_com_erro(pedido_completo):mock_processador = Mock(ProcessadorDePagamentos)mock_processador.cobrar.return_value = Falsefinalizador = FinalizadorDePedido(mock_processador)cartao_falso = '1234-5678-9012-3456'with pytest.raises(ValueError):finalizador.finalizar(pedido_completo, cartao_falso)
Aqui não usamos assert porque queremos validar que o raise ValueError('Pagamento recusado pelo banco') é executado de fato — o mock sempre retorna False para garantir que esse caminho seja testado.
Conferindo como o Mock foi chamado: assert_called_once_with
Esse método garante duas coisas: que o mock foi chamado apenas uma vez (pega chamadas duplicadas, um bug comum no dia a dia) e que, nessa chamada, os argumentos recebidos são exatamente os que especificamos. No teste de compra com sucesso, adicionamos no final:
mock_processador.cobrar.assert_called_once_with(30.0, cartao_falso)
O fluxo por trás da checagem
O mock é criado com retorno True, passado para o FinalizadorDePedido e guardado em self.processador. Ao rodar finalizador.finalizar(pedido_completo, cartao_falso), lá dentro executa aprovado = self.processador.cobrar(total, cartao), que retorna True e registra internamente a chamada.
# depois da chamada, o mock guarda:call_count = 1called = Truecall_args = call(30.0, '1234-5678-9012-3456')
O assert_called_once_with compara esses valores registrados com os que passamos; se baterem, o teste passa; se não, quebra avisando que não é o valor esperado. Esse método garante que cobrar realmente é chamado, com os valores corretos, uma única vez — não testamos só a saída final, mas o processo até ela.
