Código sem testes é código que você não confia. Testes automatizados são a rede de segurança que permite refatorar, adicionar funcionalidades e corrigir bugs com confiança. pytest é o framework de testes dominante em Python — mais expressivo que o unittest da biblioteca padrão, com fixtures poderosas, plugins ricos e uma sintaxe que torna os testes legíveis como documentação viva do sistema.
Instalação
pip install pytest pytest-cov pytest-mock pytest-asyncio faker
Estrutura de um Projeto com Testes
projeto/
├── src/
│ └── escola/
│ ├── __init__.py
│ ├── aluno.py
│ ├── calculadora.py
│ └── servicos.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← fixtures compartilhadas
│ ├── test_boas_praticas.py
│ ├── unit/
│ │ ├── test_calculadora.py
│ │ ├── test_parametrizados.py
│ │ └── test_servicos.py
│ └── integration/
│ ├── test_api.py
│ └── test_async.py
├── main.py ← a API testada em test_api.py
├── pyproject.toml
└── pytest.ini
# pytest.ini
[pytest]
testpaths = tests
pythonpath = src
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
unit: testes unitários
integration: testes de integração
slow: testes lentos
Duas linhas desse arquivo evitam erros que confundem quem está começando. O pythonpath = src, disponível desde o pytest 7, põe a pasta src no caminho de importação: sem ele, o primeiro from escola.aluno import Aluno do conftest.py falha com ModuleNotFoundError: No module named 'escola' e nenhum teste roda. A alternativa é instalar o próprio projeto em modo editável, com pip install -e .. A outra regra é ter a configuração do pytest num arquivo só. Se existem pytest.ini e uma seção [tool.pytest.ini_options] no pyproject.toml, vale o pytest.ini, e o pytest avisa no cabeçalho: WARNING: ignoring pytest config in pyproject.toml!. Tudo o que estiver no outro arquivo é ignorado. Os exemplos foram conferidos com pytest 9.1, pytest-asyncio 1.4, pytest-mock 3.16 e pytest-cov 7.1.
Código a ser testado
# src/escola/calculadora.py
from typing import List
def calcular_media(notas: List[float]) -> float:
"""Calcula a média aritmética de uma lista de notas."""
if not notas:
raise ValueError("A lista de notas não pode ser vazia.")
if any(n < 0 or n > 10 for n in notas):
raise ValueError("Notas devem estar entre 0 e 10.")
return sum(notas) / len(notas)
def calcular_conceito(media: float) -> str:
"""Retorna o conceito baseado na média."""
if media < 0 or media > 10:
raise ValueError(f"Média inválida: {media}")
if media >= 9: return "A"
if media >= 7: return "B"
if media >= 6: return "C"
if media >= 5: return "D"
return "F"
def calcular_situacao(notas: List[float], faltas: int = 0) -> dict:
"""Determina a situação do aluno."""
media = calcular_media(notas)
conceito = calcular_conceito(media)
aprovado = media >= 6.0 and faltas < 25
return {
"media": round(media, 2),
"conceito": conceito,
"aprovado": aprovado,
"situacao": "Aprovado" if aprovado else "Reprovado"
}
# src/escola/aluno.py
from dataclasses import dataclass, field
from typing import List
from datetime import datetime, timezone
@dataclass
class Aluno:
nome: str
email: str
turma: str
notas: List[float] = field(default_factory=list)
ativo: bool = True
criado_em: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
def __post_init__(self):
if not self.nome or len(self.nome) < 2:
raise ValueError("Nome deve ter ao menos 2 caracteres.")
if "@" not in self.email:
raise ValueError("E-mail inválido.")
self.nome = self.nome.strip().title()
def adicionar_nota(self, nota: float):
if not 0 <= nota <= 10:
raise ValueError(f"Nota inválida: {nota}")
self.notas.append(nota)
@property
def media(self) -> float:
if not self.notas:
return 0.0
return sum(self.notas) / len(self.notas)
@property
def aprovado(self) -> bool:
return self.media >= 6.0
def desativar(self):
self.ativo = False
# src/escola/servicos.py
from typing import List, Optional
from .aluno import Aluno
class RepositorioAlunos:
"""Repositório em memória — em produção usaria banco de dados."""
def __init__(self):
self._dados: dict = {}
self._proximo_id: int = 1
def salvar(self, aluno: Aluno) -> int:
aluno_id = self._proximo_id
self._dados[aluno_id] = aluno
self._proximo_id += 1
return aluno_id
def buscar(self, aluno_id: int) -> Optional[Aluno]:
return self._dados.get(aluno_id)
def buscar_por_email(self, email: str) -> Optional[Aluno]:
return next(
(a for a in self._dados.values() if a.email == email),
None
)
def listar_todos(self) -> List[Aluno]:
return list(self._dados.values())
def deletar(self, aluno_id: int) -> bool:
if aluno_id in self._dados:
del self._dados[aluno_id]
return True
return False
class ServicoNotificacao:
"""Serviço de notificação — interface para injeção de dependência."""
def notificar(self, email: str, mensagem: str) -> bool:
raise NotImplementedError
class ServicoAluno:
def __init__(
self,
repositorio: RepositorioAlunos,
notificacao: ServicoNotificacao
):
self.repo = repositorio
self.notify = notificacao
def cadastrar(self, nome: str, email: str, turma: str) -> int:
if self.repo.buscar_por_email(email):
raise ValueError(f"E-mail já cadastrado: {email}")
aluno = Aluno(nome=nome, email=email, turma=turma)
aluno_id = self.repo.salvar(aluno)
self.notify.notificar(email, f"Bem-vindo(a), {aluno.nome}!")
return aluno_id
def lancar_nota(self, aluno_id: int, nota: float):
aluno = self.repo.buscar(aluno_id)
if not aluno:
raise ValueError(f"Aluno {aluno_id} não encontrado.")
aluno.adicionar_nota(nota)
if aluno.aprovado:
self.notify.notificar(aluno.email, "Parabéns! Você está aprovado.")
def relatorio_turma(self, turma: str) -> dict:
alunos = [a for a in self.repo.listar_todos() if a.turma == turma]
aprovados = [a for a in alunos if a.aprovado]
return {
"turma": turma,
"total": len(alunos),
"aprovados": len(aprovados),
"reprovados": len(alunos) - len(aprovados),
"media_geral": round(
sum(a.media for a in alunos) / len(alunos), 2
) if alunos else 0.0
}
Testes Básicos
# tests/unit/test_calculadora.py
import pytest
from escola.calculadora import calcular_media, calcular_conceito, calcular_situacao
class TestCalcularMedia:
def test_media_simples(self):
assert calcular_media([8.0, 9.0, 7.0]) == pytest.approx(8.0)
def test_media_unico_valor(self):
assert calcular_media([7.5]) == 7.5
def test_media_zeros(self):
assert calcular_media([0.0, 0.0, 0.0]) == 0.0
def test_media_maxima(self):
assert calcular_media([10.0, 10.0]) == 10.0
def test_lista_vazia_levanta_erro(self):
with pytest.raises(ValueError, match="não pode ser vazia"):
calcular_media([])
def test_nota_negativa_levanta_erro(self):
with pytest.raises(ValueError, match="entre 0 e 10"):
calcular_media([-1.0, 8.0])
def test_nota_acima_de_10_levanta_erro(self):
with pytest.raises(ValueError):
calcular_media([11.0])
def test_precisao_decimal(self):
resultado = calcular_media([7.1, 7.2, 7.3])
assert resultado == pytest.approx(7.2, rel=1e-3)
class TestCalcularConceito:
@pytest.mark.parametrize("media,conceito_esperado", [
(9.5, "A"),
(9.0, "A"),
(8.9, "B"),
(7.0, "B"),
(6.9, "C"),
(6.0, "C"),
(5.9, "D"),
(5.0, "D"),
(4.9, "F"),
(0.0, "F"),
])
def test_conceitos(self, media, conceito_esperado):
assert calcular_conceito(media) == conceito_esperado
def test_media_invalida_negativa(self):
with pytest.raises(ValueError):
calcular_conceito(-0.1)
def test_media_invalida_acima_10(self):
with pytest.raises(ValueError):
calcular_conceito(10.1)
Fixtures
# tests/conftest.py
import pytest
from faker import Faker
from escola.aluno import Aluno
from escola.servicos import RepositorioAlunos, ServicoNotificacao, ServicoAluno
fake = Faker("pt_BR")
@pytest.fixture
def aluno_simples():
"""Aluno básico para testes."""
return Aluno(
nome="Ana Silva",
email="ana@email.com",
turma="A"
)
@pytest.fixture
def aluno_com_notas():
"""Aluno com notas já lançadas."""
aluno = Aluno(nome="Bruno Costa", email="bruno@email.com", turma="B")
for nota in [7.0, 8.5, 9.0, 6.5]:
aluno.adicionar_nota(nota)
return aluno
@pytest.fixture
def repositorio():
"""Repositório limpo para cada teste."""
return RepositorioAlunos()
@pytest.fixture
def notificacao_mock(mocker):
"""Mock do serviço de notificação."""
mock = mocker.MagicMock(spec=ServicoNotificacao)
mock.notificar.return_value = True
return mock
@pytest.fixture
def servico(repositorio, notificacao_mock):
"""Serviço de aluno com dependências mockadas."""
return ServicoAluno(repositorio, notificacao_mock)
@pytest.fixture
def turma_populada(repositorio, notificacao_mock):
"""Repositório com múltiplos alunos."""
servico = ServicoAluno(repositorio, notificacao_mock)
dados = [
("Ana Silva", "ana@email.com", "A", [9.0, 8.5, 9.5]),
("Bruno Costa", "bruno@email.com", "A", [5.0, 6.0, 5.5]),
("Carla Souza", "carla@email.com", "B", [7.0, 8.0, 7.5]),
("Diego Lima", "diego@email.com", "B", [4.0, 3.5, 5.0]),
]
ids = []
for nome, email, turma, notas in dados:
aid = servico.cadastrar(nome, email, turma)
for nota in notas:
servico.lancar_nota(aid, nota)
ids.append(aid)
return servico, ids
@pytest.fixture
def aluno_faker():
"""Aluno com dados gerados aleatoriamente."""
return Aluno(
nome=fake.name(),
email=fake.email(),
turma=fake.random_element(["A", "B", "C"])
)
Testes com Mocks
# tests/unit/test_servicos.py
import pytest
from escola.servicos import ServicoAluno
from escola.aluno import Aluno
class TestServicoAluno:
def test_cadastrar_aluno(self, servico, notificacao_mock):
aluno_id = servico.cadastrar("Ana Silva", "ana@email.com", "A")
assert aluno_id == 1
aluno = servico.repo.buscar(aluno_id)
assert aluno.nome == "Ana Silva"
assert aluno.email == "ana@email.com"
# Verifica que notificação foi enviada
notificacao_mock.notificar.assert_called_once_with(
"ana@email.com", "Bem-vindo(a), Ana Silva!"
)
def test_cadastrar_email_duplicado(self, servico):
servico.cadastrar("Ana Silva", "ana@email.com", "A")
with pytest.raises(ValueError, match="já cadastrado"):
servico.cadastrar("Ana Outra", "ana@email.com", "B")
def test_lancar_nota_aprovado_notifica(self, servico, notificacao_mock):
aluno_id = servico.cadastrar("Ana Silva", "ana@email.com", "A")
notificacao_mock.reset_mock()
servico.lancar_nota(aluno_id, 9.5)
# Deve notificar aprovação
notificacao_mock.notificar.assert_called_with(
"ana@email.com", "Parabéns! Você está aprovado."
)
def test_lancar_nota_reprovado_nao_notifica(self, servico, notificacao_mock):
aluno_id = servico.cadastrar("Bruno Costa", "bruno@email.com", "A")
notificacao_mock.reset_mock()
servico.lancar_nota(aluno_id, 3.0)
# Só foi chamada no cadastro — não na nota baixa
notificacao_mock.notificar.assert_not_called()
def test_lancar_nota_aluno_inexistente(self, servico):
with pytest.raises(ValueError, match="não encontrado"):
servico.lancar_nota(999, 8.0)
def test_relatorio_turma(self, turma_populada):
servico, _ = turma_populada
relatorio = servico.relatorio_turma("A")
assert relatorio["turma"] == "A"
assert relatorio["total"] == 2
assert relatorio["aprovados"] == 1
assert relatorio["reprovados"] == 1
def test_relatorio_turma_vazia(self, servico):
relatorio = servico.relatorio_turma("Z")
assert relatorio["total"] == 0
assert relatorio["media_geral"] == 0.0
class TestAluno:
def test_nome_normalizado(self):
aluno = Aluno(nome=" ana silva ", email="ana@email.com", turma="A")
assert aluno.nome == "Ana Silva"
def test_nome_invalido(self):
with pytest.raises(ValueError, match="2 caracteres"):
Aluno(nome="A", email="a@email.com", turma="A")
def test_email_invalido(self):
with pytest.raises(ValueError, match="E-mail inválido"):
Aluno(nome="Ana Silva", email="invalido", turma="A")
def test_media_sem_notas(self, aluno_simples):
assert aluno_simples.media == 0.0
def test_media_com_notas(self, aluno_com_notas):
assert aluno_com_notas.media == pytest.approx(7.75)
def test_aprovado_com_media_suficiente(self, aluno_com_notas):
assert aluno_com_notas.aprovado is True
def test_reprovado_com_media_insuficiente(self, aluno_simples):
aluno_simples.adicionar_nota(4.0)
assert aluno_simples.aprovado is False
def test_nota_invalida(self, aluno_simples):
with pytest.raises(ValueError):
aluno_simples.adicionar_nota(11.0)
def test_desativar(self, aluno_simples):
aluno_simples.desativar()
assert aluno_simples.ativo is False
Testes Parametrizados Avançados
# tests/unit/test_parametrizados.py
import pytest
from escola.calculadora import calcular_situacao
class TestCalcularSituacao:
@pytest.mark.parametrize("notas,faltas,aprovado_esperado", [
([8.0, 9.0, 7.0], 5, True), # nota ok, faltas ok
([8.0, 9.0, 7.0], 25, False), # nota ok, muitas faltas
([4.0, 5.0, 3.0], 5, False), # nota baixa, faltas ok
([4.0, 5.0, 3.0], 30, False), # nota baixa, muitas faltas
([6.0, 6.0, 6.0], 0, True), # nota mínima, sem faltas
([5.9, 6.0, 6.1], 10, True), # média exatamente 6.0
])
def test_situacao_aprovacao(self, notas, faltas, aprovado_esperado):
resultado = calcular_situacao(notas, faltas=faltas)
assert resultado["aprovado"] == aprovado_esperado
@pytest.mark.parametrize("notas,conceito_esperado", [
([10.0, 10.0, 10.0], "A"),
([9.0, 8.0, 8.5], "B"),
([6.5, 6.0, 6.0], "C"),
([5.5, 5.0, 5.0], "D"),
([3.0, 2.0, 4.0], "F"),
])
def test_conceito_calculado(self, notas, conceito_esperado):
resultado = calcular_situacao(notas)
assert resultado["conceito"] == conceito_esperado
@pytest.mark.parametrize("notas_invalidas", [
[],
[-1.0, 5.0],
[11.0, 5.0],
])
def test_entradas_invalidas(self, notas_invalidas):
with pytest.raises(ValueError):
calcular_situacao(notas_invalidas)
def test_chamada_sem_faltas_aprova_media_alta(self):
# O caso mais comum: nenhuma falta informada
resultado = calcular_situacao([10.0, 10.0, 10.0])
assert resultado["situacao"] == "Aprovado"
O último teste foi acrescentado depois que ele pegou um defeito da primeira versão do artigo. A função se chamava calcular_situacao(notas, min_faltas=25) e aprovava com min_faltas < 25. Chamada sem o segundo argumento, a comparação era 25 < 25, sempre falsa, e ela reprovava todo mundo: calcular_situacao([10.0, 10.0, 10.0]) devolvia média 10.0, conceito "A" e situação "Reprovado". Os testes de conceito usavam justamente essa chamada, mas só conferiam o conceito, e passavam. É um padrão comum: o valor padrão de um parâmetro costuma ser o caminho mais usado e o menos testado. O nome também atrapalhava, porque min_faltas sugere um limite, mas o parâmetro recebia a quantidade de faltas do aluno. Agora ele se chama faltas, e o padrão é zero.
Testes de API com TestClient
# tests/integration/test_api.py
import pytest
from fastapi.testclient import TestClient
# main.py: a API do CRUD de alunos, com os dados num dict em memória
@pytest.fixture
def client():
import main
main._alunos.clear() # cada teste começa com o banco vazio
main._proximo_id = 1
return TestClient(main.app)
class TestAPIAlunos:
def test_listar_alunos_vazio(self, client):
resp = client.get("/alunos")
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_criar_aluno(self, client):
resp = client.post("/alunos", json={
"nome": "Ana Silva",
"email": "ana@email.com",
"nota": 9.5
})
assert resp.status_code == 201
dados = resp.json()
assert dados["nome"] == "Ana Silva"
assert dados["email"] == "ana@email.com"
assert "id" in dados
def test_criar_aluno_dados_invalidos(self, client):
resp = client.post("/alunos", json={
"nome": "A",
"email": "invalido",
"nota": 15.0
})
assert resp.status_code == 422
def test_buscar_aluno_inexistente(self, client):
resp = client.get("/alunos/9999")
assert resp.status_code == 404
def test_atualizar_nota(self, client):
# Cria
resp_criar = client.post("/alunos", json={
"nome": "Bruno Costa", "email": "bruno@email.com", "nota": 5.0
})
aluno_id = resp_criar.json()["id"]
# Atualiza
resp_patch = client.patch(
f"/alunos/{aluno_id}",
json={"nota": 8.5}
)
assert resp_patch.status_code == 200
assert resp_patch.json()["nota"] == 8.5
def test_deletar_aluno(self, client):
resp_criar = client.post("/alunos", json={
"nome": "Carla Souza", "email": "carla@email.com", "nota": 7.0
})
aluno_id = resp_criar.json()["id"]
resp_del = client.delete(f"/alunos/{aluno_id}")
assert resp_del.status_code == 204
resp_get = client.get(f"/alunos/{aluno_id}")
assert resp_get.status_code == 404
A API testada é a do CRUD de alunos de FastAPI: APIs modernas com tipagem e documentação automática, salva como main.py na raiz do projeto. Ela guarda os alunos num dicionário do módulo, e por isso a fixture zera esse estado antes de cada teste. Sem as duas linhas de limpeza, os seis testes passam na ordem em que estão escritos — e só nela. Rodando test_criar_aluno antes de test_listar_alunos_vazio, o segundo falha com assert 1 == 0, porque o aluno criado pelo primeiro continua lá. Um teste que depende da ordem é um defeito à espera de acontecer: rodar um teste isolado com pytest -k, distribuir com o pytest-xdist ou embaralhar com o pytest-randomly muda o que roda antes de cada teste, e a falha aparece longe da causa.
Testes Assíncronos
# tests/integration/test_async.py
import pytest
import httpx
@pytest.mark.asyncio
async def test_buscar_cep():
"""Testa cliente HTTP assíncrono sem sair para a rede."""
resposta_mock = {
"cep": "01310-100",
"logradouro": "Avenida Paulista",
"localidade": "São Paulo",
"uf": "SP"
}
def responder(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/ws/01310100/json/"
return httpx.Response(200, json=resposta_mock)
transporte = httpx.MockTransport(responder)
async with httpx.AsyncClient(transport=transporte) as client:
resp = await client.get("https://viacep.com.br/ws/01310100/json/")
dados = resp.json()
assert resp.status_code == 200
assert dados["localidade"] == "São Paulo"
assert dados["uf"] == "SP"
@pytest.mark.asyncio
async def test_tarefa_concorrente():
"""Testa execução de múltiplas corrotinas."""
import asyncio
async def tarefa(n: int, delay: float) -> int:
await asyncio.sleep(delay)
return n * 2
resultados = await asyncio.gather(
tarefa(1, 0.01),
tarefa(2, 0.01),
tarefa(3, 0.01),
)
assert resultados == [2, 4, 6]
A primeira versão do test_buscar_cep falhava com TypeError: 'coroutine' object is not subscriptable. Ela trocava o get por um AsyncMock e fazia a resposta também ser um AsyncMock. Só que todo atributo de um AsyncMock é assíncrono, e o resp.json() passou a devolver uma corrotina em vez do dicionário. O httpx.MockTransport evita a armadilha: o cliente é o de verdade, e só o transporte é substituído por uma função que devolve um httpx.Response real. De quebra, o teste confere a URL pedida. O pytest-asyncio roda por padrão no modo estrito, e o @pytest.mark.asyncio em cada teste é o que o faz rodar como corrotina; asyncio_mode = auto no pytest.ini dispensa a marcação.
Cobertura de Testes
# Executando testes com cobertura
pytest --cov=src/escola --cov-report=term-missing --cov-report=html
# Saída medida com os testes deste artigo:
# Name Stmts Miss Cover Missing
# ---------------------------------------------------------
# src/escola/aluno.py 28 0 100%
# src/escola/calculadora.py 20 0 100%
# src/escola/servicos.py 47 5 89% 32-35, 42
# ---------------------------------------------------------
# TOTAL 95 5 95%
# Gerando relatório HTML
pytest --cov=src --cov-report=html:htmlcov
# Abrir: htmlcov/index.html
# pyproject.toml — configuração de cobertura
# (a configuração do pytest fica só no pytest.ini)
[tool.coverage.run]
source = ["src"]
omit = ["*/migrations/*", "*/tests/*", "*/__init__.py"]
[tool.coverage.report]
fail_under = 85 # falha se cobertura < 85%
show_missing = true
skip_covered = false
Boas Práticas de Testes
# tests/test_boas_praticas.py
import sys
import pytest
# Padrão AAA — Arrange, Act, Assert
def test_lancar_nota_padrao_aaa(servico, notificacao_mock):
# ARRANGE — prepara o estado
aluno_id = servico.cadastrar("Ana Silva", "ana@email.com", "A")
notificacao_mock.reset_mock()
# ACT — executa a ação
servico.lancar_nota(aluno_id, 9.5)
# ASSERT — verifica o resultado
aluno = servico.repo.buscar(aluno_id)
assert aluno.notas == [9.5]
assert aluno.aprovado is True
# Testes independentes — nunca dependem da ordem
def test_independente_1(repositorio):
assert len(repositorio.listar_todos()) == 0 # sempre começa vazio
def test_independente_2(repositorio):
assert len(repositorio.listar_todos()) == 0 # idem — fixture é recriada
# Nomes descritivos — o nome é a documentação
class TestServicoAluno:
def test_cadastrar_aluno_com_email_duplicado_levanta_valor_error(self, servico):
servico.cadastrar("Ana", "ana@email.com", "A")
with pytest.raises(ValueError):
servico.cadastrar("Ana 2", "ana@email.com", "B")
def test_media_de_aluno_sem_notas_retorna_zero(self, aluno_simples):
assert aluno_simples.media == 0.0
def test_nota_acima_do_maximo_nao_e_aceita(self, aluno_simples):
with pytest.raises(ValueError):
aluno_simples.adicionar_nota(10.1)
# Markers para organizar e pular testes
@pytest.mark.slow
@pytest.mark.integration
def test_integracao_banco():
pass # fora da execução rápida: pytest -m "not slow"
@pytest.mark.skip(reason="Feature ainda não implementada")
def test_feature_futura():
pass
@pytest.mark.skipif(
sys.platform != "linux",
reason="Só roda em Linux"
)
def test_especifico_linux():
pass
Executando os Testes
# Todos os testes
pytest
# Com verbosidade
pytest -v
# Arquivo específico
pytest tests/unit/test_calculadora.py
# Teste específico
pytest tests/unit/test_calculadora.py::TestCalcularMedia::test_media_simples
# Filtrando por marker
pytest -m unit
pytest -m "not slow"
# Parando no primeiro erro
pytest -x
# Mostrando prints
pytest -s
# Reruns automáticos em falhas
pip install pytest-rerunfailures
pytest --reruns 3 --reruns-delay 1
# Executando em paralelo
pip install pytest-xdist
pytest -n auto # usa todos os núcleos disponíveis
# Relatório completo
pytest --tb=long -v --cov=src --cov-report=html
O pytest reduz o custo de escrever um teste ao mínimo: uma função com assert já é um teste, as fixtures entregam o estado pronto a quem pedir pelo nome, o parametrize transforma uma tabela de casos em dezenas de testes, e os plugins cuidam do resto — pytest-mock para substituir dependências, pytest-asyncio para corrotinas e pytest-cov para medir o que foi executado. Para clientes HTTP como os de Consumindo APIs REST com requests e httpx, o httpx.MockTransport permite testar o código de verdade sem sair para a rede.
Os defeitos que este artigo tinha mostram o que os testes não garantem sozinhos. Uma função que reprovava todo mundo passava na suíte com 100% de cobertura, porque nenhum teste conferia o resultado do caminho padrão. Testes de API passavam só na ordem em que estavam escritos. E uma configuração num segundo arquivo era ignorada sem que ninguém notasse. Cobertura diz o que rodou, não o que foi verificado. O teste que vale é o que falharia se o código estivesse errado — e a melhor forma de saber isso é vê-lo falhar ao menos uma vez.
Fontes e leituras recomendadas
- pytest — documentação oficial — https://docs.pytest.org/en/stable/
- pytest-cov — https://pytest-cov.readthedocs.io/en/latest/
- pytest-mock — https://pytest-mock.readthedocs.io/en/latest/
- pytest-asyncio — https://pytest-asyncio.readthedocs.io/en/latest/
- Faker — geração de dados falsos — https://faker.readthedocs.io/en/master/
- PERCIVAL, Harry. Test-Driven Development with Python. 2. ed. O'Reilly Media, 2017. — TDD de ponta a ponta com Python, Django e pytest.
- OKKEN, Brian. Python Testing with pytest. 2. ed. Pragmatic Bookshelf, 2022. — referência definitiva e prática sobre pytest.
Exercícios
Exercício 1
A suíte da escola tem 66 testes, todos verdes, e o relatório de cobertura mostra calculadora.py com 100%. Mesmo assim, o boletim gerado com calcular_situacao(notas) reprova todos os alunos, inclusive os de média 10. A função, na primeira versão do artigo, tinha a assinatura calcular_situacao(notas, min_faltas=25) e aprovava com media >= 6.0 and min_faltas < 25. Como os testes deixaram isso passar?
Ver resposta
✓ Resposta: Sem o segundo argumento, a condição vira 25 < 25, que é sempre falsa. Medido: calcular_situacao([10.0, 10.0, 10.0]) devolvia média 10.0, conceito "A" e situação "Reprovado". Os testes de aprovação passavam o número de faltas explicitamente, e os que usavam a chamada padrão só conferiam o conceito. Todas as linhas da função rodavam, e daí os 100%, mas nenhum teste verificava a saída do caminho mais usado. A cobertura de linhas responde "este código foi executado?", e não "este resultado foi conferido?". A correção tem duas partes: o parâmetro vira faltas: int = 0, o nome e o padrão que o uso sugere, e entra um teste do caso mais comum, sem faltas informadas, conferindo a situação. Para medir se os testes verificam de fato, existe o teste de mutação (mutmut, por exemplo): ele altera o código de propósito e conta quantas mudanças a suíte não percebe.
Exercício 2
Um colega clona o projeto, instala as dependências e roda pytest. Nenhum teste executa, e a saída mostra ImportError while loading conftest com ModuleNotFoundError: No module named 'escola'. No cabeçalho aparece ainda WARNING: ignoring pytest config in pyproject.toml!. Os dois problemas estão relacionados?
Ver resposta
✓ Resposta: São dois problemas de configuração, independentes. O primeiro vem do layout src/: o pacote escola fica dentro de src, que não está no caminho de importação, e o conftest.py falha já na primeira linha. Há duas saídas: pythonpath = src no pytest.ini, disponível desde o pytest 7, ou instalar o projeto com pip install -e ., que também serve para quem importa o pacote fora dos testes. O layout src/ existe justamente para forçar essa decisão: os testes rodam contra o pacote instalado, não contra arquivos soltos na pasta. O segundo problema é a configuração dividida. Com pytest.ini presente, a seção [tool.pytest.ini_options] do pyproject.toml é ignorada inteira, e o asyncio_mode = "auto" que estava lá nunca valeu — o pytest-asyncio rodava no modo estrito, como o próprio cabeçalho informa (mode=Mode.STRICT). A regra é um arquivo só; hoje, o mais comum é o pyproject.toml.
Exercício 3
Os seis testes de API passam com pytest tests/integration/test_api.py. Um desenvolvedor, depurando, passa os dois testes na linha de comando, primeiro test_api.py::TestAPIAlunos::test_criar_aluno e depois test_api.py::TestAPIAlunos::test_listar_alunos_vazio, e vê o segundo falhar com assert 1 == 0, na primeira versão da fixture client, que só criava o TestClient. Por que o mesmo teste passa num caso e falha no outro?
Ver resposta
✓ Resposta: Porque a API guarda os alunos num dicionário do módulo, e o módulo é importado uma vez por sessão de testes. Na execução completa, test_listar_alunos_vazio é o primeiro da classe e encontra o dicionário vazio por sorte. Quando os testes são passados pelo ID, o pytest os executa na ordem dada: o test_criar_aluno roda antes, deixa a Ana no dicionário, e a listagem encontra 1. O mesmo acontece com o pytest-randomly, que embaralha a ordem. Curiosamente, pytest -k "criar_aluno or listar_alunos_vazio" passa, porque o -k seleciona mas mantém a ordem do arquivo — o defeito existe, e só algumas formas de rodar o revelam. A fixture precisa entregar o mesmo estado inicial a todo teste: main._alunos.clear() e main._proximo_id = 1 antes de criar o cliente. Com banco de dados real, o equivalente é uma transação desfeita ao fim de cada teste, ou um banco descartável por sessão. A pergunta de controle é: este teste passa se rodar sozinho? Se a resposta depende de quem rodou antes, há estado vazando.
Exercício 4
O teste do cliente de CEP, na primeira versão, fazia patch("httpx.AsyncClient.get"), punha mock_get.return_value = AsyncMock() e configurava mock_get.return_value.json.return_value = resposta_mock. Ele falha com TypeError: 'coroutine' object is not subscriptable na linha dados["localidade"]. Onde entrou a corrotina?
Ver resposta
✓ Resposta: No json(). O patch percebe que AsyncClient.get é assíncrono e cria um AsyncMock: o await client.get(...) devolve o return_value dele, que o teste definiu como outro AsyncMock. Só que os atributos de um AsyncMock também são assíncronos, então resp.json() não devolve o dicionário configurado, e sim uma corrotina que o devolveria se fosse aguardada. O httpx.Response.json() de verdade é síncrono, e o mock não reproduzia isso. A correção mínima seria mock_get.return_value = MagicMock(), mas a melhor é não mockar o cliente: com httpx.AsyncClient(transport=httpx.MockTransport(responder)), o código testado usa o cliente real, e a função responder devolve um httpx.Response(200, json=...) real, com json() síncrono, cabeçalhos e status verdadeiros. De quebra, ela pode conferir a URL e o método pedidos. Mock de objeto de biblioteca tende a divergir do comportamento real; substituir a fronteira, o transporte, costuma ser mais fiel.
Exercício 5
No bloco de boas práticas, a primeira versão marcava um teste com @pytest.mark.skipif(condition=True, reason="Só roda em Linux") e outro, test_integracao_banco(db_real), com o comentário "só roda com --run-slow". Rodando a suíte no Linux, o primeiro aparece como pulado e o segundo como erro: fixture 'db_real' not found. O que cada um deveria ser?
Ver resposta
✓ Resposta: O skipif pula quando a condição é verdadeira, e True é sempre verdadeiro: o teste nunca roda em lugar nenhum, inclusive no Linux, e o motivo exibido engana quem lê o relatório. A condição precisa descrever o ambiente: sys.platform != "linux". O segundo caso mistura três coisas. A fixture db_real não existe, então o pytest nem chega a rodar o teste. A opção --run-slow também não existe: ela teria de ser criada num conftest.py, com o hook pytest_addoption e um pytest_collection_modifyitems que pule os testes marcados. E o marcador slow, registrado no pytest.ini, não pula nada sozinho, só rotula. A forma simples é filtrar na linha de comando: pytest -m "not slow" exclui os lentos da execução rápida. Nos dois casos, a lição é a mesma — o texto de um reason ou de um comentário não é verificado por ninguém; só o código é.