pytest: O que É e Como Funciona | Python Brasil
pytest é o framework de testes Python mais popular. Aprenda fixtures, conftest.py, markers, cobertura de código, mocks e integração contínua.
O que é pytest?
O pytest é o framework de testes mais popular do Python. Ele torna a escrita de testes simples e intuitiva, sem a necessidade de classes ou boilerplate excessivo. Com pytest, você escreve testes usando funções simples e a palavra-chave assert, enquanto o framework se encarrega de encontrar, executar e reportar os resultados.
Por que pytest em vez de unittest?
O unittest, que vem embutido no Python, exige herança de classes e métodos específicos. O pytest é muito mais conciso e expressivo, com mensagens de falha detalhadas, descoberta automática de testes e um ecossistema rico de plugins.
# unittest (verboso)
import unittest
class TestCalculadora(unittest.TestCase):
def test_somar(self):
self.assertEqual(somar(2, 3), 5)
self.assertRaises(ValueError, dividir, 10, 0)
# pytest (simples e direto)
def test_somar():
assert somar(2, 3) == 5
def test_dividir_por_zero():
with pytest.raises(ValueError):
dividir(10, 0)
Exemplo prático completo
# calculadora.py
def somar(a, b):
return a + b
def dividir(a, b):
if b == 0:
raise ValueError("Divisão por zero!")
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)
# test_calculadora.py
import pytest
from calculadora import somar, dividir, calcular_desconto
def test_somar():
assert somar(2, 3) == 5
assert somar(-1, 1) == 0
assert somar(0, 0) == 0
def test_dividir():
assert dividir(10, 2) == 5.0
assert dividir(7, 2) == pytest.approx(3.5) # Para floats, use approx
def test_dividir_por_zero():
with pytest.raises(ValueError, match="Divisão por zero"):
dividir(10, 0)
def test_desconto_invalido():
with pytest.raises(ValueError, match="Percentual inválido"):
calcular_desconto(100, 150)
# Parametrize: um teste, múltiplos cenários
@pytest.mark.parametrize("a, b, esperado", [
(1, 1, 2),
(2, 3, 5),
(-1, -1, -2),
(100, 200, 300),
(0, 0, 0),
])
def test_somar_parametrizado(a, b, esperado):
assert somar(a, b) == esperado
Fixtures: configuração reutilizável de testes
Fixtures são funções que fornecem dados ou objetos para os testes. São injetadas automaticamente pelo nome do parâmetro:
import pytest
@pytest.fixture
def usuario_padrao():
"""Fixture simples: retorna um dicionário com dados de usuário."""
return {
'id': 1,
'nome': 'Ana Silva',
'email': 'ana@exemplo.com',
'ativo': True
}
@pytest.fixture
def banco_de_dados_teste():
"""Fixture com setup e teardown usando yield."""
# Setup: executado antes do teste
db = ConexaoDB(url='sqlite:///:memory:')
db.criar_tabelas()
yield db # O teste recebe 'db' aqui
# Teardown: executado após o teste (mesmo se falhar)
db.fechar()
def test_criar_usuario(banco_de_dados_teste, usuario_padrao):
# Ambas as fixtures são injetadas automaticamente
banco_de_dados_teste.inserir(usuario_padrao)
resultado = banco_de_dados_teste.buscar(id=1)
assert resultado['nome'] == 'Ana Silva'
Escopos de fixtures
O escopo controla com que frequência a fixture é criada e destruída:
import pytest
# function (padrão): criada/destruída para cada função de teste
@pytest.fixture(scope='function')
def dado_por_funcao():
print("\nSetup por função")
yield {'contador': 0}
print("\nTeardown por função")
# class: uma instância por classe de testes
@pytest.fixture(scope='class')
def dado_por_classe():
print("\nSetup por classe")
yield {'usuarios': []}
print("\nTeardown por classe")
# module: uma instância por arquivo de teste
@pytest.fixture(scope='module')
def conexao_db():
print("\nAbrindo conexão com banco")
db = criar_conexao_teste()
yield db
print("\nFechando conexão com banco")
db.fechar()
# session: uma instância para toda a sessão de testes
@pytest.fixture(scope='session')
def servidor_externo():
print("\nIniciando servidor de testes")
servidor = ServidorTeste.iniciar(porta=9999)
yield servidor
print("\nDesligando servidor")
servidor.parar()
conftest.py: fixtures compartilhadas
O arquivo conftest.py é carregado automaticamente pelo pytest e permite compartilhar fixtures entre múltiplos arquivos de teste sem importações explícitas:
meu_projeto/
├── conftest.py # Fixtures disponíveis para todos os testes
├── tests/
│ ├── conftest.py # Fixtures disponíveis apenas em tests/
│ ├── test_usuario.py
│ ├── test_pedido.py
│ └── api/
│ ├── conftest.py # Fixtures apenas para testes de API
│ └── test_endpoints.py
# conftest.py (raiz do projeto)
import pytest
from minha_app import criar_app
from minha_app.banco import db as _db
@pytest.fixture(scope='session')
def app():
"""Cria a aplicação Flask/FastAPI para toda a sessão."""
app = criar_app(config='testing')
return app
@pytest.fixture(scope='function')
def client(app):
"""Cliente HTTP para testar endpoints."""
with app.test_client() as client:
yield client
@pytest.fixture(scope='function', autouse=True)
def limpar_banco():
"""Limpa o banco antes de cada teste (autouse=True: aplica automaticamente)."""
yield
_db.rollback()
Markers: categorizando e controlando testes
import pytest
# Markers integrados
@pytest.mark.skip(reason="Funcionalidade ainda não implementada")
def test_nova_feature():
assert False # Nunca executado
@pytest.mark.skipif(sys.platform == 'win32', reason="Não funciona no Windows")
def test_somente_linux():
pass
@pytest.mark.xfail(reason="Bug conhecido, aguardando correção")
def test_comportamento_com_bug():
assert calcular_algo() == resultado_esperado # Falha esperada
# Markers personalizados (registre em pytest.ini ou pyproject.toml)
@pytest.mark.integracao
def test_chamada_api_externa():
resposta = requests.get("https://api.exemplo.com")
assert resposta.status_code == 200
@pytest.mark.lento
def test_processo_demorado():
resultado = processar_milhoes_de_registros()
assert resultado['total'] > 0
# Executando apenas markers específicos:
# pytest -m "integracao" - apenas integração
# pytest -m "not lento" - excluindo testes lentos
# pytest -m "integracao and not lento"
Fixtures especiais: monkeypatch, tmp_path, capfd
import pytest
import os
# monkeypatch: substitui comportamentos temporariamente durante o teste
def test_variaveis_de_ambiente(monkeypatch):
monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
monkeypatch.setenv('DEBUG', '1')
# A variável de ambiente é restaurada após o teste
assert os.environ['DATABASE_URL'] == 'sqlite:///:memory:'
def test_substituindo_funcao(monkeypatch):
def mock_get(url, **kwargs):
class FakeResponse:
status_code = 200
def json(self): return {'resultado': 42}
return FakeResponse()
monkeypatch.setattr('requests.get', mock_get)
resultado = minha_funcao_que_chama_api()
assert resultado == 42
# tmp_path: diretório temporário criado e limpo automaticamente
def test_escrita_de_arquivo(tmp_path):
arquivo = tmp_path / "saida.txt"
arquivo.write_text("conteúdo de teste")
assert arquivo.exists()
assert arquivo.read_text() == "conteúdo de teste"
# capfd: captura stdout e stderr
def test_saida_no_terminal(capfd):
print("Mensagem importante")
print("Erro ocorreu", file=sys.stderr)
out, err = capfd.readouterr()
assert "Mensagem importante" in out
assert "Erro ocorreu" in err
pytest-mock: mocking elegante
# pip install pytest-mock
import pytest
def test_servico_de_email(mocker):
# Substitui o método de envio real por um mock
mock_enviar = mocker.patch('meu_app.email.enviar_email')
mock_enviar.return_value = True
servico = ServicoNotificacao()
servico.notificar_usuario(id=1, mensagem="Bem-vindo!")
# Verifica se o email foi chamado corretamente
mock_enviar.assert_called_once()
args = mock_enviar.call_args
assert 'Bem-vindo!' in args[1]['mensagem']
def test_api_externa_falha(mocker):
mocker.patch(
'requests.get',
side_effect=ConnectionError("Sem conexão")
)
with pytest.raises(ServicoIndisponivel):
buscar_dados_externos()
Configuração: pytest.ini e pyproject.toml
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
markers =
integracao: Testes que requerem serviços externos
lento: Testes que demoram mais de 5 segundos
smoke: Testes de verificação rápida
addopts = -v --tb=short --strict-markers
# pyproject.toml (alternativa moderna)
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integracao: Testes de integração com serviços externos",
"lento: Testes com execução demorada",
]
addopts = "-v --tb=short"
pytest-cov: cobertura de código
# Instalando
pip install pytest-cov
# Executando com relatório de cobertura
pytest --cov=meu_pacote --cov-report=term-missing
pytest --cov=meu_pacote --cov-report=html # Gera relatório HTML em htmlcov/
pytest --cov=meu_pacote --cov-fail-under=80 # Falha se cobertura < 80%
# .coveragerc
[run]
source = meu_pacote
omit =
*/migrations/*
*/settings.py
*/tests/*
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
Integração contínua (CI)
# .github/workflows/tests.yml
name: Testes
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Configurar Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Instalar dependências
run: |
pip install -e ".[dev]"
pip install pytest pytest-cov pytest-mock
- name: Executar testes
run: |
pytest --cov=meu_pacote --cov-report=xml -m "not integracao"
- name: Enviar cobertura ao Codecov
uses: codecov/codecov-action@v3
Organizacao de testes em projetos grandes
Em projetos grandes, organize os testes espelhando a estrutura do codigo: tests/unit/ para testes unitários isolados, tests/integration/ para testes que envolvem banco de dados ou APIs reais, e tests/e2e/ para testes de ponta a ponta. Use markers para controlar quais testes rodam em quais ambientes.
Termos Relacionados
- Python - A linguagem de programação
- Decorators - Usados em fixtures e marks do pytest
- Classe - Testes podem ser organizados em classes