Skip to content

🧪 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)

  1. Nunca toque no disco real: Não use os.mkdir, open("arquivo_real") ou tempfile.mkdtemp.
  2. Nunca execute comandos reais: Não chame subprocess.run(["git", ...]) sem mock.
  3. 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:

  1. Adicione injeção de dependência no construtor
  2. Use MemoryFileSystem em novos testes
  3. Mantenha mocks antigos funcionando (não quebre)
  4. 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


🎯 Testes de CLI (Typer CliRunner)

⚠️ Regra Obrigatória: NUNCA Use subprocess para Testes de CLI

Por quê?

  1. Autoimunidade de CI: subprocess.run() executa em ambiente real, não isolado
  2. Performance: 95% mais rápido sem overhead de spawnar processos
  3. Segurança: Eliminação de riscos de escape de shell e injeção de comandos
  4. 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:

  1. 🧬 Mutação: O mutmut modifica o código automaticamente (ex: + vira -, == vira !=)
  2. 🧪 Teste: Executa a suite de testes com o código mutado
  3. 📊 Análise:
  4. Mutante Morto ✅: Teste falhou → Teste está funcionando corretamente
  5. Mutante Sobrevivente ❌: Teste passou → Teste não está validando a lógica

Como Usar

1. Executar Mutation Testing Completo (⚠️ Demorado)

make mutation-check

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

  1. Identificar o mutante:
mutmut show 3
  1. Ver o código mutado:
- if status == "active":
+ if status == "inactive":  # Mutação
  1. 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:

  1. Acesse GitHub Actions
  2. Selecione a execução desejada
  3. Baixe o artefato mutation-report-{run_number}
  4. Abra html/index.html no 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:

def validar_email(email: str) -> bool:
    return "@" in email and "." in email

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