Introdução ao pytest: Por Que Abandonar print() nos Testes
Quando comecei a programar, assim como muitos, testava meu código inserindo print() em diversos pontos e verificando manualmente a saída. Isso funciona para scripts pequenos, mas quando seu projeto cresce, essa abordagem se torna caótica e improdutiva. O pytest é um framework que transforma testes em uma prática sistemática, legível e automatizável — permitindo que você execute centenas de testes em segundos e saiba exatamente o que quebrou.
O pytest se diferencia de outras ferramentas porque é minimalista: você escreve funções Python normais com nomes começados em test_, e ele as encontra e executa automaticamente. Não há necessidade de herdar classes, usar decoradores complicados ou aprender uma sintaxe própria. Vamos entender como começar e, progressivamente, construir uma estratégia sólida de testes.
Fundamentos: Escrevendo Seu Primeiro Teste
Instalação e Primeiro Teste
A instalação é simples — use pip para adicionar pytest ao seu projeto:
pip install pytest
Agora, crie um arquivo chamado test_calculadora.py:
def soma(a, b):
return a + b
def subtrai(a, b):
return a - b
def test_soma_positivos():
assert soma(2, 3) == 5
def test_soma_negativos():
assert soma(-1, -2) == -3
def test_subtrai():
assert subtrai(10, 5) == 5
Execute com pytest test_calculadora.py ou simplesmente pytest (pytest procura automaticamente por arquivos e funções que começam com test_). A saída será clara: quantos testes passaram, quantos falharam e por quê.
Entendendo assert e Mensagens de Erro
O assert é a coluna vertebral dos testes em pytest. Você escreve uma condição e, se for falsa, o teste falha. Mas pytest é inteligente: ele mostra exatamente o que diferiu. Veja um exemplo real:
def calcula_desconto(preco, percentual):
return preco * (1 - percentual / 100)
def test_desconto_inválido():
resultado = calcula_desconto(100, 10)
assert resultado == 91.0, f"Esperado 90.0, mas obtive {resultado}"
Se colocar uma lógica errada na função, a mensagem será clara. Pytest mostra o valor real vs. esperado automaticamente, tornando debug muito mais rápido que print() tradicional.
Organizando Testes em Diretórios
Para projetos maiores, organize seus testes em uma estrutura clara:
meu_projeto/
├── src/
│ ├── calculadora.py
│ ├── validador.py
│ └── __init__.py
├── tests/
│ ├── test_calculadora.py
│ ├── test_validador.py
│ └── conftest.py
├── pytest.ini
└── requirements.txt
Crie um arquivo pytest.ini na raiz do projeto para configurar o comportamento padrão:
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
Isso garante que pytest sempre procure em tests/ e exiba saída verbosa com rastreamento de erro reduzido.
Fixtures: Reutilizando Recursos e Dados Entre Testes
O Conceito de Fixture e Por Que Usar
Uma fixture é uma função que prepara dados ou recursos que seus testes precisam. Imagine que você testa uma classe UsuarioService que precisa conectar a um banco de dados. Sem fixtures, você escreveria o setup em cada teste — código duplicado. Com fixtures, você define uma única vez e pytest a executa automaticamente para cada teste que a solicita.
Fixtures resolvem três problemas: eliminar duplicação, garantir limpeza de recursos (como fechar conexões) e facilitar testes que dependem do mesmo estado inicial.
Criando e Usando Fixtures Básicas
Crie um arquivo tests/conftest.py — esse é o arquivo especial onde pytest procura por fixtures:
import pytest
@pytest.fixture
def usuario_padrao():
return {
"id": 1,
"nome": "João Silva",
"email": "joao@example.com",
"ativo": True
}
@pytest.fixture
def lista_usuarios():
return [
{"id": 1, "nome": "João", "email": "joao@example.com"},
{"id": 2, "nome": "Maria", "email": "maria@example.com"},
{"id": 3, "nome": "Pedro", "email": "pedro@example.com"},
]
Agora, em qualquer teste, você solicita a fixture como argumento:
def test_usuario_tem_email(usuario_padrao):
assert usuario_padrao["email"] == "joao@example.com"
def test_usuario_ativo(usuario_padrao):
assert usuario_padrao["ativo"] is True
def test_contar_usuarios(lista_usuarios):
assert len(lista_usuarios) == 3
Pytest injeta as fixtures automaticamente. Se você precisar usar usuario_padrao em 20 testes, basta adicioná-la como argumento em cada um — zero duplicação.
Fixtures com Setup e Teardown
Frequentemente você precisa preparar algo antes do teste e limpar depois. Use yield para isso:
import sqlite3
import pytest
@pytest.fixture
def db_conexao():
# Setup: criar conexão
conexao = sqlite3.connect(":memory:")
cursor = conexao.cursor()
cursor.execute("""
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT UNIQUE
)
""")
conexao.commit()
# O teste roda aqui
yield conexao
# Teardown: fechar conexão
conexao.close()
def test_inserir_usuario(db_conexao):
cursor = db_conexao.cursor()
cursor.execute("INSERT INTO usuarios (nome, email) VALUES (?, ?)",
("João", "joao@example.com"))
db_conexao.commit()
cursor.execute("SELECT * FROM usuarios WHERE email = ?",
("joao@example.com",))
resultado = cursor.fetchone()
assert resultado is not None
assert resultado[1] == "João"
O yield marca o ponto onde o teste roda. Código antes do yield é setup, código depois é teardown. Isso garante que a conexão sempre seja fechada, mesmo se o teste falhar.
Escopos de Fixture: Quando Reutilizar
Fixtures têm escopos que determinam quantas vezes são criadas. O padrão é function (uma por teste), mas existem outras opções:
@pytest.fixture(scope="function") # Padrão: nova instância por teste
def recurso_por_funcao():
return {"valor": "novo"}
@pytest.fixture(scope="module") # Uma instância para todos os testes do módulo
def recurso_modulo():
return sqlite3.connect(":memory:")
@pytest.fixture(scope="session") # Uma instância para toda a sessão de testes
def recurso_sessao():
return {"config": "global"}
def test_um(recurso_por_funcao):
recurso_por_funcao["valor"] = "modificado"
def test_dois(recurso_por_funcao):
# Aqui recurso_por_funcao é uma nova instância, não afetada pelo test_um
assert recurso_por_funcao["valor"] == "novo"
Use module ou session com cuidado — testes podem se afetar mutuamente se compartilharem estado. Geralmente, function é a escolha segura.
Fixtures Parametrizadas
Às vezes você quer executar o mesmo teste com dados diferentes. Use params:
@pytest.fixture(params=[
{"entrada": 2, "esperado": 4},
{"entrada": 3, "esperado": 9},
{"entrada": -1, "esperado": 1},
])
def casos_potencia(request):
return request.param
def quadrado(n):
return n ** 2
def test_potencia(casos_potencia):
assert quadrado(casos_potencia["entrada"]) == casos_potencia["esperado"]
Pytest rodará test_potencia três vezes — uma para cada item em params. Você obtém 3 testes por 1 função de teste, reduzindo duplicação massivamente.
Organização Profissional de Testes: Estrutura e Boas Práticas
Estrutura de Diretórios Escalável
Conforme seu projeto cresce, organize testes por módulos de funcionalidade:
projeto/
├── src/
│ ├── usuarios/
│ │ ├── models.py
│ │ ├── services.py
│ │ └── __init__.py
│ ├── pedidos/
│ │ ├── models.py
│ │ ├── services.py
│ │ └── __init__.py
│ └── __init__.py
├── tests/
│ ├── conftest.py # Fixtures globais
│ ├── test_usuarios/
│ │ ├── conftest.py # Fixtures específicas de usuarios
│ │ ├── test_models.py
│ │ └── test_services.py
│ └── test_pedidos/
│ ├── conftest.py
│ ├── test_models.py
│ └── test_services.py
└── pytest.ini
Pytest procura por conftest.py em cada nível de diretório, permitindo fixtures globais e específicas por módulo. Isso mantém tudo organizado e evita que você carregue fixtures desnecessárias.
Estrutura de Testes para Classes
Para código orientado a objetos, organize testes em classes:
class Usuario:
def __init__(self, nome, email):
self.nome = nome
self.email = email
self.ativo = True
def desativar(self):
self.ativo = False
def validar_email(self):
return "@" in self.email
class TestUsuario:
@pytest.fixture
def usuario(self):
return Usuario("João", "joao@example.com")
def test_criacao(self, usuario):
assert usuario.nome == "João"
def test_desativar(self, usuario):
usuario.desativar()
assert usuario.ativo is False
def test_validar_email_valido(self, usuario):
assert usuario.validar_email() is True
def test_validar_email_invalido(self):
usuario_invalido = Usuario("Maria", "maria_sem_email")
assert usuario_invalido.validar_email() is False
Classes agrupam testes relacionados, facilitando leitura e manutenção. Pytest as trata normalmente — não é necessário herdar de nada.
Marcadores (Markers) para Categorizar Testes
Use marcadores para executar subconjuntos de testes:
import pytest
@pytest.mark.rapido
def test_soma():
assert 1 + 1 == 2
@pytest.mark.lento
def test_leitura_arquivo_grande():
# Simula teste que demora
import time
time.sleep(2)
assert True
@pytest.mark.integracao
def test_conectar_banco_dados():
# Simula conexão real
assert True
No terminal, execute apenas testes rápidos com pytest -m rapido ou evite os lentos com pytest -m "not lento". Registre marcadores no pytest.ini:
[pytest]
markers =
rapido: testes que executam em menos de 1 segundo
lento: testes que demoram mais de 1 segundo
integracao: testes que acessam banco de dados ou APIs externas
Testando Exceções e Comportamentos Esperados
Às vezes você quer verificar se uma exceção é lançada:
import pytest
def dividir(a, b):
if b == 0:
raise ValueError("Divisão por zero não permitida")
return a / b
def test_divisao_por_zero():
with pytest.raises(ValueError, match="Divisão por zero"):
dividir(10, 0)
def test_divisao_valida():
assert dividir(10, 2) == 5.0
pytest.raises() garante que a exceção é lançada. O parâmetro match verifica se a mensagem contém o padrão esperado. Isso testa comportamento defensivo — código que reage bem a entradas inválidas.
Mocking: Testando sem Dependências Externas
Frequentemente você testa código que depende de APIs, bancos de dados ou serviços externos. Use unittest.mock para simular essas dependências:
from unittest.mock import Mock, patch
import pytest
class PedidoService:
def __init__(self, db):
self.db = db
def criar_pedido(self, usuario_id, itens):
# Simula criação de pedido
pedido = {"usuario_id": usuario_id, "itens": itens}
self.db.salvar(pedido)
return pedido
def test_criar_pedido_com_mock():
# Cria um mock (objeto falso) do banco de dados
db_mock = Mock()
service = PedidoService(db_mock)
resultado = service.criar_pedido(1, ["item1", "item2"])
# Verifica se o método foi chamado com os argumentos corretos
db_mock.salvar.assert_called_once()
args = db_mock.salvar.call_args[0][0]
assert args["usuario_id"] == 1
assert len(args["itens"]) == 2
Mocks permitem testar lógica sem depender de infraestrutura real, tornando testes mais rápidos e confiáveis.
Configuração Avançada e Boas Práticas
Arquivo pytest.ini Completo
Configure pytest para comportar-se como você espera:
[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
-ra
markers =
rapido: testes de execução rápida
lento: testes que demoram mais de 1 segundo
integracao: testes que acessam recursos externos
unitario: testes unitários isolados
filterwarnings =
ignore::DeprecationWarning
-v mostra cada teste, --tb=short reduz mensagens de erro verbosas, -ra resumo de tudo (skipped, xfailed, etc).
Cobertura de Testes com pytest-cov
Saiba quanto do seu código está sendo testado:
pip install pytest-cov
pytest --cov=src --cov-report=html
Isso gera um relatório HTML mostrando linhas cobertas e não cobertas. Não é sobre alcançar 100% — é sobre testar o código crítico e conhecer seus gaps.
Testes Parametrizados com pytest.mark.parametrize
Para múltiplas combinações de entrada, use parametrize:
import pytest
@pytest.mark.parametrize("entrada,esperado", [
(2, 4),
(3, 9),
(-1, 1),
(0, 0),
])
def test_quadrado(entrada, esperado):
def quadrado(n):
return n ** 2
assert quadrado(entrada) == esperado
Pytest rodará o teste 4 vezes, uma para cada tupla. A sintaxe é simples: nomes das variáveis e lista de valores.
Conclusão
Depois de caminhar por esses conceitos, retenha três pontos essenciais: Primeiro, fixtures são o coração do pytest — use-as para eliminar duplicação e gerenciar recursos com setup/teardown automático. Elas transformam testes de algo repetitivo em algo elegante e mantenível. Segundo, organize seus testes em uma estrutura clara com diretórios, conftest.py e marcadores — isso permite executar subconjuntos de testes rapidamente e mantém projetos escaláveis. Um teste deveria responder: "o que estou testando e por quê?" em sua estrutura de arquivo. Terceiro, use marcadores, mocking e parametrização para cobrir casos reais sem depender de infraestrutura externa — testes rápidos e confiáveis são testes que serão executados frequentemente.