Ir para o conteúdo

Testes em Python

Introdução

Testes são uma parte essencial do desenvolvimento de software, permitindo verificar se o código funciona como esperado, identificar bugs precocemente e facilitar refatorações seguras. Em Python, existem diversas ferramentas e técnicas para escrever e executar testes, desde testes unitários simples até frameworks completos de automação de testes.

Objetivos de Aprendizado

  • Compreender a importância dos testes em desenvolvimento de software
  • Aprender a escrever testes unitários com pytest e unittest
  • Entender os conceitos de fixtures, mocks e stubs
  • Explorar técnicas de Test-Driven Development (TDD)
  • Conhecer boas práticas para escrever testes eficazes
  • Implementar testes de integração e testes funcionais

Importância dos Testes

Os testes de software oferecem diversos benefícios:

  • Detecção precoce de bugs: Identificar problemas antes que cheguem ao ambiente de produção
  • Documentação viva: Testes bem escritos servem como documentação sobre como o código deve funcionar
  • Facilitar refatoração: Alterar o código com confiança, sabendo que os testes detectarão quebras
  • Melhorar design: Código testável geralmente tem melhor design, com baixo acoplamento
  • Reduzir débito técnico: Testes ajudam a manter a qualidade do código ao longo do tempo

Tipos de Testes

# Testes unitários verificam o comportamento de unidades individuais de código

# Função a ser testada
def calcular_desconto(valor, percentual):
    """Calcula desconto baseado em um percentual."""
    if percentual < 0 or percentual > 100:
        raise ValueError("Percentual deve estar entre 0 e 100")

    return valor * (percentual / 100)

# Teste unitário usando pytest
def test_calcular_desconto():
    # Casos normais
    assert calcular_desconto(100, 10) == 10.0
    assert calcular_desconto(200, 5) == 10.0
    assert calcular_desconto(100, 0) == 0.0

    # Valor Zero
    assert calcular_desconto(0, 10) == 0.0

    # Casos de erro
    import pytest
    with pytest.raises(ValueError):
        calcular_desconto(100, -10)

    with pytest.raises(ValueError):
        calcular_desconto(100, 110)

# Para executar: pytest nome_do_arquivo.py
# Testes de integração verificam como componentes funcionam juntos

# Exemplo: Sistema de autenticação e banco de dados
class BancoDados:
    def __init__(self):
        self.usuarios = {}

    def salvar_usuario(self, usuario_id, dados):
        self.usuarios[usuario_id] = dados
        return True

    def buscar_usuario(self, usuario_id):
        return self.usuarios.get(usuario_id)

class Autenticacao:
    def __init__(self, banco_dados):
        self.banco_dados = banco_dados

    def registrar(self, usuario_id, senha):
        # Simulação de hash
        senha_hash = senha + "_hash"
        return self.banco_dados.salvar_usuario(usuario_id, {
            'senha_hash': senha_hash,
            'tentativas': 0
        })

    def autenticar(self, usuario_id, senha):
        dados = self.banco_dados.buscar_usuario(usuario_id)
        if not dados:
            return False

        senha_hash = senha + "_hash"
        if dados['senha_hash'] == senha_hash:
            dados['tentativas'] = 0
            self.banco_dados.salvar_usuario(usuario_id, dados)
            return True
        else:
            dados['tentativas'] += 1
            self.banco_dados.salvar_usuario(usuario_id, dados)
            return False

# Teste de integração
def test_integracao_autenticacao_banco():
    # Setup
    db = BancoDados()
    auth = Autenticacao(db)

    # Registro de usuário
    assert auth.registrar("user1", "senha123")

    # Autenticação bem-sucedida
    assert auth.autenticar("user1", "senha123")

    # Autenticação falha
    assert not auth.autenticar("user1", "senha_errada")

    # Verificar tentativas após falha
    assert db.buscar_usuario("user1")['tentativas'] == 1
# Testes funcionais verificam se o sistema funciona de acordo com os requisitos

# Exemplo: API de calculadora
class CalculadoraAPI:
    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

# Teste funcional
def test_funcional_calculadora():
    calc_api = CalculadoraAPI()

    # Teste do fluxo completo
    num1 = 10
    num2 = 5

    # Soma dois números
    resultado = calc_api.somar(num1, num2)
    assert resultado == 15

    # Subtrai o segundo do resultado
    resultado = calc_api.subtrair(resultado, num2)
    assert resultado == 10

    # Multiplica por 2
    resultado = calc_api.multiplicar(resultado, 2)
    assert resultado == 20

    # Divide por 4
    resultado = calc_api.dividir(resultado, 4)
    assert resultado == 5

    # Verifica erro de divisão por zero
    import pytest
    with pytest.raises(ValueError):
        calc_api.dividir(resultado, 0)

Introdução ao pytest

O pytest é um dos frameworks de teste mais populares em Python, com sintaxe simples e recursos poderosos.

# Instalação via pip
# pip install pytest

# Criando arquivo de teste
# Os arquivos devem seguir o padrão test_*.py ou *_test.py

# Exemplo de arquivo test_exemplo.py:
def soma(a, b):
    return a + b

def test_soma():
    assert soma(1, 2) == 3
    assert soma(0, 0) == 0
    assert soma(-1, 1) == 0

# Para executar:
# pytest test_exemplo.py

# Executar com detalhes:
# pytest test_exemplo.py -v

# Executar em modo de falha rápida (para ao primeiro erro):
# pytest test_exemplo.py -xvs
def test_assetrcoes_basicas():
    # Comparações básicas
    assert 1 + 1 == 2
    assert 3 - 1 != 1
    assert "abc" == "abc"

    # Operadores de comparação
    assert 5 > 3
    assert 5 >= 5
    assert 3 < 5
    assert 3 <= 5

    # Verificação de verdadeiro/falso
    assert True
    assert not False

    # Verificação de identidade (is / is not)
    x = [1, 2, 3]
    y = x  # Mesma referência
    z = [1, 2, 3]  # Referência diferente, mesmo conteúdo

    assert x is y
    assert x is not z

    # Verificação de conteúdo (in / not in)
    assert 2 in x
    assert 5 not in x
    assert "a" in "abc"

    # Verificação de exceções
    import pytest
    with pytest.raises(ZeroDivisionError):
        1 / 0

    # Verificação de substrings
    assert "python" in "Python é uma linguagem".lower()

# Quando um teste falha, pytest mostra uma explicação detalhada
def test_assetrcao_falha():
    a = 5
    b = 10
    assert a > b, f"Esperado que {a} fosse maior que {b}"
    # Falha com: AssertionError: Esperado que 5 fosse maior que 10
import pytest

# Fixtures são funções que fornecem dados ou estado para testes

@pytest.fixture
def usuario_exemplo():
    """Retorna um dicionário de usuário para testes."""
    return {
        'id': 1,
        'nome': 'João Silva',
        'email': '[email protected]',
        'ativo': True
    }

@pytest.fixture
def banco_dados_teste():
    """Cria um banco de dados temporário para testes."""
    # Setup - preparação antes do teste
    db = {'usuarios': {}}

    # Retorna o banco para o teste usar
    yield db

    # Teardown - limpeza após o teste
    db.clear()

# Usando as fixtures nos testes
def test_usuario_ativo(usuario_exemplo):
    assert usuario_exemplo['ativo'] is True

def test_adicionar_usuario(banco_dados_teste, usuario_exemplo):
    # Adiciona um usuário
    banco_dados_teste['usuarios'][usuario_exemplo['id']] = usuario_exemplo

    # Verifica se foi adicionado
    assert len(banco_dados_teste['usuarios']) == 1
    assert banco_dados_teste['usuarios'][1]['nome'] == 'João Silva'

# Fixtures com escopo
@pytest.fixture(scope="module")
def conexao_banco():
    """Uma conexão de banco que dura todo o módulo de teste."""
    print("\nAbrindo conexão com o banco...")
    conexao = {"status": "conectado"}

    yield conexao

    print("\nFechando conexão com o banco...")
    conexao["status"] = "desconectado"

def test_consulta_1(conexao_banco):
    assert conexao_banco["status"] == "conectado"
    print("Executando consulta 1")

def test_consulta_2(conexao_banco):
    assert conexao_banco["status"] == "conectado"
    print("Executando consulta 2")
import pytest

# Função para verificar se um número é primo
def is_primo(n):
    """Verifica se um número é primo."""
    if n <= 1:
        return False
    if n <= 3:
        return True
    if n % 2 == 0 or n % 3 == 0:
        return False

    i = 5
    while i * i <= n:
        if n % i == 0 or n % (i + 2) == 0:
            return False
        i += 6

    return True

# Teste parametrizado
@pytest.mark.parametrize("numero,esperado", [
    (1, False),    # 1 não é primo
    (2, True),     # 2 é primo
    (3, True),     # 3 é primo
    (4, False),    # 4 não é primo
    (5, True),     # 5 é primo
    (9, False),    # 9 não é primo
    (11, True),    # 11 é primo
    (15, False),   # 15 não é primo
    (17, True),    # 17 é primo
    (25, False),   # 25 não é primo
    (97, True),    # 97 é primo
])
def test_is_primo(numero, esperado):
    assert is_primo(numero) == esperado

# Parametrização com IDs
@pytest.mark.parametrize("entrada,esperado", [
    ("python", "PYTHON"),
    ("abc", "ABC"),
    ("123", "123"),
    ("", ""),
], ids=["palavra", "letras", "numeros", "vazio"])
def test_maiusculo(entrada, esperado):
    assert entrada.upper() == esperado

# Multiplos parâmetros
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [3, 4])
def test_multiplicacao(x, y):
    # Vai executar para todas as combinações: (1,3), (1,4), (2,3), (2,4)
    print(f"Testando {x} * {y}")
    assert x * y == x * y

Mocks e Patching

Mocks são objetos que simulam o comportamento de objetos reais de forma controlada, permitindo testar código que depende de componentes externos como APIs, bancos de dados ou funções complexas.

from unittest import mock
import requests

# Função que usa uma API externa
def buscar_usuario(id):
    """Busca um usuário em uma API externa pelo ID."""
    response = requests.get(f"https://api.exemplo.com/usuarios/{id}")
    if response.status_code == 200:
        return response.json()
    return None

# Função para exibir informações do usuário
def exibir_nome_usuario(id):
    usuario = buscar_usuario(id)
    if usuario:
        return f"Nome: {usuario['nome']}"
    return "Usuário não encontrado"

# Testando com mock
def test_exibir_nome_usuario():
    # Cria um mock para substituir a função buscar_usuario
    with mock.patch('__main__.buscar_usuario') as mock_buscar:
        # Configura o comportamento do mock
        mock_buscar.return_value = {'id': 1, 'nome': 'Ana Silva'}

        # Testa a função que usa a função mockada
        resultado = exibir_nome_usuario(1)
        assert resultado == "Nome: Ana Silva"

        # Verifica se o mock foi chamado corretamente
        mock_buscar.assert_called_once_with(1)

    # Mock com retorno diferente
    with mock.patch('__main__.buscar_usuario') as mock_buscar:
        mock_buscar.return_value = None

        resultado = exibir_nome_usuario(999)
        assert resultado == "Usuário não encontrado"
import pytest
from unittest import mock
import requests

# Classe que depende de requests para buscar dados
class ClienteAPI:
    def __init__(self, base_url):
        self.base_url = base_url

    def obter_usuario(self, id):
        url = f"{self.base_url}/usuarios/{id}"
        response = requests.get(url)
        response.raise_for_status()  # Lança exceção para status codes de erro
        return response.json()

    def criar_usuario(self, nome, email):
        url = f"{self.base_url}/usuarios"
        data = {"nome": nome, "email": email}
        response = requests.post(url, json=data)
        response.raise_for_status()
        return response.json()

# Teste com mock
def test_obter_usuario():
    cliente = ClienteAPI("https://api.exemplo.com")

    # Criar um objeto mock que simula a resposta de requests.get
    mock_resposta = mock.Mock()
    mock_resposta.json.return_value = {"id": 1, "nome": "João", "email": "[email protected]"}
    mock_resposta.raise_for_status.return_value = None

    # Usa patch para substituir requests.get pelo mock
    with mock.patch('requests.get', return_value=mock_resposta) as mock_get:
        # Chama o método que usa requests.get
        resultado = cliente.obter_usuario(1)

        # Verifica se requests.get foi chamado com a URL correta
        mock_get.assert_called_once_with("https://api.exemplo.com/usuarios/1")

        # Verifica se o resultado está correto
        assert resultado == {"id": 1, "nome": "João", "email": "[email protected]"}

# Usando pytest.fixture para reutilizar mocks
@pytest.fixture
def mock_requests():
    with mock.patch('requests.get') as mock_get, \
         mock.patch('requests.post') as mock_post:

        # Configura o comportamento padrão
        mock_response = mock.Mock()
        mock_response.json.return_value = {}
        mock_response.raise_for_status.return_value = None

        mock_get.return_value = mock_response
        mock_post.return_value = mock_response

        yield {
            'get': mock_get,
            'post': mock_post,
            'response': mock_response
        }

def test_criar_usuario(mock_requests):
    cliente = ClienteAPI("https://api.exemplo.com")

    # Configura o mock para este teste específico
    mock_requests['response'].json.return_value = {
        "id": 2,
        "nome": "Maria",
        "email": "[email protected]"
    }

    # Executa o método a ser testado
    resultado = cliente.criar_usuario("Maria", "[email protected]")

    # Verifica se requests.post foi chamado corretamente
    mock_requests['post'].assert_called_once_with(
        "https://api.exemplo.com/usuarios",
        json={"nome": "Maria", "email": "[email protected]"}
    )

    # Verifica o resultado
    assert resultado["nome"] == "Maria"
    assert resultado["email"] == "[email protected]"
from unittest import mock
import requests
import pytest

# Função que lida com erros de API
def buscar_dados_seguros(url):
    """Busca dados e lida com erros de forma segura."""
    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.Timeout:
        return {"erro": "Tempo esgotado"}
    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            return {"erro": "Recurso não encontrado"}
        return {"erro": f"Erro HTTP: {e.response.status_code}"}
    except Exception as e:
        return {"erro": f"Erro desconhecido: {str(e)}"}

# Testando diferentes cenários de erro
def test_buscar_dados_timeout():
    with mock.patch('requests.get') as mock_get:
        # Simula um timeout
        mock_get.side_effect = requests.exceptions.Timeout("Tempo esgotado")

        resultado = buscar_dados_seguros("https://api.exemplo.com/dados")
        assert resultado == {"erro": "Tempo esgotado"}

def test_buscar_dados_404():
    with mock.patch('requests.get') as mock_get:
        # Cria um mock para a resposta e a exceção
        mock_response = mock.Mock()
        mock_response.status_code = 404

        # Cria a exceção com a resposta mockada
        mock_erro = requests.exceptions.HTTPError("404 Not Found")
        mock_erro.response = mock_response

        # Faz o mock.get lançar a exceção quando chamado
        mock_get.side_effect = mock_erro

        resultado = buscar_dados_seguros("https://api.exemplo.com/dados")
        assert resultado == {"erro": "Recurso não encontrado"}

def test_buscar_dados_sucesso():
    with mock.patch('requests.get') as mock_get:
        # Simula uma resposta bem-sucedida
        mock_response = mock.Mock()
        mock_response.json.return_value = {"nome": "Teste", "valor": 42}
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        resultado = buscar_dados_seguros("https://api.exemplo.com/dados")
        assert resultado == {"nome": "Teste", "valor": 42}
from unittest.mock import Mock, patch, call

# Classe para ser "espiada"
class Calculadora:
    def somar(self, a, b):
        return a + b

    def operacao_complexa(self, x, y, z):
        # Chamamos somar internamente
        resultado = self.somar(x, y)
        return resultado * z

# Teste usando spy (observando chamadas de método reais)
def test_spy_metodo():
    calculadora = Calculadora()

    # Substitui o método somar por um spy
    # Isso permite monitorar chamadas, mas ainda executar o método original
    with patch.object(calculadora, 'somar', wraps=calculadora.somar) as spy_somar:
        # Chamamos o método que usa somar internamente
        resultado = calculadora.operacao_complexa(3, 4, 2)

        # Verificamos se o resultado está correto
        assert resultado == 14  # (3 + 4) * 2

        # Verificamos se o método somar foi chamado corretamente
        spy_somar.assert_called_once_with(3, 4)

    # Espionando múltiplas chamadas
    with patch.object(calculadora, 'somar', wraps=calculadora.somar) as spy_somar:
        calculadora.somar(1, 2)
        calculadora.somar(3, 4)
        calculadora.somar(5, 6)

        # Verificando todas as chamadas
        assert spy_somar.call_count == 3

        # Verificando argumentos de cada chamada
        expected_calls = [call(1, 2), call(3, 4), call(5, 6)]
        assert spy_somar.call_args_list == expected_calls

Testes em Frameworks Web

Python é muito usado para desenvolvimento web, e os frameworks possuem ferramentas específicas para testes.

import pytest
from flask import Flask, jsonify, request

# Aplicação Flask simples
app = Flask(__name__)

# Banco de dados "fake" para o exemplo
TAREFAS = {
    1: {"id": 1, "titulo": "Estudar Python", "concluida": False},
    2: {"id": 2, "titulo": "Fazer exercícios", "concluida": True}
}

@app.route('/tarefas', methods=['GET'])
def listar_tarefas():
    return jsonify(list(TAREFAS.values()))

@app.route('/tarefas/<int:tarefa_id>', methods=['GET'])
def obter_tarefa(tarefa_id):
    if tarefa_id not in TAREFAS:
        return jsonify({"erro": "Tarefa não encontrada"}), 404
    return jsonify(TAREFAS[tarefa_id])

@app.route('/tarefas', methods=['POST'])
def criar_tarefa():
    dados = request.json
    if not dados ou 'titulo' not in dados:
        return jsonify({"erro": "Título é obrigatório"}), 400

    novo_id = max(TAREFAS.keys(), default=0) + 1
    nova_tarefa = {
        "id": novo_id,
        "titulo": dados["titulo"],
        "concluida": dados.get("concluida", False)
    }
    TAREFAS[novo_id] = nova_tarefa
    return jsonify(nova_tarefa), 201

# Fixture para criar um cliente de teste
@pytest.fixture
def cliente():
    # Configura a aplicação para testes
    app.config['TESTING'] = True
    with app.test_client() as cliente:
        yield cliente

# Fixture para garantir que os dados de teste sejam consistentes
@pytest.fixture
def banco_reset():
    # Salva o estado original
    original = TAREFAS.copy()
    yield
    # Restaura o estado original após o teste
    TAREFAS.clear()
    TAREFAS.update(original)

# Testes para as rotas
def test_listar_tarefas(cliente, banco_reset):
    resposta = cliente.get('/tarefas')
    dados = resposta.get_json()

    assert resposta.status_code == 200
    assert len(dados) == 2
    assert dados[0]["titulo"] == "Estudar Python"
    assert dados[1]["titulo"] == "Fazer exercícios"

def test_obter_tarefa_existente(cliente, banco_reset):
    resposta = cliente.get('/tarefas/1')
    dados = resposta.get_json()

    assert resposta.status_code == 200
    assert dados["id"] == 1
    assert dados["titulo"] == "Estudar Python"

def test_obter_tarefa_inexistente(cliente, banco_reset):
    resposta = cliente.get('/tarefas/999')
    dados = resposta.get_json()

    assert resposta.status_code == 404
    assert "erro" in dados

def test_criar_tarefa(cliente, banco_reset):
    nova_tarefa = {"titulo": "Nova Tarefa", "concluida": True}
    resposta = cliente.post('/tarefas', json=nova_tarefa)
    dados = resposta.get_json()

    assert resposta.status_code == 201
    assert dados["titulo"] == "Nova Tarefa"
    assert dados["concluida"] is True
    assert "id" in dados

    # Verifica se a tarefa foi realmente adicionada
    resposta_lista = cliente.get('/tarefas')
    todas_tarefas = resposta_lista.get_json()
    assert len(todas_tarefas) == 3
# Django usa seu próprio framework de teste baseado em unittest

# models.py
from django.db import models

class Produto(models.Model):
    nome = models.CharField(max_length=100)
    preco = models.DecimalField(max_digits=10, decimal_places=2)
    estoque = models.IntegerField(default=0)

    def esta_disponivel(self):
        return self.estoque > 0

    def __str__(self):
        return self.nome

# views.py
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from .models import Produto

def listar_produtos(request):
    produtos = Produto.objects.all()
    dados = [{"id": p.id, "nome": p.nome, "preco": p.preco} for p in produtos]
    return JsonResponse(dados, safe=False)

def detalhe_produto(request, produto_id):
    produto = get_object_or_404(Produto, pk=produto_id)
    return JsonResponse({
        "id": produto.id,
        "nome": produto.nome,
        "preco": float(produto.preco),
        "estoque": produto.estoque,
        "disponivel": produto.esta_disponivel()
    })

# tests.py
from django.test import TestCase, Client
from django.urls import reverse
from .models import Produto
import json

class ProdutoModelTest(TestCase):
    def setUp(self):
        # Este método é executado antes de cada teste
        Produto.objects.create(nome="Laptop", preco="1999.99", estoque=10)
        Produto.objects.create(nome="Teclado", preco="99.99", estoque=0)

    def test_produto_disponibilidade(self):
        """Testa o método esta_disponivel()"""
        laptop = Produto.objects.get(nome="Laptop")
        teclado = Produto.objects.get(nome="Teclado")

        self.assertTrue(laptop.esta_disponivel())
        self.assertFalse(teclado.esta_disponivel())

    def test_produto_string(self):
        """Testa a representação de string do produto"""
        laptop = Produto.objects.get(nome="Laptop")
        self.assertEqual(str(laptop), "Laptop")

class ProdutoViewsTest(TestCase):
    def setUp(self):
        self.client = Client()
        Produto.objects.create(nome="Laptop", preco="1999.99", estoque=10)
        Produto.objects.create(nome="Teclado", preco="99.99", estoque=0)

    def test_listar_produtos(self):
        """Testa a view de listar produtos"""
        url = reverse('listar_produtos')  # Usa as URLs nomeadas
        response = self.client.get(url)

        self.assertEqual(response.status_code, 200)

        data = json.loads(response.content)
        self.assertEqual(len(data), 2)

    def test_detalhe_produto(self):
        """Testa a view de detalhe do produto"""
        laptop = Produto.objects.get(nome="Laptop")
        url = reverse('detalhe_produto', args=[laptop.id])
        response = self.client.get(url)

        self.assertEqual(response.status_code, 200)

        data = json.loads(response.content)
        self.assertEqual(data['nome'], "Laptop")
        self.assertEqual(data['preco'], 1999.99)
        self.assertEqual(data['estoque'], 10)
        self.assertTrue(data['disponivel'])

    def test_produto_nao_encontrado(self):
        """Testa a resposta para um produto que não existe"""
        url = reverse('detalhe_produto', args=[999])
        response = self.client.get(url)

        self.assertEqual(response.status_code, 404)

Test-Driven Development (TDD)

O TDD é uma abordagem de desenvolvimento em que você escreve testes antes de implementar o código.

# O ciclo TDD consiste em: Red -> Green -> Refactor

# 1. RED: Escreva um teste que falha
# Arquivo: test_calculadora.py
def test_divisao():
    from calculadora import dividir
    assert dividir(10, 2) == 5
    assert dividir(8, 4) == 2
    assert dividir(5, 2) == 2.5

    # Verifica se lança a exceção correta
    import pytest
    with pytest.raises(ValueError, match="Divisão por zero"):
        dividir(10, 0)

# Se executarmos agora: pytest test_calculadora.py
# O teste falhará porque a função dividir não existe

# 2. GREEN: Implementar o código mínimo para passar no teste
# Arquivo: calculadora.py
def dividir(a, b):
    if b == 0:
        raise ValueError("Divisão por zero")
    return a / b

# Agora o teste passará

# 3. REFACTOR: Melhorar o código mantendo os testes passando
# Arquivo: calculadora.py
def dividir(a, b):
    """
    Divide dois números.

    Args:
        a: Numerador
        b: Denominador

    Returns:
        O resultado da divisão a/b

    Raises:
        ValueError: Se b for zero
    """
    if b == 0:
        raise ValueError("Divisão por zero")
    return a / b

# Repetir o ciclo para cada nova funcionalidade
# Vamos implementar um validador de senhas usando TDD

# Requisitos:
# 1. A senha deve ter pelo menos 8 caracteres
# 2. A senha deve conter pelo menos uma letra maiúscula
# 3. A senha deve conter pelo menos um número
# 4. A senha deve conter pelo menos um caractere especial

# Passo 1: Escrever o teste
# Arquivo: test_validador_senha.py

import pytest

def test_validador_senha():
    from validador_senha import validar_senha

    # Senhas válidas
    assert validar_senha("Abc123!@") == True
    assert validar_senha("Senha123!") == True

    # Senha curta
    assert validar_senha("Ab1!") == False

    # Sem letra maiúscula
    assert validar_senha("abc123!@") == False

    # Sem número
    assert validar_senha("Abcdef!@") == False

    # Sem caractere especial
    assert validar_senha("Abcde123") == False

# Passo 2: Implementar o código mínimo para passar
# Arquivo: validador_senha.py

def validar_senha(senha):
    # Verifica o tamanho mínimo
    if len(senha) < 8:
        return False

    # Verifica se tem pelo menos uma letra maiúscula
    if not any(c.isupper() for c in senha):
        return False

    # Verifica se tem pelo menos um número
    if not any(c.isdigit() for c in senha):
        return False

    # Verifica se tem pelo menos um caractere especial
    caracteres_especiais = "!@#$%^&*()-_=+[]{}|;:'\",.<>/?`~"
    if not any(c in caracteres_especiais for c in senha):
        return False

    return True

# Passo 3: Refatorar
# Arquivo: validador_senha.py

def validar_senha(senha):
    """
    Valida uma senha de acordo com os seguintes critérios:
    - Pelo menos 8 caracteres
    - Pelo menos uma letra maiúscula
    - Pelo menos um número
    - Pelo menos um caractere especial

    Args:
        senha: A senha a ser validada

    Returns:
        bool: True se a senha é válida, False caso contrário
    """
    if len(senha) < 8:
        return False

    tem_maiuscula = False
    tem_numero = False
    tem_especial = False
    caracteres_especiais = "!@#$%^&*()-_=+[]{}|;:'\",.<>/?`~"

    for c in senha:
        if c.isupper():
            tem_maiuscula = True
        if c.isdigit():
            tem_numero = True
        if c in caracteres_especiais:
            tem_especial = True

    return tem_maiuscula and tem_numero and tem_especial

# Ou uma versão mais limpa usando funções mais específicas

def validar_senha(senha):
    """Valida uma senha de acordo com os critérios de segurança."""
    return (len(senha) >= 8 and
            contem_maiuscula(senha) and
            contem_numero(senha) and
            contem_especial(senha))

def contem_maiuscula(senha):
    """Verifica se a senha contém pelo menos uma letra maiúscula."""
    return any(c.isupper() for c in senha)

def contem_numero(senha):
    """Verifica se a senha contém pelo menos um número."""
    return any(c.isdigit() for c in senha)

def contem_especial(senha):
    """Verifica se a senha contém pelo menos um caractere especial."""
    caracteres_especiais = "!@#$%^&*()-_=+[]{}|;:'\",.<>/?`~"
    return any(c in caracteres_especiais for c in senha)

Cobertura de Testes

A cobertura de testes mede quanto do seu código está sendo exercitado pelos testes.

# Instalar pytest-cov
pip install pytest-cov

# Executar testes com relatório de cobertura
pytest --cov=meu_pacote tests/

# Saída de exemplo:
# Name                    Stmts   Miss  Cover
# -------------------------------------------
# meu_pacote/__init__.py      1      0   100%
# meu_pacote/modulo_a.py     20      2    90%
# meu_pacote/modulo_b.py     15      5    67%
# -------------------------------------------
# TOTAL                      36      7    81%

# Gerar relatório HTML detalhado
pytest --cov=meu_pacote --cov-report=html tests/
# Isso cria uma pasta 'htmlcov' com um relatório navegável
# Exemplo de código que precisa de testes

def processar_dados(dados):
    """Processa uma lista de dados."""
    if not dados:
        return []

    resultado = []
    for item in dados:
        if isinstance(item, str):
            # Processa strings
            if item.isdigit():
                resultado.append(int(item))
            else:
                resultado.append(item.upper())
        elif isinstance(item, (int, float)):
            # Processa números
            resultado.append(item * 2)
        else:
            # Ignora outros tipos
            continue

    return resultado

# Testes incompletos (não cobrem todos os caminhos)
def test_processar_dados_incompleto():
    assert processar_dados([]) == []
    assert processar_dados([1, 2, 3]) == [2, 4, 6]
    assert processar_dados(["a", "b"]) == ["A", "B"]

# A cobertura mostrará que faltam testar:
# - Strings que contêm números
# - Tipos que não são strings nem números

# Testes completos
def test_processar_dados_completo():
    # Lista vazia
    assert processar_dados([]) == []

    # Processamento de números
    assert processar_dados([1, 2, 3]) == [2, 4, 6]
    assert processar_dados([1.5, 2.5]) == [3.0, 5.0]

    # Processamento de strings
    assert processar_dados(["a", "b"]) == ["A", "B"]

    # Strings que são números
    assert processar_dados(["123", "456"]) == [123, 456]

    # Dados ignorados
    assert processar_dados([None, {}, []]) == []

    # Mistura de tipos
    assert processar_dados([1, "a", "123", None]) == [2, "A", 123]

Testes em Aplicações Reais

meu_projeto/
├── meu_pacote/
│   ├── __init__.py
│   ├── modulo_a.py
│   ├── modulo_b.py
│   └── subpacote/
│       ├── __init__.py
│       └── modulo_c.py
├── tests/
│   ├── __init__.py
│   ├── test_modulo_a.py
│   ├── test_modulo_b.py
│   └── test_modulo_c.py
├── setup.py
├── requirements.txt
├── requirements-dev.txt
└── pytest.ini
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Marcadores personalizados
markers =
    slow: Marca testes lentos
    integration: Marca testes de integração
    api: Marca testes que usam APIs externas

# Configurações de cobertura
addopts = --cov=meu_pacote --cov-report=term --cov-report=html
# tests/test_modulo_a.py
import pytest
from meu_pacote.modulo_a import funcao_a, ClasseA

@pytest.fixture
def objeto_a():
    return ClasseA(nome="Teste")

def test_funcao_a():
    assert funcao_a(10) == 20
    assert funcao_a(0) == 0

    with pytest.raises(ValueError):
        funcao_a(-1)

def test_classe_a(objeto_a):
    assert objeto_a.nome == "Teste"
    assert objeto_a.metodo_a() == "Teste"

@pytest.mark.slow
def test_operacao_lenta():
    # Um teste que leva tempo
    import time
    time.sleep(0.1)
    assert True

@pytest.mark.parametrize("entrada,esperado", [
    (1, 2),
    (2, 4),
    (3, 6)
])
def test_funcao_a_parametrizado(entrada, esperado):
    assert funcao_a(entrada) == esperado
# tests/test_integracao.py
import pytest
from meu_pacote.modulo_a import funcao_a
from meu_pacote.modulo_b import funcao_b

@pytest.mark.integration
def test_integracao_modulos():
    # Teste que verifica a integração entre módulos
    resultado_a = funcao_a(10)
    assert resultado_a == 20

    resultado_b = funcao_b(resultado_a)
    assert resultado_b == "Valor: 20"

@pytest.mark.api
def test_api_externa(mocker):
    # Teste que mockaria uma API externa
    from meu_pacote.subpacote.modulo_c import buscar_dados

    # Mock para a chamada de API
    mock_response = mocker.patch('requests.get')
    mock_response.return_value.json.return_value = {"status": "ok", "data": [1, 2, 3]}
    mock_response.return_value.status_code = 200

    # Testar a função que usa a API
    result = buscar_dados("endpoint")
    assert result == [1, 2, 3]
# Executar todos os testes
pytest

# Executar apenas testes rápidos (excluir lentos)
pytest -m "not slow"

# Executar apenas testes de integração
pytest -m integration

# Executar com saída detalhada
pytest -v

# Executar um arquivo específico
pytest tests/test_modulo_a.py

# Executar um teste específico
pytest tests/test_modulo_a.py::test_funcao_a

# Executar em modo debug
pytest --pdb

# Gerar relatório XML para CI/CD
pytest --junitxml=report.xml

Boas Práticas de Teste

# 1. Testes devem ser independentes
def test_independente_1():
    # Não deve depender de outros testes
    # Nem de estado compartilhado
    assert True

def test_independente_2():
    # Deve funcionar mesmo se outros testes falharem
    assert True

# 2. Testes devem ser determinísticos
def test_determinístico():
    # Deve dar o mesmo resultado em qualquer execução
    # Evite dependências de tempo, ordem, ou recursos externos
    resultado = 2 + 2
    assert resultado == 4

# 3. Testes devem ser rápidos
def test_rapido():
    # Testes devem executar rapidamente
    # Se um teste é lento, marque-o como tal
    assert True

# 4. Testes devem ser claros
def test_divisao_por_zero_deve_lancar_erro():
    # Nome claro e descritivo
    # Um teste por comportamento
    import pytest
    with pytest.raises(ZeroDivisionError):
        1 / 0

# 5. Testes devem ter boa cobertura
def calcular_area(largura, altura):
    if largura <= 0 ou altura <= 0:
        raise ValueError("Dimensões devem ser positivas")
    return largura * altura

def test_calcular_area():
    # Testar casos normais
    assert calcular_area(2, 3) == 6
    assert calcular_area(1, 1) == 1

    # Testar valores limite
    assert calcular_area(0.1, 0.1) == 0.01

    # Testar exceções
    import pytest
    with pytest.raises(ValueError):
        calcular_area(0, 5)
    with pytest.raises(ValueError):
        calcular_area(5, -1)
# O padrão AAA organiza os testes em três partes: 
# - Arrange (Preparar)
# - Act (Agir)
# - Assert (Verificar)

def test_exemplo_aaa():
    # Arrange - Preparar os dados e objetos
    lista = [1, 2, 3, 4, 5]
    valor_alvo = 3

    # Act - Executar a operação que está sendo testada
    resultado = valor_alvo in lista

    # Assert - Verificar se o resultado está correto
    assert resultado == True

# Exemplo com classe
class CarrinhoCompras:
    def __init__(self):
        self.itens = {}

    def adicionar_item(self, produto, quantidade):
        if produto em self.itens:
            self.itens[produto] += quantidade
        else:
            self.itens[produto] = quantidade

    def remover_item(self, produto, quantidade=None):
        if produto not in self.itens:
            return

        if quantidade é None ou quantidade >= self.itens[produto]:
            del self.itens[produto]
        else:
            self.itens[produto] -= quantidade

    def total_itens(self):
        return sum(self.itens.values())

def test_adicionar_item():
    # Arrange
    carrinho = CarrinhoCompras()

    # Act
    carrinho.adicionar_item("maça", 3)

    # Assert
    assert "maça" em carrinho.itens
    assert carrinho.itens["maça"] == 3

def test_remover_item_completo():
    # Arrange
    carrinho = CarrinhoCompras()
    carrinho.adicionar_item("maça", 3)

    # Act
    carrinho.remover_item("maça")

    # Assert
    assert "maça" não em carrinho.itens

def test_remover_item_parcial():
    # Arrange
    carrinho = CarrinhoCompras()
    carrinho.adicionar_item("maça", 3)

    # Act
    carrinho.remover_item("maça", 2)

    # Assert
    assert carrinho.itens["maça"] == 1
import pytest
import time

# Fixtures de diferentes escopos

@pytest.fixture
def recurso_por_teste():
    """Esta fixture é recreada para cada teste."""
    print("\nCriando recurso por teste")
    return {"valor": 42}

@pytest.fixture(scope="module")
def recurso_por_modulo():
    """Esta fixture é criada uma vez por módulo de teste."""
    print("\nCriando recurso por módulo")
    return {"contador": 0}

@pytest.fixture(scope="session")
def recurso_por_sessao():
    """Esta fixture é criada uma vez por sessão de teste."""
    print("\nCriando recurso por sessão")
    start_time = time.time()
    yield {"tempo_inicio": start_time}
    print(f"\nTempo total da sessão: {time.time() - start_time:.2f} segundos")

# Testes usando as fixtures
def test_um(recurso_por_teste, recurso_por_modulo, recurso_por_sessao):
    assert recurso_por_teste["valor"] == 42
    recurso_por_modulo["contador"] += 1
    assert recurso_por_modulo["contador"] == 1

def test_dois(recurso_por_teste, recurso_por_modulo, recurso_por_sessao):
    assert recurso_por_teste["valor"] == 42
    recurso_por_modulo["contador"] += 1
    assert recurso_por_modulo["contador"] == 2

# Fixtures parametrizadas
@pytest.fixture(params=[1, 2, 3])
def valor_teste(request):
    """Esta fixture gera três testes com valores diferentes."""
    return request.param

def test_parametrizado(valor_teste):
    assert valor_teste > 0

Resumo

Nesta aula, você aprendeu sobre testes em Python, incluindo:

  • Importância dos testes para garantir a qualidade do código
  • Tipos de testes: unitários, de integração e funcionais
  • Framework pytest e suas funcionalidades principais
  • Fixtures para reutilização de código de teste
  • Parametrização para executar testes com diferentes entradas
  • Mocks e patching para isolar unidades de código
  • TDD (Test-Driven Development) como metodologia de desenvolvimento
  • Cobertura de testes para medir a eficácia dos testes
  • Testes em frameworks como Flask e Django
  • Boas práticas para escrever testes eficazes

Próximos Passos

Na próxima aula, exploraremos os recursos e atualizações das versões recentes do Python, incluindo novos recursos de sintaxe, módulos da biblioteca padrão e melhorias de desempenho.

Avance para a próxima aula →

← Voltar para Herança e Polimorfismo