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:

  • assert puro do Python. Você não precisa de assertEqual ou bibliotecas de asserção próprias. pytest reescreve a afirmação para relatar erros detalhados.
  • pytest.raises verifica se um bloco de código lança uma exceção do tipo correto. O parâmetro match é 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.fixture marca 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 order pode depender da fixture customer. 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 em try/finally se 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 conftest em 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 é um pathlib.Path apontando para um diretório temporário, único para cada teste. Os testes não sujam o diretório do projeto.
  • monkeypatch modifica 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 de setenv, você frequentemente usa monkeypatch.setattr(obj, "name", value).
  • capsys captura a saída impressa em stdout e stderr. 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á erro Fixture "customer" called directly. Declare-o como um parâmetro.
  • Escopo amplo com dados mutáveis. Um Order com 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 escopo function como na seção 6.
  • Usar autouse=True em 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 order depende de customer, customer depende de address, address depende de country…, 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 assert na 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.

pythonpytesttestingfixturetestes-unitários