Escrevendo testes com pytest e fixture em Python
1. Visão geral
À medida que o número de testes aumenta, a parte de preparação de dados (criar clientes, pedidos, conexões com banco de dados…) tende a se repetir por toda parte. pytest resolve esse problema com fixtures: uma função que fornece dados ou recursos para um teste e os limpa automaticamente após a execução.
Este artigo vai desde seu primeiro teste com pytest até fixtures com yield, escopo, conftest.py, fixtures integradas e parametrização. Ao final, você será capaz de organizar sua bateria de testes Python de forma clara, legível e com menos código repetido.
2. Instalando pytest
Você deve instalar pytest em um virtual environment:
python -m venv .venv
source .venv/bin/activate
pip install pytest
A estrutura do projeto usada neste artigo é assim:
shop/
├── shop.py
└── tests/
├── conftest.py
├── test_order.py
└── test_repository.py
pytest procura automaticamente por arquivos nomeados test_*.py ou *_test.py. Em cada arquivo, ele executa funções que começam com test_.
Para importar shop do diretório tests, adicione a configuração ao pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
3. Seu primeiro teste
Aqui está o código de negócio em shop.py. Preços são representados com tipo int para evitar erros de ponto flutuante:
from dataclasses import dataclass, field
@dataclass
class Customer:
name: str
is_vip: bool = False
@dataclass
class Order:
customer: Customer
items: list[tuple[str, int, int]] = field(default_factory=list)
def add_item(self, sku: str, quantity: int, price: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.items.append((sku, quantity, price))
def total(self) -> int:
subtotal = sum(qty * price for _, qty, price in self.items)
return subtotal * 90 // 100 if self.customer.is_vip else subtotal
Clientes VIP recebem 10% de desconto. Testes para essa lógica em tests/test_order.py:
import pytest
from shop import Customer, Order
def test_total_for_regular_customer():
order = Order(Customer("An"))
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_add_item_rejects_zero_quantity():
order = Order(Customer("An"))
with pytest.raises(ValueError, match="quantity must be positive"):
order.add_item("BOOK-01", 0, 50_000)
Você precisa apenas de duas coisas:
assertpuro do Python. Você não precisa deassertEqualou bibliotecas de asserção próprias. pytest reescreve a afirmação para relatar erros detalhados.pytest.raisesverifica se um bloco de código lança uma exceção do tipo correto. O parâmetromatché uma regex para corresponder à mensagem da exceção.
Execute os testes:
pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED [100%]
============================== 2 passed in 0.01s ===============================
Se você intencionalmente alterar o valor esperado para 90_000, pytest exibirá ambos os lados:
> assert order.total() == 90_000
E assert 100000 == 90000
E + where 100000 = total()
4. Fixtures básicas
4.1. Declarando e usando fixtures
Ambos os testes acima criam Customer("An") e Order. Quando há dezenas de testes, esse código é repetido constantemente. Movemos para uma fixture:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
def test_total_for_regular_customer(order):
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_empty_order_has_zero_total(order):
assert order.total() == 0
Como funciona:
@pytest.fixturemarca uma função como uma fixture.- O teste declara um parâmetro com o mesmo nome da fixture. pytest chama a fixture e passa o valor retornado para o teste. Essa é injeção de dependência por nome.
- A fixture
orderpode depender da fixturecustomer. pytest resolve automaticamente a ordem.
Por padrão, cada teste recebe uma nova instância. Portanto, o add_item no primeiro teste não muda o order do segundo teste.
4.2. Factory fixture
Às vezes, um teste precisa de vários pedidos com parâmetros diferentes. Em vez de retornar um objeto, a fixture pode retornar uma função que cria objetos:
@pytest.fixture
def make_order():
def _make(name="An", is_vip=False, items=()):
order = Order(Customer(name, is_vip))
for sku, qty, price in items:
order.add_item(sku, qty, price)
return order
return _make
def test_vip_gets_ten_percent_discount(make_order):
regular = make_order(items=[("BOOK-01", 1, 200_000)])
vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])
assert regular.total() == 200_000
assert vip.total() == 180_000
Este padrão mantém a preparação concisa enquanto ainda permite que o teste decida seus próprios dados.
5. Setup e teardown com yield
Fixtures são realmente úteis ao trabalhar com recursos que precisam ser limpos, como conexões de banco de dados, arquivos ou servidores mock. Adicione um repositório usando SQLite a shop.py:
import sqlite3
class OrderRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def save(self, customer_name: str, total: int) -> int:
cur = self.conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)",
(customer_name, total),
)
return cur.lastrowid
def count(self) -> int:
return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
A fixture usa yield em vez de return:
import sqlite3
import pytest
from shop import OrderRepository
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
return OrderRepository(db_connection)
def test_save_order_returns_id(repository):
order_id = repository.save("An", 100_000)
assert order_id == 1
assert repository.count() == 1
A parte antes do yield é setup. O valor após yield é passado para o teste. A parte após yield é teardown e sempre é executada, mesmo se o teste falhar. Você não precisa de try/finally.
A flag --setup-show mostra a ordem clara:
pytest tests/test_repository.py --setup-show
tests/test_repository.py
SETUP F db_connection
SETUP F repository (fixtures used: db_connection)
tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
TEARDOWN F repository
TEARDOWN F db_connection
A letra F significa escopo function. O teardown é executado na ordem inversa do setup.
Observação: Se uma exceção ocorrer antes do
yield, a parte teardown não será executada. Isso é porque a fixture não concluiu sua configuração. Você deve manter a parte setup breve, ou envolver emtry/finallyse o setup tiver várias etapas que podem falhar.
6. Escopo das fixtures
Criar recursos caros (conexões de banco de dados reais, iniciar contêineres…) para cada teste tornará a bateria de testes lenta. O parâmetro scope determina quanto tempo uma fixture vive:
| Escopo | Recriada quando |
|---|---|
function (padrão) |
cada teste |
class |
cada classe de teste |
module |
cada arquivo de teste |
package |
cada pacote |
session |
uma vez para toda a execução |
Por exemplo, compartilhar uma conexão para todo o arquivo, mas limpar dados após cada teste:
@pytest.fixture(scope="module")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
yield OrderRepository(db_connection)
db_connection.execute("DELETE FROM orders")
def test_first_save(repository):
repository.save("An", 100_000)
assert repository.count() == 1
def test_second_save_starts_clean(repository):
repository.save("Binh", 50_000)
assert repository.count() == 1
db_connection é criada apenas uma vez. repository tem escopo function, então limpa a tabela após cada teste. Graças a isso, o segundo teste ainda vê uma tabela vazia.
Há uma regra importante: uma fixture só pode usar outras fixtures com escopo igual ou mais amplo. Se uma fixture de session usar uma fixture de function, pytest reporta um erro:
ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.
7. Compartilhando fixtures via conftest.py
As fixtures customer e make_order são úteis para vários arquivos de teste. Você as coloca em tests/conftest.py:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
pytest carrega conftest.py automaticamente. Todos os testes nesse diretório e subdiretórios podem usar essas fixtures sem precisar importar. Você pode colocar conftest.py em vários níveis de diretório. Fixtures em níveis mais próximos do teste substituem aquelas em níveis externos.
Observação: Não importe
conftestem arquivos de teste. Se fizer isso, a fixture pode ser registrada duas vezes, causando comportamento impredizível.
Para ver quais testes usam quais fixtures e onde essas fixtures são definidas, execute:
pytest --fixtures-per-test tests/test_order.py
8. Fixtures integradas
pytest fornece várias fixtures convenientes. As três mais usadas são tmp_path, monkeypatch e capsys.
Suponha que shop.py tenha mais duas funções:
import os
from pathlib import Path
def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
path.write_text("\n".join(lines), encoding="utf-8")
class PaymentService:
def __init__(self):
self.api_key = os.environ["PAYMENT_API_KEY"]
def charge(self, order: "Order") -> str:
print(f"Charging {order.total()} VND")
return "SUCCESS"
Testes para elas:
from shop import PaymentService, export_orders_csv
def test_export_orders_csv(tmp_path):
output = tmp_path / "orders.csv"
export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)
assert output.read_text(encoding="utf-8").splitlines() == [
"customer,total",
"An,100000",
"Binh,50000",
]
def test_payment_service_charges_order(monkeypatch, capsys, order):
monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
order.add_item("BOOK-01", 1, 75_000)
result = PaymentService().charge(order)
assert result == "SUCCESS"
assert capsys.readouterr().out == "Charging 75000 VND\n"
tmp_pathé umpathlib.Pathapontando para um diretório temporário, único para cada teste. Os testes não sujam o diretório do projeto.monkeypatchmodifica variáveis de ambiente, atributos ou funções dentro do escopo de um teste. Todas as alterações são desfeitas automaticamente quando o teste termina. Além desetenv, você frequentemente usamonkeypatch.setattr(obj, "name", value).capsyscaptura a saída impressa emstdoutestderr.readouterr()retorna o que foi impresso desde o início do teste ou desde a chamada anterior.
9. Parametrizando testes
9.1. @pytest.mark.parametrize
Quando a mesma lógica precisa ser testada com múltiplos conjuntos de dados, você não deve copiar o teste. Use parametrize:
import pytest
@pytest.mark.parametrize(
("is_vip", "price", "expected"),
[
(False, 100_000, 100_000),
(True, 100_000, 90_000),
(True, 15, 13),
],
ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED
Cada conjunto de parâmetros é um teste separado, com IDs legíveis graças ao ids. Note que você pode combinar fixtures (make_order) com parâmetros. O caso vip-rounding-down documenta claramente o comportamento de arredondamento para baixo da divisão inteira.
9.2. Fixtures parametrizadas
Se você quer que todos os testes que usam uma fixture sejam executados com múltiplas variantes, você parametriza a própria fixture:
@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
return Customer("An", is_vip=request.param)
def test_total_is_never_negative(any_customer):
order = Order(any_customer)
order.add_item("BOOK-01", 3, 10_000)
assert order.total() >= 0
request é uma fixture especial. request.param contém o valor atual. O teste acima é executado automaticamente duas vezes.
10. Erros comuns e quando não usar
- Nome de fixture incorreto. Um nome de parâmetro que não corresponde a nenhuma fixture causará erro
fixture 'oder' not found. pytest lista as fixtures disponíveis logo abaixo, leia atentamente para corrigir. - Chamar fixture como uma função normal. Escrever
customer()dentro do teste relatará erroFixture "customer" called directly. Declare-o como um parâmetro. - Escopo amplo com dados mutáveis. Um
Ordercom escopo de módulo que um teste modifica fará outro teste falhar. O resultado depende da ordem de execução, muito difícil de depurar. Use escopo amplo apenas para recursos caros e sem estado, ou limpe o estado em fixtures com escopofunctioncomo na seção 6. - Usar
autouse=Trueem excesso. Uma fixture que se aplica automaticamente a todos os testes é conveniente porque não precisa ser declarada, mas o leitor do teste não a vê em lugar nenhum. Use apenas para coisas verdadeiramente globais, por exemplo, bloquear rede. - Cadeias de fixtures muito profundas. Quando
orderdepende decustomer,customerdepende deaddress,addressdepende decountry…, o leitor tem que pular por vários arquivos para entender o teste. Para dados simples, criar diretamente no teste ou usar factory fixtures geralmente é mais legível.
Observação: Fixtures são ferramentas para preparar o ambiente, não locais para conter lógica de verificação. Mantenha
assertna função de teste para que cada teste conte claramente o que está verificando.
11. Conclusão
Você cobriu os fundamentos do pytest. assert puro e pytest.raises são usados para verificar resultados. Injeção de fixture por nome ajuda a eliminar código de preparação repetido. yield oferece setup/teardown seguro, enquanto scope ajuda a equilibrar velocidade e isolamento. conftest.py compartilha fixtures entre vários arquivos. Fixtures integradas como tmp_path, monkeypatch, capsys lidam com arquivos, ambiente e saída. Por fim, parametrize e fixtures com params ajudam a cobrir muitos casos sem precisar copiar o teste.
À medida que sua bateria de testes cresce, comece com fixtures com escopo function. Alargue o escopo apenas quando medir que a velocidade é realmente um problema.