Pytest é o framework de testes mais popular do ecossistema Python. Baseado em convenções simples, oferece fixtures poderosas, parametrização nativa, extenso ecossistema de plugins e relatórios claros sem boilerplate excessivo.
Setup e Configuração
Instalação
# Instalação básica
pip install pytest
# Com plugins essenciais
pip install pytest pytest-cov pytest-mock pytest-asyncio
# Com Django
pip install pytest-django
# Com FastAPI/httpx
pip install pytest-httpx httpx
# Verificar instalação
pytest --versionpytest.ini / pyproject.toml
# pytest.ini — configuração tradicional
[pytest]
# Diretório raiz dos testes
testpaths = tests
# Padrões de descoberta de arquivos e funções de teste
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
# Markers customizados (evita warnings de marker desconhecido)
markers =
integracao: testes que requerem serviços externos
lento: testes com tempo de execução alto
smoke: subset mínimo para validação rápida
# Flags padrão passados ao pytest
addopts =
--strict-markers
--tb=short
-v
--cov=src
--cov-report=term-missing
# Configuração do asyncio
asyncio_mode = auto# pyproject.toml — configuração moderna (recomendado)
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--tb=short",
"-v",
]
markers = [
"integracao: testes que requerem serviços externos",
"lento: testes com tempo de execução alto",
"smoke: subset mínimo para validação rápida",
]
asyncio_mode = "auto"
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*", "*/__init__.py"]
[tool.coverage.report]
fail_under = 80
show_missing = truePrimeiro Teste
# src/calculadora.py
def somar(a: float, b: float) -> float:
return a + b
def dividir(a: float, b: float) -> float:
if b == 0:
raise ValueError("Divisão por zero não é permitida")
return a / b
def calcular_desconto(preco: float, percentual: float) -> float:
if not 0 <= percentual <= 100:
raise ValueError(f"Percentual inválido: {percentual}")
return preco * (1 - percentual / 100)
# tests/test_calculadora.py
# Pytest descobre automaticamente funções que começam com "test_"
import pytest
from src.calculadora import somar, dividir, calcular_desconto
def test_somar_dois_numeros_positivos():
resultado = somar(2, 3)
assert resultado == 5
def test_somar_numero_negativo():
assert somar(-1, 1) == 0
def test_dividir_retorna_quociente():
assert dividir(10, 2) == 5.0
def test_dividir_por_zero_lanca_excecao():
with pytest.raises(ValueError, match="Divisão por zero"):
dividir(10, 0)
def test_desconto_cinquenta_porcento():
resultado = calcular_desconto(100.0, 50)
assert resultado == 50.0
def test_desconto_invalido_lanca_excecao():
with pytest.raises(ValueError, match="Percentual inválido"):
calcular_desconto(100.0, 150)# Execução básica
pytest # descobre e executa todos os testes
pytest tests/test_calculadora.py # arquivo específico
pytest tests/test_calculadora.py::test_somar_dois_numeros_positivos # teste específico
pytest -v # verbose — mostra nome de cada teste
pytest -s # mostra print() durante execução
pytest -k "desconto" # executa apenas testes com "desconto" no nome
pytest -x # para na primeira falha
pytest --lf # executa apenas os testes que falharam na última vez
pytest --co # lista testes sem executar (collect only)
pytest -n auto # execução paralela (requer pytest-xdist)Asserções
# Pytest reescreve automaticamente "assert" para mensagens de erro ricas
# Muito mais legível que unittest-style
def test_assertoes_basicas():
# Igualdade
assert 2 + 2 == 4
assert "hello" == "hello"
# Verdade / Falsidade
assert True
assert not False
assert bool([1, 2, 3]) # lista não-vazia é truthy
# Comparações numéricas
assert 3 > 2
assert 5 >= 5
assert 1 < 2
# Aproximação de float (evita erros de ponto flutuante)
assert 0.1 + 0.2 == pytest.approx(0.3)
assert 1.0001 == pytest.approx(1.0, rel=1e-3) # tolerância relativa de 0.1%
assert 1.0001 == pytest.approx(1.0, abs=0.001) # tolerância absoluta
# Strings
assert "hello" in "hello world"
assert "hello world".startswith("hello")
# Coleções
assert 3 in [1, 2, 3]
assert {"a": 1, "b": 2} == {"b": 2, "a": 1} # dicts são iguais independente de ordem
# None
assert None is None
valor = None
assert valor is None
def test_assertoes_excecoes():
# pytest.raises como context manager
with pytest.raises(ZeroDivisionError):
1 / 0
# Captura a exceção para inspeção
with pytest.raises(ValueError) as exc_info:
int("não_é_número")
assert "invalid literal" in str(exc_info.value)
assert exc_info.type is ValueError
# match — regex contra a mensagem da exceção
with pytest.raises(ValueError, match=r"invalid literal for int\(\)"):
int("abc")Fixtures
Fixtures são funções que fornecem dados ou objetos de teste. Pytest injeta automaticamente pelo nome do parâmetro.
Fixtures Básicas
# conftest.py — fixtures disponíveis para todos os testes do diretório
import pytest
from myapp.models import Cliente, Pedido
from myapp.services import PedidoService
from myapp.repositories import PedidoRepository
@pytest.fixture
def cliente_padrao():
"""Fixture de escopo padrão (function) — recriada a cada teste."""
return Cliente(
id="CLI-001",
nome="João Silva",
email="joao@example.com",
tipo="COMUM"
)
@pytest.fixture
def cliente_vip():
return Cliente(id="CLI-VIP", nome="Maria VIP", email="maria@vip.com", tipo="VIP")
@pytest.fixture
def pedido_pendente(cliente_padrao):
# Fixtures podem depender de outras fixtures — injeção automática
return Pedido(
id="PED-001",
cliente=cliente_padrao,
valor=500.0,
status="PENDENTE"
)
# Usando fixtures nos testes — basta declarar como parâmetro
def test_pedido_tem_cliente_associado(pedido_pendente, cliente_padrao):
assert pedido_pendente.cliente.id == cliente_padrao.id
def test_cliente_vip_tem_desconto(cliente_vip):
assert cliente_vip.percentual_desconto() == 10Escopos de Fixtures
# function (padrão) — recriada para cada função de teste
@pytest.fixture(scope="function")
def conexao_banco():
conn = criar_conexao()
yield conn
conn.close()
# class — compartilhada entre todos os testes de uma classe
@pytest.fixture(scope="class")
def servidor_http():
server = HTTPServer("localhost", 0)
server.start()
yield server
server.stop()
# module — compartilhada entre todos os testes de um módulo
@pytest.fixture(scope="module")
def app_client():
"""Cria cliente da aplicação uma vez por módulo de teste."""
from myapp import create_app
app = create_app({"TESTING": True, "DATABASE_URL": "sqlite:///:memory:"})
with app.test_client() as client:
yield client
# session — compartilhada entre toda a sessão de testes
# Ideal para recursos caros (containers, servidores externos)
@pytest.fixture(scope="session")
def banco_de_dados():
"""Banco criado uma vez para toda a sessão."""
db = criar_banco_teste()
db.executar_migrations()
yield db
db.destruir()yield Fixtures (Setup e Teardown)
@pytest.fixture
def arquivo_temporario(tmp_path):
# Setup — antes do teste
arquivo = tmp_path / "dados_teste.txt"
arquivo.write_text("conteúdo inicial do arquivo")
print(f"\nArquivo criado: {arquivo}")
yield arquivo # <-- o teste recebe este valor
# Teardown — após o teste (mesmo se falhar)
# tmp_path já limpa automaticamente, mas ilustrativo:
print(f"\nArquivo removido: {arquivo}")
@pytest.fixture
def transacao_bd(banco_de_dados):
"""Abre uma transação e faz rollback após cada teste — isolamento perfeito."""
conn = banco_de_dados.conectar()
transacao = conn.begin()
yield conn # testes veem as mudanças, mas não persistem
transacao.rollback() # desfaz tudo após o teste
conn.fechar()
def test_inserir_cliente(transacao_bd):
# Insere no banco
transacao_bd.execute("INSERT INTO clientes VALUES ('CLI-TEST', 'Teste')")
# Verifica que foi inserido
resultado = transacao_bd.execute("SELECT * FROM clientes WHERE id='CLI-TEST'")
assert resultado.rowcount == 1
# Após o teste, o rollback desfaz a inserção automaticamenteconftest.py e Hierarquia
tests/
├── conftest.py # fixtures disponíveis para TODOS os testes
├── test_calculadora.py
├── unit/
│ ├── conftest.py # fixtures apenas para tests/unit/
│ └── test_servicos.py
└── integration/
├── conftest.py # fixtures apenas para tests/integration/
└── test_api.py# tests/conftest.py — fixtures globais
import pytest
@pytest.fixture(scope="session")
def configuracao_global():
return {"ambiente": "test", "debug": True}
# tests/integration/conftest.py — fixtures de integração
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def db_url(postgres_container):
return postgres_container.get_connection_url()autouse — Fixture Automática
@pytest.fixture(autouse=True)
def limpar_cache():
"""Aplicada automaticamente a todos os testes do módulo sem precisar declarar."""
yield
cache.limpar()
# Escopo de autouse — todos os testes da classe
class TestPedidoService:
@pytest.fixture(autouse=True)
def setup(self):
self.service = PedidoService()
self.repository = Mock()
self.service.repository = self.repository
def test_criar_pedido(self):
# self.service e self.repository já estão disponíveis
self.repository.save.return_value = Pedido(id="PED-001")
resultado = self.service.criar(CriarPedidoDTO(cliente_id="CLI-001"))
assert resultado.id == "PED-001"Markers
import pytest
# @pytest.mark.skip — pula o teste incondicionalmente
@pytest.mark.skip(reason="Funcionalidade ainda não implementada")
def test_exportacao_csv():
assert exportar_para_csv() is not None
# @pytest.mark.skipif — pula com condição
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="Não suportado no Windows")
def test_permissoes_arquivo():
assert verificar_permissao("/etc/hosts") is True
# @pytest.mark.xfail — teste esperado para falhar
@pytest.mark.xfail(reason="Bug conhecido — ticket #2345")
def test_calculo_com_bug_conhecido():
resultado = calcular_com_bug()
assert resultado == 42 # vai falhar, mas não conta como erro na suíte
# xfail com strict=True — falha se o teste PASSAR (regressão inesperada)
@pytest.mark.xfail(strict=True, reason="Deve falhar até o bug ser corrigido")
def test_que_deve_falhar():
assert funcionalidade_quebrada() == "correto"
# Markers customizados (declarados no pytest.ini)
@pytest.mark.integracao
def test_conectar_banco_real():
conn = conectar_postgres("postgresql://localhost/testdb")
assert conn.is_connected()
@pytest.mark.lento
@pytest.mark.integracao
def test_importar_arquivo_grande():
resultado = importar_csv("dados/arquivo_grande.csv")
assert resultado.total_linhas == 1_000_000# Executar apenas testes marcados
pytest -m integracao
pytest -m "integracao and not lento"
pytest -m "smoke or (integracao and not lento)"
# Excluir marcados
pytest -m "not integracao"
# Listar markers disponíveis
pytest --markersParametrize
import pytest
# @pytest.mark.parametrize básico — um parâmetro
@pytest.mark.parametrize("numero", [1, 2, 3, 4, 5])
def test_numero_e_positivo(numero):
assert numero > 0
# @pytest.mark.parametrize — múltiplos parâmetros por caso
@pytest.mark.parametrize("a, b, esperado", [
(1, 1, 2),
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, -50, 50),
])
def test_somar(a, b, esperado):
assert somar(a, b) == esperado
# IDs customizados para facilitar leitura nos relatórios
@pytest.mark.parametrize("cpf, valido", [
("111.444.777-35", True),
("000.000.000-00", False),
("", False),
(None, False),
("123.456.789-09", True),
], ids=["cpf_valido", "todos_zeros", "vazio", "none", "outro_valido"])
def test_validar_cpf(cpf, valido):
assert validar_cpf(cpf) == valido
# pytest.param — controle individual (skip, xfail, ids por item)
@pytest.mark.parametrize("entrada, esperado", [
pytest.param("hello", "HELLO", id="minusculo"),
pytest.param("WORLD", "WORLD", id="ja_maiusculo"),
pytest.param("", "", id="vazio"),
pytest.param(
"caso_especial",
"CASO_ESPECIAL",
marks=pytest.mark.xfail(reason="bug com underscore"),
id="com_underscore"
),
])
def test_para_maiusculo(entrada, esperado):
assert entrada.upper() == esperado
# Parametrize múltiplo — produto cartesiano
@pytest.mark.parametrize("formato", ["json", "xml", "csv"])
@pytest.mark.parametrize("versao", ["v1", "v2"])
def test_exportar(formato, versao):
# Executa 6 vezes: v1/json, v1/xml, v1/csv, v2/json, v2/xml, v2/csv
resultado = exportar(formato=formato, versao=versao)
assert resultado is not None
# Parametrize indireta — passa parâmetros para fixture
@pytest.fixture
def usuario(request):
"""Fixture parametrizada indiretamente."""
tipo = request.param
if tipo == "admin":
return Usuario(id="U001", nome="Admin", role="ADMIN")
elif tipo == "comum":
return Usuario(id="U002", nome="Comum", role="USER")
raise ValueError(f"Tipo desconhecido: {tipo}")
@pytest.mark.parametrize("usuario", ["admin", "comum"], indirect=True)
def test_usuario_tem_permissao_basica(usuario):
assert usuario.pode_fazer_login() is TrueMock e Monkeypatch
unittest.mock
from unittest.mock import Mock, MagicMock, patch, call, ANY
def test_servico_chama_repositorio():
# Mock simples
repositorio = Mock()
repositorio.salvar.return_value = Pedido(id="PED-001")
servico = PedidoService(repositorio=repositorio)
resultado = servico.criar(CriarPedidoDTO(cliente_id="CLI-001", valor=100.0))
# Verifica que salvar foi chamado
repositorio.salvar.assert_called_once()
# Verifica o argumento passado
chamada = repositorio.salvar.call_args
pedido_passado = chamada.args[0]
assert pedido_passado.cliente_id == "CLI-001"
def test_mock_configura_excecao():
repositorio = Mock()
repositorio.buscar_por_id.side_effect = PedidoNaoEncontradoError("PED-999")
servico = PedidoService(repositorio=repositorio)
with pytest.raises(PedidoNaoEncontradoError):
servico.buscar("PED-999")
def test_mock_verifica_multiplas_chamadas():
notificador = Mock()
servico = NotificacaoService(notificador=notificador)
servico.notificar_todos(["user1@ex.com", "user2@ex.com"])
# Verifica que foi chamado exatamente 2 vezes
assert notificador.enviar.call_count == 2
# Verifica todas as chamadas em ordem
notificador.enviar.assert_has_calls([
call("user1@ex.com", ANY),
call("user2@ex.com", ANY),
], any_order=False)
def test_mock_com_spec():
# spec=True — Mock respeita a interface da classe real
# Lança AttributeError se tentar acessar método que não existe
repositorio = Mock(spec=PedidoRepository)
repositorio.metodo_inexistente() # Lança AttributeError!
# @patch como decorador
@patch("myapp.services.EmailService")
@patch("myapp.services.PedidoRepository")
def test_criar_pedido_envia_email(mock_repo, mock_email):
# Decoradores são injetados na ordem reversa
mock_repo.return_value.salvar.return_value = Pedido(id="PED-001")
servico = PedidoService() # usa as classes mockadas
servico.criar(CriarPedidoDTO(cliente_id="CLI-001"))
mock_email.return_value.enviar_confirmacao.assert_called_once()
# @patch como context manager
def test_usar_patch_como_contexto():
with patch("myapp.services.calcular_frete") as mock_frete:
mock_frete.return_value = 15.90
pedido = PedidoService().criar(request)
assert pedido.frete == 15.90pytest-mock (plugin)
# pytest-mock fornece fixture "mocker" — mais integrado com pytest
# pip install pytest-mock
def test_com_mocker(mocker):
# mocker.patch — patch que é desfeito automaticamente após o teste
mock_email = mocker.patch("myapp.services.EmailService.enviar")
mock_frete = mocker.patch("myapp.services.calcular_frete", return_value=9.90)
servico = PedidoService()
pedido = servico.criar(CriarPedidoDTO(cliente_id="CLI-001", valor=200.0))
mock_email.assert_called_once()
assert pedido.frete == 9.90
def test_spy_com_mocker(mocker):
# mocker.spy — como patch mas chama a implementação real também
# Útil para verificar chamadas sem alterar comportamento
spy = mocker.spy(PedidoService, "calcular_desconto")
servico = PedidoService()
servico.criar(CriarPedidoDTO(cliente_id="CLI-VIP", valor=1000.0))
# Verifica que foi chamado com os argumentos corretos
spy.assert_called_once_with(1000.0, tipo_cliente="VIP")
def test_mock_com_side_effect_dinamico(mocker):
chamadas = []
def registrar_e_retornar(email, mensagem):
chamadas.append({"email": email, "msg": mensagem})
return True
mocker.patch("myapp.email.enviar", side_effect=registrar_e_retornar)
servico = NotificacaoService()
servico.notificar_pedido("user@ex.com", "PED-001")
assert len(chamadas) == 1
assert chamadas[0]["email"] == "user@ex.com"monkeypatch fixture
# monkeypatch — substitui atributos, variáveis de ambiente, imports
# Desfeito automaticamente após o teste
def test_usando_variavel_de_ambiente(monkeypatch):
# Substitui variável de ambiente
monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/testdb")
monkeypatch.setenv("DEBUG", "false")
config = Config.carregar_do_ambiente()
assert config.database_url == "postgresql://test:test@localhost/testdb"
assert config.debug is False
def test_substituir_funcao_builtin(monkeypatch):
# Substitui função builtin (cuidado — substitui globalmente no módulo)
monkeypatch.setattr("myapp.utils.time", lambda: 1704067200.0) # timestamp fixo
resultado = myapp.utils.formatar_data_atual()
assert resultado == "2024-01-01"
def test_substituir_metodo_de_classe(monkeypatch):
def fake_conectar(self, url):
return FakeConnection()
monkeypatch.setattr(DatabaseClient, "conectar", fake_conectar)
client = DatabaseClient()
conn = client.conectar("postgresql://qualquer-url")
assert isinstance(conn, FakeConnection)
def test_modificar_dicionario_de_sistema(monkeypatch):
# Modifica sys.path ou outros dicionários sem afetar outros testes
monkeypatch.syspath_prepend("/caminho/para/modulos/custom")
import modulo_custom # agora encontra no path modificado
def test_substituir_atributo_de_objeto(monkeypatch):
config = Config()
monkeypatch.setattr(config, "limite_requisicoes", 1000)
# Ou usando a sintaxe de string
monkeypatch.setattr("myapp.config.TIMEOUT_SEGUNDOS", 30)Testes de Exceções
def test_exceção_simples():
with pytest.raises(ValueError):
int("abc")
def test_exceção_com_mensagem_exata():
with pytest.raises(ValueError, match="Valor inválido: -5"):
validar_quantidade(-5)
def test_exceção_com_regex():
with pytest.raises(ValueError, match=r"Valor .* não pode ser negativo"):
calcular_raiz(-1)
def test_inspecionar_exceção():
with pytest.raises(CustomException) as exc_info:
raise CustomException("mensagem", codigo=404)
excecao = exc_info.value
assert excecao.mensagem == "mensagem"
assert excecao.codigo == 404
assert "mensagem" in str(excecao)
def test_exceção_com_cause():
with pytest.raises(ServicoIndisponivelError) as exc_info:
servico_que_falha()
# Verifica a causa raiz
assert isinstance(exc_info.value.__cause__, ConnectionError)
# Verificar que NÃO lança exceção
def test_nao_lanca_excecao():
# Simples — se lançar, o teste falha normalmente
resultado = dividir(10, 2)
assert resultado == 5.0
# Explícito com does_not_raise (importar se necessário)
from contextlib import nullcontext as does_not_raise
with does_not_raise():
somar(1, 2)Testes Assíncronos
# pip install pytest-asyncio
import pytest
import asyncio
import httpx
from unittest.mock import AsyncMock
# asyncio_mode = "auto" no pyproject.toml — não precisa de @pytest.mark.asyncio
# Se não configurado, adicione o marker manualmente
@pytest.mark.asyncio
async def test_buscar_usuario_async():
servico = UsuarioService()
usuario = await servico.buscar_por_id("U001")
assert usuario.id == "U001"
@pytest.fixture
async def cliente_http():
"""Fixture assíncrona — funciona com pytest-asyncio."""
async with httpx.AsyncClient(base_url="http://testserver") as client:
yield client
@pytest.mark.asyncio
async def test_endpoint_retorna_200(cliente_http):
resposta = await cliente_http.get("/api/health")
assert resposta.status_code == 200
def test_mock_coroutine_com_asyncmock():
"""Mockando funções assíncronas."""
repositorio = AsyncMock()
repositorio.buscar_por_id.return_value = Usuario(id="U001", nome="Teste")
# asyncio.run necessário sem pytest-asyncio ou em testes síncronos
async def executar():
servico = UsuarioService(repositorio=repositorio)
return await servico.buscar("U001")
usuario = asyncio.run(executar())
assert usuario.nome == "Teste"
repositorio.buscar_por_id.assert_awaited_once_with("U001")
# Fixture assíncrona de escopo de sessão
@pytest.fixture(scope="session")
async def app_assincrono():
"""Cria aplicação FastAPI uma vez para toda a sessão."""
from myapp import create_app
app = create_app()
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield clientCoverage com pytest-cov
# Instalar
pip install pytest-cov
# Executar com cobertura
pytest --cov=src
# Relatório detalhado — mostra linhas não cobertas
pytest --cov=src --cov-report=term-missing
# Gera HTML interativo em htmlcov/index.html
pytest --cov=src --cov-report=html
# Múltiplos formatos de relatório
pytest --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml
# Falha se cobertura abaixo do mínimo
pytest --cov=src --cov-fail-under=80
# Arquivo de configuração (pyproject.toml)
# [tool.coverage.run]
# source = ["src"]
# branch = true # cobertura de branches (if/else)
# omit = ["tests/*", "*/migrations/*"]# Excluindo código específico do coverage
# Linha única
resultado = funcao_debug() # pragma: no cover
# Bloco
if TYPE_CHECKING: # pragma: no cover
from myapp.types import MinhaInterface
# Comentário "noqa" não afeta coverage — use pragma: no cover
# .coveragerc ou pyproject.toml
# [tool.coverage.report]
# exclude_lines =
# pragma: no cover
# def __repr__
# if TYPE_CHECKING:
# raise NotImplementedError
# if __name__ == .__main__.:Plugins Úteis
pytest-httpx
# Para testar código que faz chamadas HTTP com httpx
# pip install pytest-httpx
def test_buscar_cep(httpx_mock):
httpx_mock.add_response(
url="https://viacep.com.br/ws/01310100/json/",
json={
"cep": "01310-100",
"logradouro": "Avenida Paulista",
"localidade": "São Paulo",
"uf": "SP",
}
)
resultado = BuscaCep().buscar("01310100")
assert resultado.logradouro == "Avenida Paulista"
assert resultado.uf == "SP"
def test_tratar_erro_de_cep_invalido(httpx_mock):
httpx_mock.add_response(
url="https://viacep.com.br/ws/00000000/json/",
status_code=400
)
with pytest.raises(CepInvalidoError):
BuscaCep().buscar("00000000")pytest-django
# pip install pytest-django
# pytest.ini ou pyproject.toml
# [pytest]
# DJANGO_SETTINGS_MODULE = myproject.settings.test
import pytest
from django.test import TestCase
from myapp.models import Produto
@pytest.mark.django_db
def test_criar_produto():
"""@pytest.mark.django_db permite acesso ao banco de dados."""
produto = Produto.objects.create(
nome="Notebook",
preco=3000.00,
estoque=10
)
assert produto.id is not None
assert Produto.objects.count() == 1
@pytest.fixture
def produto_factory(db):
"""Factory para criar produtos de teste."""
def _factory(**kwargs):
defaults = {"nome": "Produto Teste", "preco": 100.00, "estoque": 5}
defaults.update(kwargs)
return Produto.objects.create(**defaults)
return _factory
@pytest.mark.django_db
def test_buscar_produtos_em_estoque(produto_factory):
produto_factory(nome="Disponível", estoque=5)
produto_factory(nome="Esgotado", estoque=0)
disponiveis = Produto.objects.filter(estoque__gt=0)
assert disponiveis.count() == 1
assert disponiveis.first().nome == "Disponível"
# Testando views Django
@pytest.mark.django_db
def test_endpoint_produtos(client):
"""client é fixture nativa do pytest-django."""
Produto.objects.create(nome="Mouse", preco=150.0)
response = client.get("/api/produtos/")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["nome"] == "Mouse"factory-boy
# pip install factory-boy
# Gera objetos de teste com dados realistas e relacionamentos
import factory
from factory.django import DjangoModelFactory # ou factory.Factory para Python puro
class ClienteFactory(DjangoModelFactory):
class Meta:
model = Cliente
# Dados sequenciais únicos
id = factory.Sequence(lambda n: f"CLI-{n:04d}")
nome = factory.Faker("name", locale="pt_BR")
email = factory.LazyAttribute(lambda obj: f"{obj.nome.lower().replace(' ', '.')}@example.com")
tipo = "COMUM"
class ClienteVipFactory(ClienteFactory):
"""Herda de ClienteFactory e sobrescreve tipo."""
tipo = "VIP"
limite_credito = factory.Faker("pydecimal", left_digits=5, right_digits=2, positive=True)
class PedidoFactory(DjangoModelFactory):
class Meta:
model = Pedido
id = factory.Sequence(lambda n: f"PED-{n:06d}")
cliente = factory.SubFactory(ClienteFactory) # cria cliente relacionado
valor = factory.Faker("pydecimal", left_digits=4, right_digits=2, positive=True)
status = "PENDENTE"
# Uso nos testes
@pytest.mark.django_db
def test_pedido_vip_tem_desconto():
pedido = PedidoFactory(cliente=ClienteVipFactory(), valor=1000.0)
assert pedido.calcular_total() < 1000.0
@pytest.mark.django_db
def test_listar_pedidos_do_cliente():
cliente = ClienteFactory()
PedidoFactory.create_batch(5, cliente=cliente)
PedidoFactory.create_batch(3) # pedidos de outros clientes
pedidos = Pedido.objects.filter(cliente=cliente)
assert pedidos.count() == 5Testes de Integração com Banco
# Usando testcontainers-python
# pip install testcontainers
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
import sqlalchemy
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def engine(postgres):
url = postgres.get_connection_url()
engine = sqlalchemy.create_engine(url)
# Cria as tabelas
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
@pytest.fixture
def session(engine):
"""Session com rollback por teste."""
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
def test_repositorio_com_banco_real(session):
repo = PedidoRepository(session)
pedido = Pedido(cliente_id="CLI-001", valor=500.0)
salvo = repo.salvar(pedido)
assert salvo.id is not None
recuperado = repo.buscar_por_id(salvo.id)
assert recuperado.cliente_id == "CLI-001"
assert recuperado.valor == 500.0
def test_busca_complexa_com_banco_real(session):
repo = PedidoRepository(session)
# Insere dados de teste
for i in range(10):
session.add(Pedido(
cliente_id=f"CLI-{i % 3:03d}",
valor=float(i * 100),
status="CONFIRMADO" if i % 2 == 0 else "CANCELADO"
))
session.flush()
# Testa query complexa
resultado = repo.buscar_confirmados_por_cliente("CLI-000")
assert all(p.status == "CONFIRMADO" for p in resultado)
assert all(p.cliente_id == "CLI-000" for p in resultado)Boas Práticas
Naming e Organização
# Nomes descritivos — o que o teste verifica
# Padrão: test_<método>_<cenário>_<resultado_esperado>
def test_calcular_desconto_cliente_vip_retorna_dez_porcento(): ...
def test_calcular_desconto_valor_negativo_lanca_valor_error(): ...
def test_buscar_pedido_id_invalido_lanca_pedido_nao_encontrado(): ...
# Padrão AAA — Arrange, Act, Assert
def test_aplicar_desconto_progressivo():
# Arrange — prepara dados e dependências
pedido = Pedido(valor=1000.0)
politica = PoliticaDescontoProgressiva(percentuais={5: 0.05, 10: 0.10})
# Act — executa a ação sendo testada
total = politica.aplicar(pedido, quantidade=8)
# Assert — verifica o resultado
assert total == 950.0 # 5% de desconto para 5-9 unidades
# Um comportamento por teste — evitar múltiplos asserts não relacionados
def test_criar_cliente_valida_todos_os_campos():
# Ruim — falha no primeiro assert, não mostra os outros problemas
cliente = Cliente(nome="João", email="joao@ex.com", cpf="111.444.777-35")
assert cliente.nome == "João"
assert cliente.email == "joao@ex.com"
assert cliente.cpf_formatado == "111.444.777-35"
# Melhor — usa parametrize ou organiza com @pytest.mark.parametrize
# Ou mantém junto quando são de fato o mesmo comportamento (criação válida)
# Evitar lógica condicional em testes
def test_com_condicional_ruim():
resultado = processar(dados)
if resultado.sucesso:
assert resultado.valor > 0
else:
assert resultado.erro is not None
# Problema: o teste sempre passa — não verifica ambos os caminhos
def test_sem_condicional_bom():
# Teste explícito para cada caminho
resultado = processar(dados_validos)
assert resultado.sucesso is True
assert resultado.valor > 0Isolamento e Determinismo
# Ruim — teste depende de estado global/externo
CONTADOR_GLOBAL = 0
def test_incrementar_contador():
global CONTADOR_GLOBAL
CONTADOR_GLOBAL += 1
assert CONTADOR_GLOBAL == 1 # Falha se outro teste rodou antes!
# Bom — cada teste cria seu próprio estado
def test_incrementar_contador():
contador = Contador()
contador.incrementar()
assert contador.valor == 1
# Evitar dados de teste hardcoded que podem expirar
def test_ruim_com_data_hardcoded():
resultado = calcular_idade(data_nascimento="1990-01-01")
assert resultado == 34 # Errado em 2025!
def test_bom_com_data_relativa():
from datetime import date, timedelta
nascimento = date.today() - timedelta(days=365 * 30) # 30 anos atrás
resultado = calcular_idade(nascimento)
assert resultado == 30
# Fixtures com escopo correto — session para recursos caros
@pytest.fixture(scope="session")
def container_postgres():
"""Container criado uma vez para toda a sessão — startup caro."""
with PostgresContainer() as pg:
yield pg
# function para dados mutáveis — garantir isolamento
@pytest.fixture
def dados_cliente(session):
"""Dados criados a cada teste e revertidos via rollback."""
cliente = Cliente(nome="Teste", email="teste@ex.com")
session.add(cliente)
session.flush()
return clienteChecklist de Boas Práticas
✓ Cada teste valida um único comportamento
✓ Nomes descrevem o que está sendo testado (sem "test1", "test_x")
✓ Padrão AAA (Arrange-Act-Assert) com comentários quando útil
✓ Testes independentes — sem ordem de execução implícita
✓ Use fixtures para setup/teardown (não setUp/tearDown de unittest)
✓ conftest.py para fixtures compartilhadas entre módulos
✓ Escopo de fixture correto: function para dados, session para infra
✓ yield fixtures com teardown explícito para recursos externos
✓ pytest.mark.parametrize para múltiplos casos de um mesmo comportamento
✓ pytest-mock / mocker em vez de @patch como decorador (menos boilerplate)
✓ Testcontainers para integração — evita mocks de banco complexos
✓ Coverage mínimo de 80% com --cov-fail-under
✓ Markers para categorizar (integracao, lento, smoke)
✓ Evite lógica condicional (if/for) dentro de testes
✓ factory-boy para geração de objetos com relacionamentos complexos