No artigo sobre injeção de dependência ficou um problema em aberto. A classe OrderService
não dava para testar sem subir banco, sem fazer chamada HTTP real, sem criar arquivo em disco.
A solução apresentada foi injetar as dependências pelo construtor — o que deixa o código
testável. Mas testável não significa testado. Este artigo fecha esse loop.
O objetivo aqui não é ensinar assert 1 == 1. É mostrar as ferramentas que separam uma
suite de testes que protege o código de uma suite que só infla a cobertura: fixtures com
escopo controlado, parametrize para eliminar duplicação, e mocks com pytest-mock para
isolar dependências externas de verdade.
O ponto de partida#
O código que vai servir de base vem diretamente do artigo de injeção de dependência. Um
serviço de pedidos com duas dependências injetadas: um repositório de banco e um cliente HTTP
para notificações.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| # services.py
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Order:
id: int
customer_id: int
total: float
created_at: datetime
class OrderRepository:
def get(self, order_id: int) -> Order | None:
# Na implementação real: SELECT no banco
raise NotImplementedError
def save(self, order: Order) -> Order:
# Na implementação real: INSERT/UPDATE no banco
raise NotImplementedError
class NotificationClient:
def send(self, customer_id: int, message: str) -> bool:
# Na implementação real: chamada HTTP para serviço externo
raise NotImplementedError
class OrderService:
def __init__(
self,
repository: OrderRepository,
notification_client: NotificationClient,
) -> None:
self._repo = repository
self._notifications = notification_client
def confirm_order(self, order_id: int) -> Order:
order = self._repo.get(order_id)
if order is None:
raise ValueError(f"Pedido {order_id} não encontrado")
if order.total <= 0:
raise ValueError("Pedido com total inválido não pode ser confirmado")
self._notifications.send(
order.customer_id,
f"Pedido #{order.id} confirmado. Total: R$ {order.total:.2f}",
)
return order
|
Sem injeção de dependência, testar confirm_order exigiria banco real e serviço de
notificação real. Com a estrutura acima, basta substituir as dependências por implementações
controladas. É exatamente isso que o pytest permite fazer com precisão cirúrgica.
Instalação#
1
| pip install pytest pytest-mock
|
A separação importa: pytest é o framework de testes; pytest-mock é o plugin que integra
unittest.mock ao sistema de fixtures do pytest com uma API mais ergonômica.
Fixtures: dependências controláveis e reutilizáveis#
Uma fixture no pytest é uma função que prepara algum recurso para o teste. O decorator
@pytest.fixture registra a função, e qualquer teste que declare o nome da fixture como
parâmetro a recebe automaticamente — sem herança de classe, sem setUp, sem cerimônia.
O exemplo mais direto: criar implementações falsas (fakes) das dependências do
OrderService.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| # tests/test_order_service.py
from datetime import datetime
import pytest
from services import Order, NotificationClient, OrderRepository, OrderService
class FakeOrderRepository(OrderRepository):
"""Repositório em memória para testes."""
def __init__(self) -> None:
self._store: dict[int, Order] = {}
def get(self, order_id: int) -> Order | None:
return self._store.get(order_id)
def save(self, order: Order) -> Order:
self._store[order.id] = order
return order
def add(self, order: Order) -> None:
"""Método auxiliar para popular o fake nos testes."""
self._store[order.id] = order
class FakeNotificationClient(NotificationClient):
"""Cliente de notificação que registra as mensagens enviadas."""
def __init__(self) -> None:
self.sent: list[tuple[int, str]] = []
def send(self, customer_id: int, message: str) -> bool:
self.sent.append((customer_id, message))
return True
@pytest.fixture
def repository() -> FakeOrderRepository:
return FakeOrderRepository()
@pytest.fixture
def notification_client() -> FakeNotificationClient:
return FakeNotificationClient()
@pytest.fixture
def service(
repository: FakeOrderRepository,
notification_client: FakeNotificationClient,
) -> OrderService:
return OrderService(
repository=repository,
notification_client=notification_client,
)
|
Fixtures podem depender de outras fixtures — o pytest resolve a cadeia automaticamente.
A fixture service recebe repository e notification_client, que por sua vez são
fixtures definidas acima. Nos testes, basta pedir service e todo o grafo de dependências
é construído.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| def test_confirm_order_returns_order(
service: OrderService,
repository: FakeOrderRepository,
) -> None:
order = Order(id=1, customer_id=42, total=150.00, created_at=datetime.now())
repository.add(order)
result = service.confirm_order(order_id=1)
assert result.id == 1
assert result.total == 150.00
def test_confirm_order_sends_notification(
service: OrderService,
repository: FakeOrderRepository,
notification_client: FakeNotificationClient,
) -> None:
order = Order(id=2, customer_id=99, total=75.50, created_at=datetime.now())
repository.add(order)
service.confirm_order(order_id=2)
assert len(notification_client.sent) == 1
customer_id, message = notification_client.sent[0]
assert customer_id == 99
assert "R$ 75.50" in message
|
Cada teste recebe instâncias frescas das fixtures — não há estado compartilhado entre testes
por padrão. O FakeNotificationClient.sent começa vazio em cada teste, o que elimina uma
classe inteira de bugs difíceis de diagnosticar (testes que passam ou falham dependendo da
ordem de execução).
Escopo de fixture#
O comportamento padrão — instância nova por teste — é o correto para a maioria dos casos.
Mas há situações onde inicializar um recurso a cada teste é caro demais: conexão real com
banco de testes, carregamento de arquivo grande, inicialização de servidor local.
O parâmetro scope controla o tempo de vida da fixture:
1
2
3
4
5
6
| @pytest.fixture(scope="module")
def db_connection():
"""Conexão criada uma vez por módulo de teste, não por teste."""
conn = create_test_database_connection()
yield conn
conn.close()
|
Os escopos disponíveis, do mais curto ao mais longo: "function" (padrão), "class",
"module", "package", "session".
A palavra-chave yield merece atenção. Tudo antes do yield é setup; tudo depois é
teardown. O pytest garante que o código de teardown executa mesmo se o teste falhar —
equivalente a um try/finally automático. É o padrão correto para qualquer fixture que
abre um recurso.
1
2
3
4
5
6
7
8
| @pytest.fixture
def temp_file(tmp_path):
"""tmp_path é uma fixture built-in do pytest que cria um diretório temporário."""
file = tmp_path / "test_data.csv"
file.write_text("id,name\n1,Alice\n2,Bob\n")
yield file
# O pytest limpa tmp_path automaticamente, mas se fosse um recurso externo:
# cleanup_code_here()
|
parametrize: eliminando testes duplicados#
O padrão mais comum de duplicação em suites de teste é o seguinte:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Forma ruim — três testes que testam a mesma coisa com dados diferentes
def test_confirm_order_raises_for_order_not_found(service):
with pytest.raises(ValueError, match="não encontrado"):
service.confirm_order(order_id=999)
def test_confirm_order_raises_for_zero_total(service, repository):
order = Order(id=1, customer_id=1, total=0.0, created_at=datetime.now())
repository.add(order)
with pytest.raises(ValueError, match="total inválido"):
service.confirm_order(order_id=1)
def test_confirm_order_raises_for_negative_total(service, repository):
order = Order(id=2, customer_id=1, total=-10.0, created_at=datetime.now())
repository.add(order)
with pytest.raises(ValueError, match="total inválido"):
service.confirm_order(order_id=2)
|
O decorator @pytest.mark.parametrize resolve isso sem perder granularidade de diagnóstico
— cada combinação de parâmetros gera um teste independente com ID próprio na saída:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @pytest.mark.parametrize(
"order_id, total, expected_message",
[
(999, None, "não encontrado"), # pedido inexistente
(1, 0.0, "total inválido"), # total zero
(1, -10.0, "total inválido"), # total negativo
],
ids=["order_not_found", "zero_total", "negative_total"],
)
def test_confirm_order_raises_for_invalid_input(
service: OrderService,
repository: FakeOrderRepository,
order_id: int,
total: float | None,
expected_message: str,
) -> None:
if total is not None:
order = Order(id=order_id, customer_id=1, total=total, created_at=datetime.now())
repository.add(order)
with pytest.raises(ValueError, match=expected_message):
service.confirm_order(order_id=order_id)
|
O parâmetro ids dá nomes legíveis aos casos na saída do pytest. Sem ele, o pytest gera
IDs automáticos baseados nos valores (999-None-não encontrado), o que funciona mas é menos
expressivo em suites grandes.
A saída do pytest com ids explícitos:
PASSED tests/test_order_service.py::test_confirm_order_raises_for_invalid_input[order_not_found]
PASSED tests/test_order_service.py::test_confirm_order_raises_for_invalid_input[zero_total]
PASSED tests/test_order_service.py::test_confirm_order_raises_for_invalid_input[negative_total]
Quando um caso falha, o ID aparece no relatório — é imediatamente claro qual cenário quebrou,
sem precisar inspecionar os parâmetros.
Uma limitação do parametrize padrão é que os valores são estáticos — não podem chamar
fixtures. Para parametrizar com fixtures, o pytest oferece o params no próprio decorator
de fixture combinado com request.param:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @pytest.fixture(params=[0.0, -1.0, -100.0], ids=["zero", "minus_one", "minus_hundred"])
def invalid_total(request) -> float:
return request.param
def test_confirm_order_raises_for_invalid_total(
service: OrderService,
repository: FakeOrderRepository,
invalid_total: float,
) -> None:
order = Order(id=1, customer_id=1, total=invalid_total, created_at=datetime.now())
repository.add(order)
with pytest.raises(ValueError, match="total inválido"):
service.confirm_order(order_id=1)
|
Fakes são ótimos quando a dependência tem comportamento que vale exercitar — como o
FakeOrderRepository que verifica se o pedido existe de fato. Mas há casos onde o que
interessa é apenas verificar que uma chamada aconteceu com os argumentos certos, ou simular
um comportamento excepcional sem criar uma classe inteira para isso. É o território dos mocks.
O pytest-mock fornece a fixture mocker, que é um wrapper em torno de unittest.mock
com integração automática ao ciclo de vida dos testes — não é preciso fazer patcher.stop()
manualmente, o mock é revertido automaticamente ao final de cada teste.
Verificando chamadas#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| def test_confirm_order_calls_notification_with_correct_args(
mocker,
repository: FakeOrderRepository,
) -> None:
mock_client = mocker.MagicMock()
service = OrderService(repository=repository, notification_client=mock_client)
order = Order(id=5, customer_id=77, total=200.00, created_at=datetime.now())
repository.add(order)
service.confirm_order(order_id=5)
mock_client.send.assert_called_once_with(
77,
"Pedido #5 confirmado. Total: R$ 200.00",
)
|
MagicMock aceita qualquer atribuição e qualquer chamada sem reclamar, registrando tudo.
assert_called_once_with verifica que o método foi chamado exatamente uma vez, com
exatamente esses argumentos. Se a asserção falhar, o pytest mostra a diferença entre o
esperado e o que foi chamado de fato.
Simulando falhas#
Testar o caminho feliz é a parte fácil. A parte que protege o código em produção é testar
o que acontece quando dependências externas falham.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| def test_confirm_order_propagates_notification_failure(
mocker,
repository: FakeOrderRepository,
) -> None:
mock_client = mocker.MagicMock()
mock_client.send.side_effect = ConnectionError("Serviço de notificação indisponível")
service = OrderService(repository=repository, notification_client=mock_client)
order = Order(id=6, customer_id=10, total=50.00, created_at=datetime.now())
repository.add(order)
with pytest.raises(ConnectionError, match="indisponível"):
service.confirm_order(order_id=6)
|
side_effect pode receber uma exceção (que será levantada quando o mock for chamado),
uma lista de valores (retornados em sequência a cada chamada), ou uma função (chamada com
os mesmos argumentos do mock).
mocker.patch: substituindo dependências no ponto de uso#
Às vezes a dependência não é injetada pelo construtor — é uma chamada direta a uma função
do módulo, datetime.now(), ou qualquer coisa que não dá para substituir facilmente via
construtor. mocker.patch resolve isso.
Suponha que confirm_order registre um timestamp interno usando datetime.now() e isso
precise ser verificado:
1
2
3
4
5
6
7
8
9
10
11
12
| # services.py (versão modificada)
from datetime import datetime
class OrderService:
def confirm_order(self, order_id: int) -> dict:
order = self._repo.get(order_id)
if order is None:
raise ValueError(f"Pedido {order_id} não encontrado")
confirmed_at = datetime.now() # dependência difícil de controlar
self._notifications.send(order.customer_id, f"Pedido #{order.id} confirmado.")
return {"order": order, "confirmed_at": confirmed_at}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def test_confirm_order_records_confirmation_timestamp(
mocker,
repository: FakeOrderRepository,
notification_client: FakeNotificationClient,
) -> None:
fixed_time = datetime(2026, 3, 17, 12, 0, 0)
mocker.patch("services.datetime") .now.return_value = fixed_time
service = OrderService(repository=repository, notification_client=notification_client)
order = Order(id=7, customer_id=1, total=100.00, created_at=datetime.now())
repository.add(order)
result = service.confirm_order(order_id=7)
assert result["confirmed_at"] == fixed_time
|
O argumento de mocker.patch é o caminho completo do objeto no módulo onde ele é usado,
não onde ele é definido. "services.datetime" funciona porque é de services que
datetime é importado e chamado. Esse é o erro mais comum com patch: tentar fazer patch
no módulo de origem em vez do módulo de uso.
Organizando a suite#
Com fixtures e testes crescendo, a organização importa. A estrutura recomendada:
project/
├── src/
│ └── services.py
└── tests/
├── conftest.py ← fixtures compartilhadas entre módulos
├── test_order_service.py
└── test_another_module.py
O arquivo conftest.py é carregado automaticamente pelo pytest. Fixtures definidas nele
ficam disponíveis para todos os testes no mesmo diretório e subdiretórios — sem precisar
importar nada.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| # tests/conftest.py
import pytest
from services import OrderRepository, NotificationClient, OrderService
from tests.fakes import FakeOrderRepository, FakeNotificationClient
@pytest.fixture
def repository() -> FakeOrderRepository:
return FakeOrderRepository()
@pytest.fixture
def notification_client() -> FakeNotificationClient:
return FakeNotificationClient()
@pytest.fixture
def service(
repository: FakeOrderRepository,
notification_client: FakeNotificationClient,
) -> OrderService:
return OrderService(
repository=repository,
notification_client=notification_client,
)
|
Fixtures de escopo mais amplo (session, module) também ficam bem no conftest.py — é
o lugar natural para recursos caros compartilhados entre vários arquivos de teste.
Executando e lendo a saída#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Rodar todos os testes
pytest
# Verbose: ver o nome de cada teste
pytest -v
# Parar no primeiro erro
pytest -x
# Rodar apenas testes com um nome específico
pytest -k "notification"
# Ver a cobertura (requer pytest-cov)
pytest --cov=src --cov-report=term-missing
|
A saída do --cov-report=term-missing mostra quais linhas não foram cobertas por nenhum
teste. É a métrica mais útil para identificar caminhos de código ainda sem proteção.
Name Stmts Miss Cover Missing
---------------------------------------------
src/services.py 28 2 93% 45, 61
---------------------------------------------
TOTAL 28 2 93%
Linhas 45 e 61 — fácil de saber exatamente onde focar.
O que veio antes e o que vem depois#
As fixtures e mocks deste artigo só funcionam com a estrutura que o artigo de injeção de dependência estabeleceu: dependências recebidas pelo construtor, interfaces implícitas via
duck typing. Sem isso, mocker.patch e MagicMock ficam remendando código acoplado em
vez de testando comportamento.
O próximo nível é o Hypothesis — uma biblioteca que gera casos de teste automaticamente e
encontra edge cases que qualquer teste manual perderia. Mas ele pressupõe exatamente essa
base: uma suite pytest funcionando, com fixtures organizadas e mocks no lugar certo.
Se quiser continuar a conversa, estou no Fediverse em @riverfount@bolha.us.