Testes

Pytest

Guia completo de testes em Python com Pytest — fixtures, markers, parametrize, mock, cobertura e boas práticas

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 --version

pytest.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 = true

Primeiro 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() == 10

Escopos 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 automaticamente

conftest.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 --markers

Parametrize

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 True

Mock 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.90

pytest-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 client

Coverage 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() == 5

Testes 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 > 0

Isolamento 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 cliente

Checklist 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