Testes Unitários em Python: Guia Completo com Exemplos

Testes em Python com pytest, TDD, mocking e coverage

Conteúdo da página

Testes unitários garantem que seu código Python funcione corretamente e continue funcionando conforme seu projeto evolui. Este guia abrangente aborda tudo o que você precisa saber sobre testes unitários em Python, desde conceitos básicos até técnicas avançadas.

Python Unit Testing

Por que os testes unitários importam

Os testes unitários oferecem diversos benefícios para desenvolvedores Python:

  • Detecção precoce de bugs: Capturar bugs antes que cheguem à produção
  • Qualidade do código: Força você a escrever código modular e testável
  • Confiança ao refatorar: Faça alterações com segurança, sabendo que os testes capturarão regressões
  • Documentação: Os testes servem como documentação executável de como o código deve funcionar
  • Desenvolvimento mais rápido: Testes automatizados são mais rápidos do que testes manuais
  • Melhor design: Escrever código testável leva a uma arquitetura melhor

Entendendo os fundamentos dos testes unitários

O que é um teste unitário?

Um teste unitário verifica a parte mais pequena e testável de uma aplicação (geralmente uma função ou método) em isolamento. Ele deve ser:

  • Rápido: Executar em milissegundos
  • Isolado: Independente de outros testes e sistemas externos
  • Repetível: Produzir os mesmos resultados toda vez
  • Autovalidável: Passar ou falhar claramente sem inspeção manual
  • Oportuno: Escrito antes ou junto com o código

A pirâmide de testes

Um conjunto de testes saudável segue a pirâmide de testes:

           /\
          /  \     Testes E2E (Poucos)
         /____\
        /      \   Testes de Integração (Alguns)
       /_____ ___\
      /          \ Testes Unitários (Muitos)
     /_____ __ ___\

Os testes unitários formam a base - são numerosos, rápidos e fornecem feedback rápido.

Comparação de frameworks de testes para Python

unittest: O framework embutido

A biblioteca padrão do Python inclui unittest, inspirado em JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Executar antes de cada teste"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Executar depois de cada teste"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Vantagens:

  • Embutido, não é necessário instalar
  • Bom para desenvolvedores familiarizados com frameworks xUnit
  • Amigável para empresas, bem estabelecido

Desvantagens:

  • Sintaxe verbosa com código repetitivo
  • Requer classes para organização de testes
  • Menos flexibilidade na gestão de fixtures

pytest: A escolha moderna

pytest é o framework de testes de terceiros mais popular:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Vantagens:

  • Sintaxe simples e Pythonica
  • Sistema poderoso de fixtures
  • Excelente ecossistema de plugins
  • Relatórios de erros melhores
  • Testes parametrizados embutidos

Desvantagens:

  • Requer instalação
  • Menos familiar para desenvolvedores de outras linguagens

Instalação:

pip install pytest pytest-cov pytest-mock

Escrevendo seus primeiros testes unitários

Vamos construir um exemplo simples do zero usando Desenvolvimento Guiado por Testes (TDD). Se você é novo em Python ou precisa de um rápido referencial para sintaxe e recursos da linguagem, consulte nossa Folha de Dicas do Python para uma visão abrangente dos fundamentos do Python.

Exemplo: Funções de utilidade de string

Passo 1: Escreva o teste primeiro (Vermelho)

Crie test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Passo 2: Escreva o código mínimo para passar (Verde)

Crie string_utils.py:

def reverse_string(s: str) -> str:
    """Inverter uma string"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Verificar se a string é um palíndromo (ignorando maiúsculas e espaços)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Contar vogais em uma string"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Passo 3: Executar os testes

pytest test_string_utils.py -v

Saída:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Técnicas avançadas de testes

Usando fixtures para configuração de testes

Fixtures fornecem configuração e limpeza reutilizáveis para testes:

import pytest
from database import Database

@pytest.fixture
def db():
    """Criar um banco de dados de teste"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Fornecer ao teste
    database.close()  # Limpeza após o teste

@pytest.fixture
def sample_users(db):
    """Adicionar usuários de amostra ao banco de dados"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Escopo de fixtures

Controle a vida útil das fixtures com escopos:

@pytest.fixture(scope="function")  # Padrão: executar para cada teste
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Uma vez por classe de teste
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Uma vez por módulo
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Uma vez por sessão de teste
def session_fixture():
    return create_expensive_resource()

Mockando dependências externas

Use mock para isolar o código de dependências externas:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Teste com mock
def test_get_temperature():
    service = WeatherService()
    
    # Mockar a função requests.get
    with patch('requests.get') as mock_get:
        # Configurar resposta mockada
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # Teste
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # Verificar a chamada
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Usando o plugin pytest-mock

pytest-mock fornece uma sintaxe mais limpa:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Mockar usando pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

Testes parametrizados

Teste múltiplos cenários de forma eficiente:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Testando exceções e tratamento de erros

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Não é possível dividir por zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Não é possível dividir por zero"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value).lower()

# Testando que nenhuma exceção é lançada
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Testando código assíncrono

Testar código assíncrono é essencial para aplicações Python modernas, especialmente ao trabalhar com APIs, bancos de dados ou serviços de IA. Aqui está como testar funções assíncronas:

import pytest
import asyncio

async def fetch_data(url):
    """Função assíncrona para buscar dados"""
    await asyncio.sleep(0.1)  # Simular chamada de API
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Mockar a função assíncrona
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Para exemplos práticos de testes de código assíncrono com serviços de IA, veja nosso guia sobre integrar Ollama com Python, que inclui estratégias de teste para interações com LLM.

Cobertura de código

Meça quanta parte do seu código está testada:

Usando pytest-cov

# Executar testes com cobertura
pytest --cov=myproject tests/

# Gerar relatório HTML
pytest --cov=myproject --cov-report=html tests/

# Mostrar linhas faltando
pytest --cov=myproject --cov-report=term-missing tests/

Configuração de cobertura

Criar .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Boas práticas de cobertura

  1. Almeje 80%+ de cobertura para caminhos críticos de código
  2. Não se preocupe com 100% - foque em testes significativos
  3. Teste casos de borda não apenas caminhos felizes
  4. Exclua código de boilerplate dos relatórios de cobertura
  5. Use a cobertura como um guia e não como um objetivo

Organização de testes e estrutura do projeto

Estrutura recomendada

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Fixtures compartilhados
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py para fixtures compartilhados

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Banco de dados de teste para toda a sessão"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Banco de dados limpo para cada teste"""
    test_db.clear_all_tables()
    return test_db

Configuração de pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: marca testes como lentos
    integration: marca testes como testes de integração
    unit: marca testes como testes unitários

Boas práticas para testes unitários

1. Siga o padrão AAA

Arrange-Act-Assert torna os testes claros:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Uma afirmação por teste (orientação, não regra)

# Bom: Teste focado
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# Também aceitável: Afirmações relacionadas
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Use nomes descritivos para testes

# Ruim
def test_user():
    pass

# Bom
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Teste casos de borda e limites

def test_age_validation():
    # Casos válidos
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Casos de borda
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Casos de borda
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Mantenha os testes independentes

# Ruim: Testes dependem da ordem
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Falha se executado sozinho
    global counter
    counter += 1
    assert counter == 2

# Bom: Testes são independentes
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. Não teste detalhes de implementação

# Ruim: Testando implementação
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Bom: Testando comportamento
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Teste casos de uso reais

Quando testando bibliotecas que processam ou transformam dados, foque em cenários do mundo real. Por exemplo, se você estiver trabalhando com raspagem de web ou conversão de conteúdo, consulte nosso guia sobre conversão de HTML para Markdown com Python, que inclui estratégias de teste e comparações de benchmarks para diferentes bibliotecas de conversão.

Integração com Integração Contínua

Exemplo de GitHub Actions

# .github/workflows/tests.yml
name: Testes

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Configurar Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Instalar dependências
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Executar testes
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Enviar cobertura
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Testando funções serverless

Quando testando funções AWS Lambda ou aplicações serverless, considere estratégias de integração junto com testes unitários. Nosso guia sobre construindo uma AWS Lambda dual-mode com Python e Terraform aborda abordagens de teste para aplicações Python serverless, incluindo como testar manipuladores de Lambda, consumidores SQS e integrações com API Gateway.

Checklist de boas práticas de testes

  • Escreva testes antes ou junto com o código (TDD)
  • Mantenha os testes rápidos (< 1 segundo por teste)
  • Faça os testes independentes e isolados
  • Use nomes descritivos para testes
  • Siga o padrão AAA (Arrange-Act-Assert)
  • Teste casos de borda e condições de erro
  • Mocke dependências externas
  • Almeje 80%+ de cobertura de código
  • Execute os testes no pipeline CI/CD
  • Revise e refatore os testes regularmente
  • Documente cenários de testes complexos
  • Use fixtures para configuração comum
  • Parametrize testes semelhantes
  • Mantenha os testes simples e legíveis

Padrões comuns de testes

Testando classes

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Testando com arquivos temporários

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Criar um arquivo temporário"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Testando geração de arquivos

Quando testando código que gera arquivos (como PDFs, imagens ou documentos), use diretórios temporários e verifique propriedades de arquivos:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Fornece um diretório de saída temporário"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Para exemplos abrangentes de testes de geração de PDF, veja nosso guia sobre gerando PDFs em Python, que aborda estratégias de teste para várias bibliotecas de PDF.

Testando com monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Recursos relacionados

Fundamentos do Python e boas práticas

Testando casos específicos de uso do Python

Testes em ambientes serverless e na nuvem


Testes unitários são uma habilidade essencial para desenvolvedores Python. Seja qual for sua escolha entre unittest ou pytest, o ponto principal é escrever testes consistentemente, mantê-los mantiveis e integrá-los ao seu fluxo de trabalho de desenvolvimento. Comece com testes simples, gradualmente adote técnicas avançadas como mock e fixtures, e use ferramentas de cobertura para identificar código não testado.