Testes em Python com pytest: Guia Completo
Aprenda testes unitários em Python com pytest: fixtures, parametrize, mocking, cobertura de código e boas práticas para código confiável.
Testes automatizados são essenciais para garantir que seu código funciona corretamente e continua funcionando quando você faz mudanças. O pytest é a ferramenta de testes mais popular em Python, e neste guia você vai aprender tudo que precisa para começar a testar seus projetos como um profissional.
Por que Testar?
Se você já passou pela situação de corrigir um bug e criar outros três no processo, já sabe por que testes são importantes. Testes automatizados:
- Previnem regressões: Garantem que funcionalidades existentes não quebram
- Documentam o código: Testes mostram como o código deve ser usado
- Facilitam refatoração: Você pode mudar a implementação com confiança
- Melhoram o design: Código testável é geralmente código bem projetado
Configuração Inicial
# Instalação
# pip install pytest pytest-cov
# Estrutura de pastas recomendada
# meu_projeto/
# ├── src/
# │ ├── __init__.py
# │ ├── calculadora.py
# │ └── usuario.py
# ├── tests/
# │ ├── __init__.py
# │ ├── test_calculadora.py
# │ └── test_usuario.py
# ├── pyproject.toml
# └── requirements.txt
Primeiro Teste
Vamos começar com um exemplo simples. Primeiro, o código a ser testado:
# src/calculadora.py
class Calculadora:
def somar(self, a, b):
return a + b
def subtrair(self, a, b):
return a - b
def multiplicar(self, a, b):
return a * b
def dividir(self, a, b):
if b == 0:
raise ValueError("Divisão por zero não é permitida")
return a / b
def media(self, numeros):
if not numeros:
raise ValueError("Lista não pode ser vazia")
return sum(numeros) / len(numeros)
def porcentagem(self, valor, percentual):
return valor * (percentual / 100)
Agora, os testes:
# tests/test_calculadora.py
import pytest
from src.calculadora import Calculadora
class TestCalculadora:
"""Testes para a classe Calculadora."""
def setup_method(self):
"""Executado antes de cada teste."""
self.calc = Calculadora()
def test_somar(self):
assert self.calc.somar(2, 3) == 5
def test_somar_negativos(self):
assert self.calc.somar(-1, -1) == -2
def test_somar_com_zero(self):
assert self.calc.somar(5, 0) == 5
def test_subtrair(self):
assert self.calc.subtrair(10, 3) == 7
def test_multiplicar(self):
assert self.calc.multiplicar(4, 5) == 20
def test_dividir(self):
assert self.calc.dividir(10, 2) == 5.0
def test_dividir_resultado_decimal(self):
resultado = self.calc.dividir(10, 3)
assert round(resultado, 4) == 3.3333
def test_dividir_por_zero(self):
with pytest.raises(ValueError, match="Divisão por zero"):
self.calc.dividir(10, 0)
def test_media(self):
assert self.calc.media([10, 20, 30]) == 20.0
def test_media_lista_vazia(self):
with pytest.raises(ValueError, match="Lista não pode ser vazia"):
self.calc.media([])
def test_porcentagem(self):
assert self.calc.porcentagem(200, 15) == 30.0
Para rodar os testes:
# No terminal:
# pytest # roda todos os testes
# pytest -v # modo verboso
# pytest tests/test_calc.py # arquivo específico
# pytest -k "test_somar" # testes por nome
Fixtures
Fixtures são funções que configuram o ambiente para os testes. Elas evitam repetição e mantêm os testes organizados.
# tests/conftest.py - fixtures compartilhadas entre arquivos de teste
import pytest
@pytest.fixture
def usuario_padrao():
"""Retorna um usuário padrão para testes."""
return {
"nome": "Maria Silva",
"email": "maria@email.com",
"idade": 28,
"ativo": True,
}
@pytest.fixture
def lista_usuarios():
"""Retorna uma lista de usuários para testes."""
return [
{"nome": "Ana", "email": "ana@email.com", "idade": 25},
{"nome": "Carlos", "email": "carlos@email.com", "idade": 32},
{"nome": "Pedro", "email": "pedro@email.com", "idade": 19},
]
@pytest.fixture
def banco_dados_teste(tmp_path):
"""Cria um banco de dados temporário para testes."""
import sqlite3
db_path = tmp_path / "teste.db"
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
conn.commit()
yield conn # O teste usa a conexão aqui
conn.close() # Cleanup após o teste
# src/usuario.py
class GerenciadorUsuarios:
def __init__(self):
self.usuarios = []
def adicionar(self, nome, email, idade):
if not nome or not email:
raise ValueError("Nome e email são obrigatórios")
if idade < 0 or idade > 150:
raise ValueError("Idade inválida")
if any(u["email"] == email for u in self.usuarios):
raise ValueError("Email já cadastrado")
usuario = {"nome": nome, "email": email, "idade": idade}
self.usuarios.append(usuario)
return usuario
def buscar_por_email(self, email):
for usuario in self.usuarios:
if usuario["email"] == email:
return usuario
return None
def listar_maiores_de(self, idade_minima):
return [u for u in self.usuarios if u["idade"] >= idade_minima]
def remover(self, email):
usuario = self.buscar_por_email(email)
if usuario is None:
raise ValueError("Usuário não encontrado")
self.usuarios.remove(usuario)
return True
def total(self):
return len(self.usuarios)
# tests/test_usuario.py
import pytest
from src.usuario import GerenciadorUsuarios
@pytest.fixture
def gerenciador():
"""Fixture que retorna um gerenciador com alguns usuários."""
g = GerenciadorUsuarios()
g.adicionar("Ana", "ana@email.com", 25)
g.adicionar("Carlos", "carlos@email.com", 32)
g.adicionar("Pedro", "pedro@email.com", 19)
return g
class TestGerenciadorUsuarios:
def test_adicionar_usuario(self):
g = GerenciadorUsuarios()
resultado = g.adicionar("Maria", "maria@email.com", 28)
assert resultado["nome"] == "Maria"
assert g.total() == 1
def test_adicionar_email_duplicado(self, gerenciador):
with pytest.raises(ValueError, match="Email já cadastrado"):
gerenciador.adicionar("Ana2", "ana@email.com", 30)
def test_adicionar_sem_nome(self):
g = GerenciadorUsuarios()
with pytest.raises(ValueError, match="obrigatórios"):
g.adicionar("", "test@email.com", 25)
def test_adicionar_idade_invalida(self):
g = GerenciadorUsuarios()
with pytest.raises(ValueError, match="Idade inválida"):
g.adicionar("Test", "test@email.com", -1)
def test_buscar_por_email(self, gerenciador):
usuario = gerenciador.buscar_por_email("carlos@email.com")
assert usuario is not None
assert usuario["nome"] == "Carlos"
def test_buscar_email_inexistente(self, gerenciador):
assert gerenciador.buscar_por_email("naoexiste@email.com") is None
def test_listar_maiores_de(self, gerenciador):
resultado = gerenciador.listar_maiores_de(20)
assert len(resultado) == 2
nomes = [u["nome"] for u in resultado]
assert "Ana" in nomes
assert "Carlos" in nomes
def test_remover_usuario(self, gerenciador):
assert gerenciador.total() == 3
gerenciador.remover("ana@email.com")
assert gerenciador.total() == 2
assert gerenciador.buscar_por_email("ana@email.com") is None
def test_remover_inexistente(self, gerenciador):
with pytest.raises(ValueError, match="não encontrado"):
gerenciador.remover("naoexiste@email.com")
Parametrize
@pytest.mark.parametrize permite rodar o mesmo teste com diferentes entradas:
import pytest
from src.calculadora import Calculadora
@pytest.fixture
def calc():
return Calculadora()
@pytest.mark.parametrize("a, b, esperado", [
(1, 1, 2),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(1.5, 2.5, 4.0),
(-10, -20, -30),
])
def test_somar_parametrizado(calc, a, b, esperado):
assert calc.somar(a, b) == esperado
@pytest.mark.parametrize("a, b, esperado", [
(10, 2, 5.0),
(9, 3, 3.0),
(7, 2, 3.5),
(100, 4, 25.0),
])
def test_dividir_parametrizado(calc, a, b, esperado):
assert calc.dividir(a, b) == esperado
@pytest.mark.parametrize("entrada, esperado", [
("maria@email.com", True),
("carlos@empresa.com.br", True),
("invalido", False),
("@sem-nome.com", False),
("sem-arroba.com", False),
("", False),
])
def test_validar_email(entrada, esperado):
import re
padrao = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
resultado = bool(re.match(padrao, entrada))
assert resultado == esperado, f"Falhou para: {entrada}"
Mocking
Mocking permite simular dependências externas (APIs, bancos de dados, etc.):
# src/servico_clima.py
import requests
class ServicoClima:
BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
def __init__(self, api_key):
self.api_key = api_key
def obter_temperatura(self, cidade):
"""Obtém a temperatura atual de uma cidade."""
response = requests.get(self.BASE_URL, params={
"q": cidade,
"appid": self.api_key,
"units": "metric",
})
response.raise_for_status()
dados = response.json()
return dados["main"]["temp"]
def esta_quente(self, cidade, limiar=30):
"""Verifica se está quente na cidade."""
temp = self.obter_temperatura(cidade)
return temp >= limiar
# tests/test_servico_clima.py
import pytest
from unittest.mock import patch, MagicMock
from src.servico_clima import ServicoClima
@pytest.fixture
def servico():
return ServicoClima("chave-fake-para-teste")
class TestServicoClima:
@patch("src.servico_clima.requests.get")
def test_obter_temperatura(self, mock_get, servico):
# Configurar o mock
mock_response = MagicMock()
mock_response.json.return_value = {
"main": {"temp": 28.5, "humidity": 65}
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# Executar
temperatura = servico.obter_temperatura("São Paulo")
# Verificar
assert temperatura == 28.5
mock_get.assert_called_once()
@patch("src.servico_clima.requests.get")
def test_esta_quente_verdadeiro(self, mock_get, servico):
mock_response = MagicMock()
mock_response.json.return_value = {"main": {"temp": 35.0}}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
assert servico.esta_quente("Cuiabá") is True
@patch("src.servico_clima.requests.get")
def test_esta_quente_falso(self, mock_get, servico):
mock_response = MagicMock()
mock_response.json.return_value = {"main": {"temp": 18.0}}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
assert servico.esta_quente("Curitiba") is False
@patch("src.servico_clima.requests.get")
def test_erro_api(self, mock_get, servico):
mock_get.side_effect = Exception("Erro de conexão")
with pytest.raises(Exception, match="Erro de conexão"):
servico.obter_temperatura("Qualquer")
Cobertura de Código
A cobertura de código mostra quais partes do código são executadas pelos testes:
# Rodar testes com cobertura:
# pytest --cov=src --cov-report=html --cov-report=term-missing
# Exemplo de saída:
# Name Stmts Miss Cover Missing
# -----------------------------------------------------
# src/calculadora.py 18 0 100%
# src/usuario.py 32 2 94% 45-46
# src/servico_clima.py 15 1 93% 28
# -----------------------------------------------------
# TOTAL 65 3 95%
# pyproject.toml - configuração do pytest
# [tool.pytest.ini_options]
# testpaths = ["tests"]
# python_files = ["test_*.py"]
# python_functions = ["test_*"]
# addopts = "-v --tb=short"
#
# [tool.coverage.run]
# source = ["src"]
# omit = ["tests/*"]
#
# [tool.coverage.report]
# fail_under = 80
# show_missing = true
Fixtures Avançadas
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture(scope="session")
def diretorio_temporario():
"""Fixture com escopo de sessão - criada uma vez para todos os testes."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@pytest.fixture(autouse=True)
def limpar_ambiente():
"""Fixture automática que roda antes e depois de cada teste."""
# Setup
os.environ["AMBIENTE"] = "teste"
yield
# Teardown
os.environ.pop("AMBIENTE", None)
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def tipo_banco(request):
"""Fixture parametrizada - testes rodam para cada banco."""
return request.param
def test_conexao_banco(tipo_banco):
"""Este teste roda 3 vezes, uma para cada banco."""
assert tipo_banco in ["sqlite", "postgresql", "mysql"]
print(f"Testando com {tipo_banco}")
Boas Práticas
# 1. Nome descritivo para os testes
def test_usuario_nao_pode_ter_idade_negativa():
pass # Muito melhor que test_idade_1
# 2. Padrão AAA (Arrange, Act, Assert)
def test_calcular_desconto():
# Arrange (preparar)
preco_original = 100.00
percentual_desconto = 15
# Act (agir)
desconto = preco_original * (percentual_desconto / 100)
preco_final = preco_original - desconto
# Assert (verificar)
assert preco_final == 85.00
assert desconto == 15.00
# 3. Um conceito por teste
def test_adicionar_produto_incrementa_total():
carrinho = Carrinho()
carrinho.adicionar("Notebook", 3500)
assert carrinho.total_itens() == 1
def test_adicionar_produto_atualiza_valor():
carrinho = Carrinho()
carrinho.adicionar("Notebook", 3500)
assert carrinho.valor_total() == 3500
# 4. Testes independentes (não dependem de ordem)
# 5. Testes rápidos (evite I/O quando possível)
# 6. Testes determinísticos (mesmo resultado sempre)
Conclusão
Testes não são um luxo — são uma necessidade para qualquer projeto profissional. O pytest torna a escrita de testes simples e até divertida. Comece testando as partes mais críticas do seu código e vá expandindo a cobertura aos poucos.
A regra de ouro: se você tem medo de mudar um código, provavelmente ele precisa de testes. Comece hoje e seu eu do futuro vai te agradecer!
Equipe Python Brasil
Contribuidor do Python Brasil — Aprenda Python em Português