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.

8 min de leitura Equipe Python Brasil

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!

E

Equipe Python Brasil

Contribuidor do Python Brasil — Aprenda Python em Português