Sub-issue da #33 (Fase 1 do plano umbrella test: plano de testagem 2026).
Contexto
Esta issue rastreia a Fase 1 do plano unificado de testagem (consolidação das issues #3 e #4). A Fase 0 — fundação — foi entregue no commit 5a88ee4 ("Estabelecer fundação de testes com responses + pytest-mock"). Esta Fase 1 implementa contratos offline para os 7 scrapers HTTP do projeto.
A estratégia é uma adaptação da abordagem consolidada por jtrecenti/juscraper, especificamente das issues:
Estado atual (Fase 0 entregue em 5a88ee4)
Já está pronto:
pyproject.toml: responses>=0.25.0, pytest-mock>=3.12.0, markers (slow, integration), filterwarnings = ["error"], addopts = "-m 'not integration'".
tests/_helpers.py: load_sample(scraper, relative_path, encoding="utf-8") e load_sample_bytes(scraper, relative_path).
tests/conftest.py: fixture tests_dir.
tests/fixtures/capture/_util.py: dump_response(resp, path) e attach_capture_hook(session, samples_dir, prefix, extension).
tests/fixtures/capture/README.md: como rodar, quando re-rodar, env vars.
CLAUDE.md: nova seção "Testes" com convenção e checklist obrigatória de 8 itens para scrapers novos.
Comando de baseline: pytest -m "not integration" → 8/8 passando em ~1.5s (ainda só test_utils.py).
Escopo desta Fase 1
Cobrir 7 scrapers HTTP com contratos offline (responses + samples reais):
| # |
Scraper |
Família |
Sub-PR |
| 1 |
presidencia |
HTML simples |
1A |
| 2 |
camara |
HTML simples |
1A |
| 3 |
ipea |
HTML simples |
1A |
| 4 |
senado |
HTML especial |
1B |
| 5 |
cfm |
HTML especial |
1B |
| 6 |
folha |
JSON especial |
1C |
| 7 |
nyt |
JSON especial |
1C |
Fora do escopo desta issue:
- comunicaCNJ — vai migrar para o juscraper (sistema judicial). Follow-up em issue separada no juscraper.
- ans, anvisa, datalegis, saudelegis (Playwright) — abrir issue separada própria;
responses não consegue mockar tráfego que passa pelo Chromium.
- Granulares de
utils.py e BaseScraper — Fase 2, issue própria depois desta.
- CI workflow (tests.yml em PR + integration.yml nightly) — Fase 3, issue própria depois.
Estrutura do plano de execução
3 sub-PRs em sequência, cada um se referencia a esta issue. Cada sub-PR mergeia antes do próximo começar para deixar samples e helpers compartilhados evoluírem incrementalmente (mesmo padrão usado pelo juscraper PRs #119/#123/#142/#146).
Sub-PR 1A — HTML simples (3 scrapers)
Escopo: presidencia, camara, ipea. POST/GET HTML stateless, parser BeautifulSoup, _find_n_pags extrai número de resultados via regex.
| Scraper |
URL |
Método |
Paginação |
Particularidade |
| presidencia |
legislacao.presidencia.gov.br/.../resultado_pesquisa_legislacao.php |
POST form |
offset (posicao, query_page_multiplier=10, query_page_increment=-10) |
SSL desabilitado (urllib3.disable_warnings); thousands separator em "1.006 resultados encontrados" |
| camara |
camara.leg.br/legislacao/busca |
GET |
1-based (pagina) |
Regex de (\d+) em div.busca-info__resultado |
| ipea |
ipea.gov.br/portal/coluna-5/busca |
GET |
1-based (pagina) |
h4 dentro de div.col.clearfix (frágil — sem fallback) |
Entregáveis:
tests/{presidencia,camara,ipea}/test_raspar_contract.py (3 cenários cada: typical com paginação, single_page, no_results).
tests/{presidencia,camara,ipea}/samples/raspar/*.html (samples reais).
tests/fixtures/capture/{presidencia,camara,ipea}.py (scripts de captura).
- Cenário extra
presidencia: validar parse correto de "1.006 resultados" (thousands separator) — caso real do código em src/raspe/scrapers/presidencia.py:80-90.
- Cenário extra
ipea: caso onde div.col.clearfix não existe → DataFrame vazio (regressão para o ponto frágil em src/raspe/scrapers/ipea.py).
Sub-PR 1B — HTML com paginação especial (2 scrapers)
Escopo: senado, cfm.
| Scraper |
URL |
Particularidade |
| senado |
senado.leg.br/busca |
Atributo data-click-type em <a>; fallbacks para estruturas dinâmicas (p[0]="Legislação" vs p[0]=descricao) |
| cfm |
portal.cfm.org.br/buscar-normas |
Regex "N registros encontrados" + "Mostrando página X de Y" |
Entregáveis:
tests/{senado,cfm}/test_raspar_contract.py (3 cenários cada).
- Samples + scripts de captura.
- Para
senado: 2 layouts diferentes do data-click-type (caso p[0]="Legislação" vs p[0]=descricao).
Sub-PR 1C — JSON com particularidades (2 scrapers)
Escopo: folha, nyt.
| Scraper |
URL |
Paginação |
Particularidade |
| folha |
search.folha.uol.com.br/search |
Offset (sr=1,26,51..., query_page_multiplier=25, query_page_increment=-24) |
Cap 10k resultados (400 pags); valida site ∈ {todos,online,jornal} (ValidationError se inválido) |
| nyt |
api.nytimes.com/svc/search/v2 |
0-based (query_page_multiplier=1, query_page_increment=-1) |
API key obrigatória (env NYT_API_KEY); rate limit 5 req/min; MAX_PAGES=100 |
Entregáveis:
tests/{folha,nyt}/test_raspar_contract.py (3 cenários cada).
- NYT: cenário extra
APIKeyError quando env NYT_API_KEY ausente (sem HTTP, validação pré-request); matcher confirma page=0 para paginas=range(1,2); mockar time.sleep para o rate limit.
- Folha: matcher confirma
sr=26 para página 2 (mult=25, incr=-24); cenário ValidationError quando site="invalido".
- Atualizar
tests/fixtures/capture/README.md listando NYT_API_KEY como env obrigatória.
Anatomia padrão de um contrato
Template canônico (exemplo: presidencia):
# tests/presidencia/test_raspar_contract.py
import pandas as pd
import responses
from responses.matchers import urlencoded_params_matcher
import raspe
from tests._helpers import load_sample_bytes
@responses.activate
def test_raspar_retorna_dataframe_typical(mocker):
mocker.patch("time.sleep")
url = "https://legislacao.presidencia.gov.br/pesquisa/ajax/resultado_pesquisa_legislacao.php"
responses.add(
responses.POST, url,
body=load_sample_bytes("presidencia", "raspar/page_01.html"),
status=200,
match=[urlencoded_params_matcher({"termo": "saúde", "posicao": "0"}, strict_match=False)],
)
responses.add(
responses.POST, url,
body=load_sample_bytes("presidencia", "raspar/page_02.html"),
status=200,
match=[urlencoded_params_matcher({"termo": "saúde", "posicao": "10"}, strict_match=False)],
)
scraper = raspe.scraper("presidencia")
df = scraper.raspar(pesquisa="saúde", paginas=range(1, 3))
assert isinstance(df, pd.DataFrame)
assert {"nome", "link", "ficha", "revogacao", "descricao", "termo_busca"} <= set(df.columns)
assert len(df) > 0
@responses.activate
def test_raspar_no_results(mocker):
mocker.patch("time.sleep")
# ... sample no_results.html → assert len(df) == 0
@responses.activate
def test_raspar_single_page(mocker):
mocker.patch("time.sleep")
# ... sample single_page.html → assert len(df) > 0 e só um request foi feito
Princípios obrigatórios (por sub-PR)
Reproduzidos da seção "Testes" no CLAUDE.md (commit 5a88ee4):
- Contratos validam API pública (entrada → DataFrame de saída), não estrutura interna. Sobrevivem refactor.
@responses.activate decorator. Fluxos multi-step com ordem usam responses.registries.OrderedRegistry.
mocker.patch("time.sleep") em toda função/classe com paginação. BaseScraper faz time.sleep(self.sleep_time) entre páginas (src/raspe/base_scraper.py:191).
- Matchers de payload sempre que possível:
urlencoded_params_matcher(..., strict_match=False) para POST form (presidencia).
json_params_matcher(...) para POST JSON.
query_param_matcher(...) para GET (camara, ipea, senado, cfm, folha, nyt).
- Schema validado por subset:
{"col_a", "col_b"} <= set(df.columns). Nunca igualdade — DataFrame ganha colunas como termo_busca que BaseScraper.raspar injeta automaticamente.
- 3 cenários mínimos por método: typical (paginação), single_page, no_results.
- Sem
@pytest.mark.integration no contrato — esses testes são offline puros.
- Sem dependência de rede, relógio ou TLS real.
- Captchas, tokens dinâmicos, lazy imports: mockar via
mocker.patch.dict(sys.modules, ...) — nunca invocar.
Como capturar samples (passo-a-passo)
Cada sub-PR adiciona scripts em tests/fixtures/capture/<scraper>.py. Padrão:
# tests/fixtures/capture/presidencia.py
"""Captura samples reais do scraper Presidência.
Uso:
python tests/fixtures/capture/presidencia.py
"""
from pathlib import Path
import requests
from tests.fixtures.capture._util import attach_capture_hook
from raspe.scrapers.presidencia import ScraperPresidencia
OUT = Path("tests/presidencia/samples/raspar")
OUT.mkdir(parents=True, exist_ok=True)
scraper = ScraperPresidencia()
detach = attach_capture_hook(scraper.session, OUT, prefix="page", extension="html")
# Cenário 1 — typical com paginação (≥2 páginas)
df = scraper.raspar(pesquisa="saúde", paginas=range(1, 3))
print(f"typical: {len(df)} linhas, colunas: {df.columns.tolist()}")
# Cenário 2 — single_page (busca específica que retorna ≤10 resultados)
# ... renomear depois manualmente: page_03.html → single_page.html
# Cenário 3 — no_results
df = scraper.raspar(pesquisa="termoextremamenteimprovavelxyz123")
print(f"no_results: {len(df)} linhas")
detach()
Após rodar, inspecionar manualmente os arquivos gerados em tests/<scraper>/samples/raspar/, renomear conforme convenção (page_01.html, single_page.html, no_results.html), e commitá-los.
Cenários por scraper:
| Scraper |
typical (busca) |
single_page |
no_results |
| presidencia |
"saúde" (paginas=range(1,3)) |
"decreto 7508" |
"termoextremamenteimprovavelxyz123" |
| camara |
"lei orgânica" (paginas=range(1,3)) |
"lei 9999" |
"termoextremamenteimprovavelxyz123" |
| ipea |
"renda básica" (paginas=range(1,3)) |
termo específico |
"termoextremamenteimprovavelxyz123" |
| senado |
"constituição" (paginas=range(1,3)) |
termo específico |
"termoextremamenteimprovavelxyz123" |
| cfm |
"publicidade médica" (paginas=range(1,3)) |
termo específico |
"termoextremamenteimprovavelxyz123" |
| folha |
"Lula" (paginas=range(1,3)) |
termo específico |
"termoextremamenteimprovavelxyz123" |
| nyt |
"Brazil" (paginas=range(1,3)) |
termo específico |
"termoextremamenteimprovavelxyz123" |
Critérios de aceitação por sub-PR
pytest -m "not integration" passa offline em <5s agregado.
- Cada contrato cobre 3 cenários por método público.
- Todo POST/GET tem matcher de payload quando aplicável.
- Samples commitados em
tests/<scraper>/samples/<endpoint>/<cenario>.<ext>.
- Script de captura commitado em
tests/fixtures/capture/<scraper>.py.
- Sem
src/raspe/ modificado — exceção: se contrato expor bug, abrir issue separada e marcar @pytest.mark.skip(reason="ver #N").
CHANGELOG.md não atualizado — adicionar testes para scraper existente é mudança interna (ver "Versionamento e CHANGELOG" no CLAUDE.md).
Bugs prováveis a expor (precedente do juscraper)
O juscraper PR #119 (1B do juscraper) descobriu 3 bugs reais ao implementar contratos:
- TJRS: paginação
pagina_atual=0 enviava 0-based, mas backend interpretava como start=-10.
- TJRS: aliases deprecados sobreviviam no
kwargs e colidiam com canônicos.
- TJMT/TJBA:
cjsg_parse não tratava null explícito em coleções.
Esperar bugs análogos no raspe ao escrever contratos. Pontos suspeitos:
presidencia._find_n_pags — handler de thousands separator pode falhar em casos extremos (ex.: "1.234.567 resultados" — milhão).
ipea._find_n_pags — sem fallback para div.col.clearfix ausente; HTML mudou? Quebra silenciosa.
nyt — paginação 0-based com query_page_increment=-1: validar matcher do page no GET para paginas=range(1,2) e range(2,3).
folha — validação de site está em _validar_parametros? Confirmar pre-request.
BaseScraper._set_paginas quando n_pags=0 — devolve range(1,1) (vazio)? Cobrir explicitamente.
Bug exposto = abrir issue separada, marcar @pytest.mark.skip(reason="ver #N"), NÃO incluir fix no PR de testes (mantém revisão limpa).
Referências
Contexto
Esta issue rastreia a Fase 1 do plano unificado de testagem (consolidação das issues #3 e #4). A Fase 0 — fundação — foi entregue no commit
5a88ee4("Estabelecer fundação de testes com responses + pytest-mock"). Esta Fase 1 implementa contratos offline para os 7 scrapers HTTP do projeto.A estratégia é uma adaptação da abordagem consolidada por jtrecenti/juscraper, especificamente das issues:
Estado atual (Fase 0 entregue em 5a88ee4)
Já está pronto:
pyproject.toml:responses>=0.25.0,pytest-mock>=3.12.0, markers (slow,integration),filterwarnings = ["error"],addopts = "-m 'not integration'".tests/_helpers.py:load_sample(scraper, relative_path, encoding="utf-8")eload_sample_bytes(scraper, relative_path).tests/conftest.py: fixturetests_dir.tests/fixtures/capture/_util.py:dump_response(resp, path)eattach_capture_hook(session, samples_dir, prefix, extension).tests/fixtures/capture/README.md: como rodar, quando re-rodar, env vars.CLAUDE.md: nova seção "Testes" com convenção e checklist obrigatória de 8 itens para scrapers novos.Comando de baseline:
pytest -m "not integration"→ 8/8 passando em ~1.5s (ainda sótest_utils.py).Escopo desta Fase 1
Cobrir 7 scrapers HTTP com contratos offline (
responses+ samples reais):Fora do escopo desta issue:
responsesnão consegue mockar tráfego que passa pelo Chromium.utils.pyeBaseScraper— Fase 2, issue própria depois desta.Estrutura do plano de execução
3 sub-PRs em sequência, cada um se referencia a esta issue. Cada sub-PR mergeia antes do próximo começar para deixar samples e helpers compartilhados evoluírem incrementalmente (mesmo padrão usado pelo juscraper PRs #119/#123/#142/#146).
Sub-PR 1A — HTML simples (3 scrapers)
Escopo: presidencia, camara, ipea. POST/GET HTML stateless, parser BeautifulSoup,
_find_n_pagsextrai número de resultados via regex.legislacao.presidencia.gov.br/.../resultado_pesquisa_legislacao.phpposicao,query_page_multiplier=10,query_page_increment=-10)urllib3.disable_warnings); thousands separator em "1.006 resultados encontrados"camara.leg.br/legislacao/buscapagina)de (\d+)emdiv.busca-info__resultadoipea.gov.br/portal/coluna-5/buscapagina)div.col.clearfix(frágil — sem fallback)Entregáveis:
tests/{presidencia,camara,ipea}/test_raspar_contract.py(3 cenários cada: typical com paginação, single_page, no_results).tests/{presidencia,camara,ipea}/samples/raspar/*.html(samples reais).tests/fixtures/capture/{presidencia,camara,ipea}.py(scripts de captura).presidencia: validar parse correto de "1.006 resultados" (thousands separator) — caso real do código emsrc/raspe/scrapers/presidencia.py:80-90.ipea: caso ondediv.col.clearfixnão existe → DataFrame vazio (regressão para o ponto frágil emsrc/raspe/scrapers/ipea.py).Sub-PR 1B — HTML com paginação especial (2 scrapers)
Escopo: senado, cfm.
senado.leg.br/buscadata-click-typeem<a>; fallbacks para estruturas dinâmicas (p[0]="Legislação"vsp[0]=descricao)portal.cfm.org.br/buscar-normasEntregáveis:
tests/{senado,cfm}/test_raspar_contract.py(3 cenários cada).senado: 2 layouts diferentes dodata-click-type(casop[0]="Legislação"vsp[0]=descricao).Sub-PR 1C — JSON com particularidades (2 scrapers)
Escopo: folha, nyt.
search.folha.uol.com.br/searchsr=1,26,51...,query_page_multiplier=25,query_page_increment=-24)site ∈ {todos,online,jornal}(ValidationErrorse inválido)api.nytimes.com/svc/search/v2query_page_multiplier=1,query_page_increment=-1)NYT_API_KEY); rate limit 5 req/min;MAX_PAGES=100Entregáveis:
tests/{folha,nyt}/test_raspar_contract.py(3 cenários cada).APIKeyErrorquando envNYT_API_KEYausente (sem HTTP, validação pré-request); matcher confirmapage=0parapaginas=range(1,2); mockartime.sleeppara o rate limit.sr=26para página 2 (mult=25, incr=-24); cenárioValidationErrorquandosite="invalido".tests/fixtures/capture/README.mdlistandoNYT_API_KEYcomo env obrigatória.Anatomia padrão de um contrato
Template canônico (exemplo:
presidencia):Princípios obrigatórios (por sub-PR)
Reproduzidos da seção "Testes" no
CLAUDE.md(commit5a88ee4):@responses.activatedecorator. Fluxos multi-step com ordem usamresponses.registries.OrderedRegistry.mocker.patch("time.sleep")em toda função/classe com paginação.BaseScraperfaztime.sleep(self.sleep_time)entre páginas (src/raspe/base_scraper.py:191).urlencoded_params_matcher(..., strict_match=False)para POST form (presidencia).json_params_matcher(...)para POST JSON.query_param_matcher(...)para GET (camara, ipea, senado, cfm, folha, nyt).{"col_a", "col_b"} <= set(df.columns). Nunca igualdade — DataFrame ganha colunas comotermo_buscaqueBaseScraper.rasparinjeta automaticamente.@pytest.mark.integrationno contrato — esses testes são offline puros.mocker.patch.dict(sys.modules, ...)— nunca invocar.Como capturar samples (passo-a-passo)
Cada sub-PR adiciona scripts em
tests/fixtures/capture/<scraper>.py. Padrão:Após rodar, inspecionar manualmente os arquivos gerados em
tests/<scraper>/samples/raspar/, renomear conforme convenção (page_01.html,single_page.html,no_results.html), e commitá-los.Cenários por scraper:
Critérios de aceitação por sub-PR
pytest -m "not integration"passa offline em <5s agregado.tests/<scraper>/samples/<endpoint>/<cenario>.<ext>.tests/fixtures/capture/<scraper>.py.src/raspe/modificado — exceção: se contrato expor bug, abrir issue separada e marcar@pytest.mark.skip(reason="ver #N").CHANGELOG.mdnão atualizado — adicionar testes para scraper existente é mudança interna (ver "Versionamento e CHANGELOG" noCLAUDE.md).Bugs prováveis a expor (precedente do juscraper)
O juscraper PR #119 (1B do juscraper) descobriu 3 bugs reais ao implementar contratos:
pagina_atual=0enviava 0-based, mas backend interpretava comostart=-10.kwargse colidiam com canônicos.cjsg_parsenão tratavanullexplícito em coleções.Esperar bugs análogos no raspe ao escrever contratos. Pontos suspeitos:
presidencia._find_n_pags— handler de thousands separator pode falhar em casos extremos (ex.: "1.234.567 resultados" — milhão).ipea._find_n_pags— sem fallback paradiv.col.clearfixausente; HTML mudou? Quebra silenciosa.nyt— paginação 0-based comquery_page_increment=-1: validar matcher dopageno GET parapaginas=range(1,2)erange(2,3).folha— validação desiteestá em_validar_parametros? Confirmar pre-request.BaseScraper._set_paginasquandon_pags=0— devolverange(1,1)(vazio)? Cobrir explicitamente.Bug exposto = abrir issue separada, marcar
@pytest.mark.skip(reason="ver #N"), NÃO incluir fix no PR de testes (mantém revisão limpa).Referências
/home/brunodcdo/.claude/plans/issues-3-e-4-ethereal-breeze.md(na máquina do mantenedor).responses: https://github.com/getsentry/responses