1

Implementando Testes Combinatórios em APIs: Do Algoritmo de Labirinto à Cobertura Total

Quando fiz o curso CS50 de Introdução à Ciência da Computação do MIT, uma das lições que mais me marcou foi o uso de algoritmos clássicos de busca — como Depth‑First Search e Breadth‑First Search — para resolver labirintos. O programa percorria corredores, marcava bifurcações, identificava becos sem saída e, ao final, apresentava um mapa completo do ambiente.

Então venho trazer a idéia que uso para vocês adaptarem se acharem interessante.

Anos depois, ao encarar uma base de código com dezenas de planos de assinatura, limites variáveis e mais de uma centena de rotas, percebi que estava diante de um labirinto — só que de regras de negócio. Foi assim que comecei a aplicar a mesma lógica de exploração exaustiva em testes automatizados. O resultado foi uma pequena suíte que não se limita a verificar cenários felizes: ela mapeia todas as combinações possíveis de estado do sistema e só interrompe a execução quando encontra anomalias.

Neste artigo vou mostrar como você pode levar essa ideia para qualquer stack, sem depender de ferramentas especializadas. Não vou prometer que isso é uma revolução — property‑based testing, fuzzing e model‑based testing já existem há décadas —, mas a aplicação prática desse “explorador de labirinto” em testes de integração de APIs tem se mostrado incrivelmente eficaz e de baixa manutenção.

O problema dos testes cenário‑a‑cenário
Testes tradicionais costumam ser escritos assim:

python
def test_usuario_gratuito_nao_pode_criar_mais_de_2_recursos():
# Arrange
usuario = cria_usuario_gratuito()
# Act
cria_recurso(usuario) # 1º recurso → OK
cria_recurso(usuario) # 2º recurso → OK
# Assert
with pytest.raises(PermissionError):
cria_recurso(usuario) # 3º recurso → bloqueado

Esse teste é importante, mas cobre uma única combinação de estado. Em produção, aquele mesmo usuário pode ter um plano expirado, um trial ativo, um histórico de upgrades e downgrades, ou uma mistura de recursos legados com recursos novos. A explosão combinatória é enorme: se você tem 10 estados de conta e 15 configurações de carga, já são 150 cenários. Nenhum time escreve 150 testes manuais para uma única regra — e aí moram os bugs silenciosos que só aparecem naquele cruzamento específico que ninguém testou.
**
A metáfora do labirinto ajuda a enxergar o problema:** cada estado da conta é uma bifurcação, cada configuração de recursos é uma sala diferente, e cada rota da API é um corredor. O teste tradicional manda o rato entrar e virar à direita uma vez. Nosso objetivo é fazer o rato percorrer todos os corredores e nos contar onde encontrou portas trancadas onde não devia, ou portas abertas onde deveria haver bloqueio.

Os três pilares da exploração automatizada
A suíte que montei se apoia em três estratégias complementares. Você pode implementar cada uma gradualmente.

1. Explorador combinatório de limites e estados
A ideia central é cruzar uma lista de estados de conta com uma lista de cargas de recursos e, para cada combinação, verificar se as funções de cálculo de limite do sistema permanecem consistentes.

Em vez de testar “usuário free tem limite 2”, a matriz gera:

Estados: free, trial ativo, pago mensal, pago anual, expirado, cancelado, sem nenhum registro no banco, com múltiplas assinaturas, etc.

Cargas: zero recursos, exatamente no limite, um acima do limite, carga industrial (centenas de recursos), mix de tipos de recursos diferentes.

Multiplicando, você obtém algo como 13 × 16 = 208 cenários. O laço de exploração executa cada combinação em um banco de dados isolado (um clone de produção ou um banco de testes), popula os recursos, invoca as funções que calculam o limite (tanto pela API quanto pelo frontend/backoffice, se houver lógica duplicada) e compara os resultados.

O teste só falha se encontrar divergências. Em uma das execuções reais, descobrimos que a API contava dispositivos de um tipo, mas o Dashboard ignorava um tipo novo de recurso — uma divergência que existia há semanas e que nenhum teste manual tinha pego, porque o cenário específico (trial expirado + mix de três tipos de recurso) nunca era testado.

2. Simulador de ciclo de vida
Aqui o foco é o comportamento ao longo do tempo. O teste simula um cliente real percorrendo todo o ciclo de vida:

cadastro → uso gratuito até o limite → bloqueio

ativação de trial → expiração do trial → bloqueio de re‑trial (anti‑abuso)

upgrade para plano pago → adição de expansões → cancelamento → retorno ao limite gratuito

A cada transição, o teste recalcula o que o sistema deveria permitir e confere se as portas se abrem e fecham corretamente. Isso é especialmente útil para garantir que políticas de anti‑abuso (impedir dois trials consecutivos do mesmo produto) e regras de grace period estejam funcionando.

3. Fuzzer de superfície de API
Este é o estágio de descoberta autônoma. O script:

Inspeciona a aplicação (via lista de rotas do framework, como app.url_map no Flask, ou um arquivo OpenAPI) e extrai todos os endpoints.

Gera um conjunto de usuários em todos os estados relevantes (anônimo, free, trial, pago, expirado…).

Sonda cada rota com cada estado, registrando o código HTTP retornado.

Analisa a matriz resultante em busca de anomalias: um 500 inesperado, uma rota que deveria exigir autenticação mas está aberta, ou uma rota que um usuário cancelado ainda consegue acessar.

Opcionalmente, realiza uma busca binária para descobrir o limite real de recursos — tenta criar 50, se falhar tenta 25, depois 12, e assim por diante, até achar o valor exato em que o sistema bloqueia.

O resultado é um mapa visual (emojis no terminal: ✅ 200, 🔒 401, 🚫 403, 💥 500) e um relatório de anomalias. Toda vez que uma nova rota é adicionada ao sistema, ela aparece automaticamente na próxima execução, sem que ninguém precise atualizar o teste.

Implementando na prática (com exemplos genéricos)
Vou mostrar um esqueleto de como você pode construir seu próprio explorador combinatório. O exemplo usa Python, mas o conceito é portável para qualquer linguagem.

Estrutura dos vetores de estado
Defina listas com os perfis que você quer cruzar. Exemplo para uma plataforma de armazenamento de arquivos:

python


ESTADOS = [
    "anonimo",
    "free",
    "trial_30dias",
    "pago_basico",
    "pago_premium",
    "expirado",
    "cancelado",
    "inadimplente",
]

CARGAS = [
    "zero_arquivos",
    "no_limite_5gb",
    "acima_limite_6gb",
    "carga_extrema_1tb",
]

Isolamento do ambiente
Nunca rode testes exploratórios em produção. Use um banco de testes que seja um clone do esquema de produção, preferencialmente com uma massa de dados de referência (planos, preços) mas sem dados reais de clientes. Cada cenário deve criar seus próprios registros com um prefixo que permita limpeza posterior (test_, maze_, etc.).

python
def setup_cenario(estado, carga):
usuario = criar_usuario(prefixo="maze_", estado=estado)
popular_arquivos(usuario, carga)
return usuario
Laço de exploração
O coração do teste é um laço que itera sobre o produto cartesiano dos vetores e coleta resultados:

python


def test_exploracao_combinatoria():
    divergencias = []
    for estado in ESTADOS:
        for carga in CARGAS:
            with limpar_apos():
                usuario = setup_cenario(estado, carga)
                limite_api = obter_limite_via_api(usuario)
                limite_dashboard = obter_limite_via_dashboard(usuario)
                uso_real = contar_arquivos(usuario)

                if limite_api != limite_dashboard:
                    divergencias.append(
                        f"Divergência de limite: {estado} x {carga} → "
                        f"API={limite_api}, Dashboard={limite_dashboard}"
                    )
                if uso_real > limite_api:
                    divergencias.append(
                        f"Excedeu limite: {estado} x {carga} → "
                        f"uso={uso_real}, limite={limite_api}"
                    )

    if divergencias:
        pytest.fail("\n".join(divergencias))

Busca binária de limites
Se você quiser descobrir o limite real imposto pelo backend (e não apenas confiar no número declarado), pode automatizar:

python


def descobrir_limite_real(usuario):
    baixo, alto = 0, 1024  # limite superior seguro
    while baixo < alto:
        meio = (baixo + alto + 1) // 2
        try:
            criar_ate_n_arquivos(usuario, meio)
            baixo = meio  # conseguiu, tenta mais
        except LimiteExcedido:
            alto = meio - 1  # bloqueou, tenta menos
    return baixo
Depois compare descobrir_limite_real com o limite declarado pela API. Se divergirem, você encontrou um bug de lógica de negócio potencialmente grave.

Fuzzer simples de rotas
Exemplo usando Flask (conceito similar para FastAPI, Express, etc.):

python
def coletar_rotas(app):
    regras = []
    for rule in app.url_map.iter_rules():
        if not rule.rule.startswith("/static"):
            regras.append(rule.rule)
    return regras

def sondar_rotas(rotas, estados, client):
    matriz = {}
    for estado_nome, headers in estados.items():
        for rota in rotas:
            resp = client.get(rota, headers=headers)
            matriz[(estado_nome, rota)] = resp.status_code
    return matriz

**A análise da matriz pode ser tão simples quanto buscar por status 500 ou por padrões como “usuário free acessando rota de admin”.

Resultados e lições aprendidas
Depois de algumas semanas rodando esses testes em um sistema real, os ganhos foram claros:

Cobertura de cenários extremos: bugs que dependiam de combinações específicas (ex.: trial expirado + 100+ recursos + mix de tipos) passaram a ser detectados automaticamente.

Manutenção quase zero: adicionar um novo plano de assinatura ou uma nova rota não exige escrever novos testes — o explorador combinatório e o fuzzer os incorporam automaticamente.

Documentação viva: a matriz de permissões gerada pelo fuzzer serve como documentação atualizada de quais perfis acessam quais endpoints. O time de produto passou a consultar esse relatório.

Tempo de execução: é preciso cuidado. Uma matriz 14 × 70 rotas = 980 requisições, o que pode levar alguns minutos. Aqui vale usar paralelismo ou limitar a execução a pipelines de CI noturnos, enquanto uma versão reduzida roda em commits.**

Não é bala de prata. Se o sistema tem efeitos colaterais imprevisíveis ou APIs não idempotentes, o fuzzer pode causar ruído. É essencial que o ambiente de testes seja efetivamente descartável e que os dados de produção jamais sejam tocados.

Conclusão
Transformar a mentalidade de “verificar cenários” para “explorar o espaço de estados” é um passo que vale a pena quando o sistema começa a ganhar complexidade. O que nasceu como um exercício de algoritmo de busca no CS50 se mostrou uma abordagem prática e reutilizável para encontrar bugs que testes manuais jamais encontrariam.

Se você está lidando com uma API cheia de planos, permissões e limites variáveis, sugiro começar com um explorador combinatório pequeno — três estados, cinco cargas — e rodar à parte. O primeiro bug silencioso que ele encontrar vai pagar o esforço da implementação. E, assim como no labirinto do curso, a satisfação de ver o mapa completo aparecer no terminal não tem preço.

Este artigo não requer ferramentas proprietárias. Todo o código de exemplo é genérico e adaptável a qualquer stack backend.

Carregando publicação patrocinada...