Artigo 43 — Testes com pytest: fixtures, mocks e cobertura
Prof. Ricardo Matos Módulo 8 · Testes, Qualidade e Boas Práticas · Artigo 43 de 52
Introdução
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
│ ├── turma.py
│ ├── calculadora.py
│ └── servicos.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← fixtures compartilhadas
│ ├── unit/
│ │ ├── test_aluno.py
│ │ ├── test_turma.py
│ │ └── test_calculadora.py
│ ├── integration/
│ │ └── test_servicos.py
│ └── e2e/
│ └── test_api.py
├── pyproject.toml
└── pytest.ini
# pytest.ini
[pytest]
testpaths = tests
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
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], min_faltas: int = 25) -> dict:
"""Determina a situação do aluno."""
media = calcular_media(notas)
conceito = calcular_conceito(media)
aprovado = media >= 6.0 and min_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
@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=datetime.utcnow)
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, min_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)
Testes de API com TestClient
# tests/integration/test_api.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
# Assumindo que a API do artigo 25 está em app.main
@pytest.fixture
def client():
from app.main import criar_app
app = criar_app(ambiente="testes")
return TestClient(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
Testes Assíncronos
# tests/integration/test_async.py
import pytest
import pytest_asyncio
import httpx
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_buscar_cep():
"""Testa cliente HTTP assíncrono com mock."""
resposta_mock = {
"cep": "01310-100",
"logradouro": "Avenida Paulista",
"localidade": "São Paulo",
"uf": "SP"
}
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = AsyncMock()
mock_get.return_value.json.return_value = resposta_mock
mock_get.return_value.status_code = 200
async with httpx.AsyncClient() as client:
resp = await client.get("https://viacep.com.br/ws/01310100/json/")
dados = resp.json()
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]
Cobertura de Testes
# Executando testes com cobertura
pytest --cov=src/escola --cov-report=term-missing --cov-report=html
# Saída esperada:
# Name Stmts Miss Cover
# -------------------------------------------------
# src/escola/aluno.py 28 0 100%
# src/escola/calculadora.py 18 0 100%
# src/escola/servicos.py 35 2 94%
# -------------------------------------------------
# TOTAL 81 2 97%
# Gerando relatório HTML
pytest --cov=src --cov-report=html:htmlcov
# Abrir: htmlcov/index.html
# pyproject.toml — configuração de cobertura
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[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
# 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(db_real):
pass # só roda com --run-slow
@pytest.mark.skip(reason="Feature ainda não implementada")
def test_feature_futura():
pass
@pytest.mark.skipif(
condition=True,
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
Resumo
- pytest usa descoberta automática — arquivos
test_*.py, classesTest*, funçõestest_* - Fixtures são injetadas por nome nos parâmetros — escopo pode ser
function,class,moduleousession mocker.MagicMockemocker.patchisolam dependências externas — banco, e-mail, APIspytest.mark.parametrizeelimina código repetido em testes com múltiplas entradas- O padrão AAA (Arrange, Act, Assert) torna os testes legíveis e auditáveis
pytest-covmede cobertura — configurefail_undernopyproject.tomlpara garantir mínimo- Testes devem ser independentes, rápidos, repetíveis e autoexplicativos pelo nome
pytest-asynciotesta código assíncrono com a mesma elegância dos testes síncronos
Referências e Leituras Complementares
- 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.
Prof. Ricardo Matos — Dominando o Python em 1 Ano · Artigo 43 de 52 Próximo: Artigo 44 — Qualidade de Código: linting, type hints e mypy
you asked
Sim