🧪 Guia de Testes (SRE Standard)
Este projeto adota uma filosofia estrita de Testes Unitários Isolados. O objetivo é garantir que a suíte de testes seja rápida (< 50ms), determinística e segura (sem efeitos colaterais).
🚫 O Que Não Fazer (Anti-Patterns)
- Nunca toque no disco real: Não use
os.mkdir,open("arquivo_real")outempfile.mkdtemp. - Nunca execute comandos reais: Não chame
subprocess.run(["git", ...])sem mock. - Nunca dependa de estado externo: Não assuma que o usuário tem Git instalado ou configurado.
✅ Como Escrever Testes (The Right Way)
Usamos unittest.mock intensivamente.
Exemplo: Mockando Arquivos e Comandos
from unittest.mock import MagicMock, patch
from pathlib import Path
# 1. Patch no subprocess (Blindagem)
@patch("scripts.git_sync.sync_logic.subprocess.run")
# 2. Patch no Path (Filesystem Virtual)
@patch("scripts.git_sync.sync_logic.Path")
def test_exemplo_seguro(self, mock_path, mock_run):
# Configurar o Mock do Filesystem
mock_path.return_value.exists.return_value = True
# Configurar o Mock do Comando
mock_run.return_value.returncode = 0
# Executar (O código acha que está tocando no disco, mas não está)
resultado = minha_funcao_perigosa()
# Validar
assert resultado == True
Consulte tests/test_smart_git_sync.py para exemplos avançados de mocks em cadeia.
🚀 Testes de Alta Velocidade (In-Memory)
Problema: Testes Lentos com I/O Real
Testes que tocam o disco real são lentos e frágeis:
- ⏱️ Latência: 50-100ms por arquivo (vs. 0.5ms em memória)
- 🐛 Flakiness: Race conditions em testes paralelos
- 🧹 Cleanup: Necessário gerenciar arquivos temporários
- 🔒 Isolamento: Difícil garantir independência entre testes
Solução: FileSystemAdapter + MemoryFileSystem
Use MemoryFileSystem para simular I/O em memória pura.
Exemplo: Teste com Disco Real (❌ Lento)
import tempfile
import shutil
from pathlib import Path
def test_load_config_slow():
# Setup (50ms) - cria diretório temporário
tmpdir = tempfile.mkdtemp()
config_path = Path(tmpdir) / "config.yaml"
config_path.write_text("key: value")
# Test (10ms)
manager = GitSyncManager(config_path)
config = manager.load_config()
# Cleanup (20ms) - remove arquivos
shutil.rmtree(tmpdir)
assert config == {"key": "value"}
# Total: ~80ms
Problemas:
- Lento (80ms)
- Precisa de cleanup manual
- Pode deixar arquivos órfãos em caso de erro
- Não funciona bem em CI/CD com filesystem read-only
Exemplo: Teste In-Memory (✅ Rápido)
from pathlib import Path
from scripts.utils.filesystem import MemoryFileSystem
def test_load_config_fast():
# Setup (0.1ms) - filesystem virtual em RAM
fs = MemoryFileSystem()
fs.write_text(Path("config.yaml"), "key: value")
# Test (0.3ms) - injeta dependência
manager = GitSyncManager(Path("config.yaml"), fs=fs)
config = manager.load_config()
# Cleanup: Automático! (0ms)
assert config == {"key": "value"}
# Total: ~0.5ms (160x mais rápido!)
Benefícios:
- ⚡ 160x mais rápido (0.5ms vs 80ms)
- 🧹 Zero cleanup (garbage collector cuida)
- 🔒 Isolamento total (cada teste tem seu próprio filesystem)
- 🎯 Determinístico (sem race conditions)
API Completa do MemoryFileSystem
from pathlib import Path
from scripts.utils.filesystem import MemoryFileSystem
# Criar filesystem virtual
fs = MemoryFileSystem()
# Escrever arquivos
fs.write_text(Path("config.yaml"), "key: value")
fs.write_text(Path("data/users.json"), '{"name": "Alice"}')
# Ler arquivos
content = fs.read_text(Path("config.yaml")) # "key: value"
# Verificar existência
assert fs.exists(Path("config.yaml")) # True
assert fs.is_file(Path("config.yaml")) # True
assert fs.is_dir(Path("data")) # True
assert not fs.exists(Path("inexistente")) # False
# Criar diretórios
fs.mkdir(Path("logs/2025/12"))
# Glob patterns (simplificado)
files = fs.glob(Path("."), "*.yaml") # [Path("config.yaml")]
# Copiar arquivos
fs.copy(Path("config.yaml"), Path("backup/config.yaml"))
Padrão de Injeção de Dependência
Para tornar código testável, injete o FileSystemAdapter:
❌ Código Não Testável
class GitSyncManager:
def __init__(self, config_path: Path):
self.config_path = config_path
def load_config(self):
# Acoplado ao disco real
if self.config_path.exists():
return yaml.safe_load(self.config_path.read_text())
return {}
✅ Código Testável (com DI)
from scripts.utils.filesystem import FileSystemAdapter, RealFileSystem
class GitSyncManager:
def __init__(
self,
config_path: Path,
fs: FileSystemAdapter | None = None # Injeção
):
self.config_path = config_path
self.fs = fs or RealFileSystem() # Default produção
def load_config(self):
# Usa abstração
if self.fs.exists(self.config_path):
content = self.fs.read_text(self.config_path)
return yaml.safe_load(content)
return {}
🧪 Teste Unitário
def test_load_config_quando_existe():
# Arrange
fs = MemoryFileSystem()
fs.write_text(Path("config.yaml"), "key: value")
# Act
manager = GitSyncManager(Path("config.yaml"), fs=fs)
config = manager.load_config()
# Assert
assert config == {"key": "value"}
def test_load_config_quando_nao_existe():
# Arrange
fs = MemoryFileSystem() # Filesystem vazio
# Act
manager = GitSyncManager(Path("config.yaml"), fs=fs)
config = manager.load_config()
# Assert
assert config == {}
Cenários Avançados
Simulando Erros de I/O
from scripts.utils.filesystem import MemoryFileSystem
def test_handle_file_not_found():
fs = MemoryFileSystem()
manager = GitSyncManager(Path("config.yaml"), fs=fs)
# Arquivo não existe, deve retornar {}
config = manager.load_config()
assert config == {}
def test_read_invalid_yaml():
fs = MemoryFileSystem()
fs.write_text(Path("config.yaml"), "invalid: [yaml") # YAML inválido
manager = GitSyncManager(Path("config.yaml"), fs=fs)
with pytest.raises(yaml.YAMLError):
manager.load_config()
Testando Operações de Diretório
def test_create_nested_directories():
fs = MemoryFileSystem()
# Cria estrutura profunda
fs.mkdir(Path("logs/2025/12/05"))
fs.write_text(Path("logs/2025/12/05/app.log"), "INFO: Started")
# Verifica hierarquia
assert fs.is_dir(Path("logs"))
assert fs.is_dir(Path("logs/2025"))
assert fs.is_dir(Path("logs/2025/12"))
assert fs.is_file(Path("logs/2025/12/05/app.log"))
Testando Glob Patterns
def test_find_test_files():
fs = MemoryFileSystem()
fs.write_text(Path("test_utils.py"), "# test")
fs.write_text(Path("test_models.py"), "# test")
fs.write_text(Path("main.py"), "# app")
# Busca apenas testes
test_files = fs.glob(Path("."), "test_*.py")
assert len(test_files) == 2
assert Path("test_utils.py") in test_files
assert Path("test_models.py") in test_files
assert Path("main.py") not in test_files
Quando Usar vs. Mocks Tradicionais
| Cenário | Use MemoryFileSystem | Use unittest.mock |
|---|---|---|
| Testes de lógica de negócio | ✅ Sim | ❌ Verboso |
| Múltiplas operações I/O | ✅ Sim (simples) | ❌ Complexo |
| Verificar estado do filesystem | ✅ Sim (natural) | ⚠️ Trabalhoso |
| Código legado sem DI | ❌ Não (precisa refatorar) | ✅ Sim (patch) |
| Testar erro específico | ⚠️ Limitado | ✅ Sim (mock.side_effect) |
| Operações binárias | ❌ Não (apenas texto) | ✅ Sim |
Migração Gradual
Se você tem código legado usando unittest.mock, migre gradualmente:
- Adicione injeção de dependência no construtor
- Use MemoryFileSystem em novos testes
- Mantenha mocks antigos funcionando (não quebre)
- Refatore aos poucos conforme tocar no código
Limitações do MemoryFileSystem
⚠️ Não suporta:
- Arquivos binários (apenas texto UTF-8)
- Permissões de arquivo (sempre 0o644 implícito)
- Links simbólicos
- Timestamps (criação/modificação)
- Glob patterns complexos (apenas
*e?)
Para esses casos, use unittest.mock.patch ou RealFileSystem com tempfile.
Referências
- Abstração de Plataforma e I/O - Design detalhado
scripts/utils/filesystem.py- Código-fonte completo- Testes Existentes - Exemplos práticos
🎯 Testes de CLI (Typer CliRunner)
⚠️ Regra Obrigatória: NUNCA Use subprocess para Testes de CLI
Por quê?
- Autoimunidade de CI:
subprocess.run()executa em ambiente real, não isolado - Performance: 95% mais rápido sem overhead de spawnar processos
- Segurança: Eliminação de riscos de escape de shell e injeção de comandos
- Determinismo: CliRunner não depende de PATH, variáveis de ambiente, etc.
✅ Padrão Correto: typer.testing.CliRunner
Use CliRunner para invocar comandos Typer de forma isolada:
from typer.testing import CliRunner
from scripts.cortex.cli import app
runner = CliRunner()
def test_cortex_map_command():
"""Testa o comando 'cortex map' de forma isolada."""
result = runner.invoke(app, ["map", "--verbose"])
# Verificações
assert result.exit_code == 0
assert "✅ Context map generated" in result.stdout
Exemplos Práticos
Teste com Flags e Argumentos
def test_cortex_audit_with_strict_mode():
"""Testa audit em modo strict."""
runner = CliRunner()
result = runner.invoke(app, [
"audit",
"docs/guides/",
"--strict",
"--fail-on-error"
])
assert result.exit_code in [0, 1] # Pode falhar se houver erros
assert "Audit complete" in result.stdout
Teste de Comando que Deve Falhar
def test_cortex_audit_fails_with_invalid_path():
"""Verifica que comando falha com path inválido."""
runner = CliRunner()
result = runner.invoke(app, ["audit", "/caminho/invalido"])
assert result.exit_code == 1
assert "Error" in result.stdout or "not found" in result.stdout.lower()
Teste com Entrada Interativa (stdin)
def test_interactive_command():
"""Testa comando que pede confirmação do usuário."""
runner = CliRunner()
# Simula usuário digitando 'y' + Enter
result = runner.invoke(app, ["init", "docs/new.md"], input="y\n")
assert result.exit_code == 0
assert "Frontmatter added" in result.stdout
Teste com Mock de Sistema de Arquivos
from unittest.mock import patch, MagicMock
def test_cortex_map_with_mocked_fs():
"""Testa cortex map com filesystem mockado."""
runner = CliRunner()
with patch("scripts.cortex.commands.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
result = runner.invoke(app, ["map"])
assert result.exit_code == 0
mock_path.assert_called()
Anti-Patterns (NÃO FAÇA)
❌ ERRADO - Usando subprocess:
import subprocess
def test_cortex_map_wrong():
# NUNCA FAÇA ISSO!
result = subprocess.run(
["python", "-m", "scripts.cortex.cli", "map"],
capture_output=True,
text=True
)
assert result.returncode == 0
Problemas:
- Depende do ambiente externo (PATH, virtualenv)
- Lento (spawna processo Python completo)
- Frágil em CI/CD (variáveis de ambiente)
- Risco de segurança
✅ CORRETO - Usando CliRunner:
from typer.testing import CliRunner
from scripts.cortex.cli import app
def test_cortex_map_correct():
runner = CliRunner()
result = runner.invoke(app, ["map"])
assert result.exit_code == 0
Estrutura de Teste Recomendada
"""Testes para comandos cortex CLI."""
import pytest
from typer.testing import CliRunner
from scripts.cortex.cli import app
# Fixture reutilizável
@pytest.fixture
def cli_runner():
"""Retorna CliRunner configurado."""
return CliRunner()
class TestCortexCommands:
"""Suite de testes para comandos cortex."""
def test_map_generates_context(self, cli_runner):
"""Verifica que 'cortex map' gera contexto."""
result = cli_runner.invoke(app, ["map"])
assert result.exit_code == 0
assert ".cortex/context.json" in result.stdout
def test_audit_validates_docs(self, cli_runner):
"""Verifica que 'cortex audit' valida documentação."""
result = cli_runner.invoke(app, ["audit", "docs/"])
assert result.exit_code == 0
assert "Audit" in result.stdout
Debugging de Testes CLI
Se um teste falhar, inspecione a saída:
def test_debug_output(cli_runner):
result = cli_runner.invoke(app, ["comando", "--opcao"])
# Debug helpers
print(f"Exit Code: {result.exit_code}")
print(f"STDOUT:\n{result.stdout}")
print(f"Exception: {result.exception}")
# Se houver exceção, mostra traceback completo
if result.exception:
import traceback
traceback.print_exception(
type(result.exception),
result.exception,
result.exception.__traceback__
)
Referências
🧟 Mutation Testing (Validação de Qualidade de Testes)
O Problema: Testes Falsos Positivos
Você pode ter 100% de cobertura de código, mas isso NÃO garante que seus testes estejam validando a lógica corretamente.
Exemplo de teste falso positivo:
def soma(a, b):
return a + b # Lógica correta
def test_soma():
resultado = soma(2, 3)
assert resultado # ❌ Passa, mas não valida o valor!
Este teste tem cobertura 100%, mas não valida se o resultado é 5. Se alguém mudar para return a - b, o teste continua passando.
A Solução: Mutation Testing
O Mutation Testing (Teste de Mutação) funciona assim:
- 🧬 Mutação: O mutmut modifica o código automaticamente (ex:
+vira-,==vira!=) - 🧪 Teste: Executa a suite de testes com o código mutado
- 📊 Análise:
- Mutante Morto ✅: Teste falhou → Teste está funcionando corretamente
- Mutante Sobrevivente ❌: Teste passou → Teste não está validando a lógica
Como Usar
1. Executar Mutation Testing Completo (⚠️ Demorado)
Este comando:
- Exibe aviso sobre o tempo de execução
- Roda mutmut em todo o código (
scripts/,src/) - Gera relatório de mutantes mortos vs. sobreviventes
2. Executar em Arquivo Específico (Recomendado)
Para desenvolvimento diário, teste apenas o arquivo que você está trabalhando:
# Exemplo: validar apenas utils/security.py
# Nota: mutmut usa configuração do pyproject.toml, então ajuste temporariamente
# a seção [tool.mutmut] para paths_to_mutate = ["scripts/utils/security.py"]
mutmut run
# Ver resultados
mutmut results
# Ver detalhes de um mutante sobrevivente específico
mutmut show 1
Interpretando os Resultados
Exemplo de output:
Legend for output:
🎉 Killed mutants: The goal! Your tests caught the bug.
⏰ Timeout: Mutant caused infinite loop (good!).
🤔 Suspicious: Mutant caused error but test passed (investigate).
🙁 Survived: Mutant passed all tests (FIX YOUR TESTS!).
Exemplo de relatório:
Survived: 5 (❌ Testes fracos - prioridade alta)
Killed: 42 (✅ Testes funcionando)
Timeout: 2 (✅ Testes funcionando)
Suspicious: 1 (⚠️ Investigar)
Como Corrigir Mutantes Sobreviventes
- Identificar o mutante:
- Ver o código mutado:
- Adicionar/melhorar teste:
def test_status_validation():
result = validar_status("active")
assert result is True # ✅ Agora detecta a mutação
result_inativo = validar_status("inactive")
assert result_inativo is False # ✅ Teste negativo
Configuração
A configuração do mutmut está em pyproject.toml:
[tool.mutmut]
paths_to_mutate = "scripts/,src/"
runner = "python -m pytest"
tests_dir = "tests/"
backup = false
Quando Usar Mutation Testing
✅ Use quando:
- Implementar lógica crítica (segurança, validações)
- Refatorar código existente
- Aumentar confiança na suite de testes
- Auditar qualidade de testes legados
❌ Evite quando:
- Código trivial (getters/setters)
- Testes ainda não escritos (escreva primeiro)
- CI/CD diário (muito lento)
Auditoria Noturna Automatizada
Este projeto executa mutation testing automaticamente todas as noites às 03:00 AM (BRT) através do workflow mutation-audit.yml.
Características:
- 🕐 Agendamento: Diário às 03:00 BRT (06:00 UTC)
- 🎯 Foco:
scripts/core/(núcleo do projeto) - 📊 Relatório: HTML disponível como artefato do workflow
- ⏱️ Timeout: 6 horas máximo
- 📥 Retenção: Relatórios salvos por 30 dias
Como acessar os relatórios:
- Acesse GitHub Actions
- Selecione a execução desejada
- Baixe o artefato
mutation-report-{run_number} - Abra
html/index.htmlno navegador
Execução manual:
# Via GitHub Actions (recomendado para CI)
gh workflow run mutation-audit.yml
# Via Makefile (local, modo interativo)
make mutation-check
# Via Makefile (local, modo CI - core only)
make mutation-ci
Métricas de Qualidade
Meta de Mutation Score:
- 🥇 Excelente: > 80% mutantes mortos
- 🥈 Bom: 60-80% mutantes mortos
- 🥉 Aceitável: 40-60% mutantes mortos
- ❌ Crítico: < 40% mutantes mortos
Exemplo Prático
Código original:
Mutações possíveis:
# Mutante 1: Operador lógico
return "@" in email or "." in email # ❌ Sobrevivente?
# Mutante 2: Operador de comparação
return "@" not in email and "." in email # ✅ Deve morrer
# Mutante 3: String literal
return "" in email and "." in email # ✅ Deve morrer
Testes robustos:
def test_validar_email():
# Casos positivos
assert validar_email("user@example.com") is True
# Casos negativos (matam mutantes)
assert validar_email("user@example") is False # Sem domínio
assert validar_email("userexample.com") is False # Sem @
assert validar_email("user@") is False # Incompleto
assert validar_email("") is False # Vazio
Referências
Última atualização: 2025-12-31 (v1.3.0) - Adicionada seção Mutation Testing