본문으로 건너뛰기

테스트 가이드

테스트는 1급 시민입니다. PR 머지 게이트는 **변경된 코드의 line coverage ≥ 80 %**이며, Playwright E2E 스위트는 PR에서 실행되지 않습니다(아래 커버리지 게이트 참고). 이 페이지는 레이아웃, 하네스 패턴, 정적 분석으로는 못 잡는 버그를 잡는 적대적 입력 규칙을 다룹니다.

대상 독자

모든 컨트리뷰터. apps/backend/나 apps/frontend/를 건드리는 모든 PR에 적용.

Backend — pytest​

테스트는 apps/backend/tests/ 아래에 있으며 세 계층으로 분리됩니다.

apps/backend/tests/
├── unit/ # 순수 함수 테스트, DB·네트워크 없음
├── integration/ # FastAPI TestClient + Postgres (testcontainers)
└── e2e/ # 백엔드 단독 블랙박스 흐름; Playwright 스위트와 다름

각 계층의 conftest.py는 적절한 fixture를 노출합니다. 최상위 conftest.py는 계층 간 공용 헬퍼(factory, time freezing)를 제공합니다.

집중된 셋만 실행​

cd apps/backend

# 전체 스위트
pytest -q

# 단일 계층
pytest -q tests/unit

# 키워드로
pytest -q -k "api_key and revoke"

# 단일 테스트 + print
pytest -s tests/integration/test_api_key_endpoints.py::test_revoke_immediate

git worktree 안에서 작업할 때​

세 가지가 예상과 다릅니다.

tools/ 아래 저장소 도구가 저장소 루트를 찾는 방식이 서로 다릅니다. tools/em-dash/lint.mjs는 git에게 물으므로 실행 위치를 따릅니다. tools/ko-style/lint.mjs는 자기 파일 위치에서 루트를 계산하므로, 메인 체크아웃의 복사본을 절대경로로 부르면 워크트리 안에서 실행하든 워크트리 파일을 인자로 주든 메인 트리를 검사합니다. 워크트리 자기 복사본을 쓰십시오. 린트가 내 파일을 읽고 있는지 확인하려면 그중 하나에 위반을 심어 정확한 줄에서 잡히는지 보고 되돌립니다. 검사한 파일 수는 내 브랜치가 파일을 추가했을 때만 달라지므로 그것만으로는 알 수 없습니다.

내 앞 리비전이 아직 다른 사람 브랜치에 남아 있어 alembic upgrade head가 풀리지 않을 때는 자리표시자 마이그레이션을 만들되, 파일 이름에 _local_stub을 넣고 .git/info/exclude에는 그 정확한 경로를 적습니다. 글롭이 아닙니다. 그 파일은 git 공통 디렉터리에 하나뿐이라 모든 워크트리에 적용되므로, 0081_*.py라고 적으면 동료의 진짜 0081_finding_assignment.py가 그쪽 git status에서 사라지고 마이그레이션이 빠진 채로 PR이 열립니다. 그리고 진짜 리비전이 main에 들어오는 즉시, 리베이스하기 전에 자리표시자를 지우십시오. 같은 revision 문자열을 선언한 파일이 둘이면 alembic이 아예 멈춥니다. 그때 .git/info/exclude의 해당 줄도 함께 지웁니다. 남은 줄은 무해하지 않습니다. 공용 파일이고, 이제는 아무도 볼 수 없는 경로를 가리키며, 다음 사람이 그 줄이 무엇을 막고 있었는지 알아내야 합니다.

리베이스한 결과에 낯선 파일이 없는지 볼 때 git diff --name-only origin/main을 쓰는 것도 예상과 다릅니다. 이 명령은 "무엇이 다른가"에 답하므로 내 변경과 브랜치를 딴 뒤 main이 얻은 것을 함께 보여 줍니다. 그래서 남이 머지한 파일이 내가 지운 파일처럼 보입니다. 병합 기준과 비교하거나, 리베이스한 뒤에 보십시오. 낯선 이름이 나왔을 때 main이 움직인 것인지 내가 움직인 것인지는 git log HEAD..origin/main이 가릅니다. 이것이 헷갈리는 것으로 끝나지 않는 이유는 소음입니다. 확인할 때마다 낯선 이름이 나오면 목록을 훑고 넘기게 되고, 진짜 사고는 그 목록 안에 있습니다.

자리표시자가 닿은 데이터베이스는 버린다​

자리표시자 마이그레이션을 적용하면 DDL은 하나도 실행하지 않은 채 그 리비전만 적용됨으로 기록됩니다. 나중에 진짜 리비전이 들어오면 alembic이 alembic_version에서 그 id를 보고 건너뛰고, alembic upgrade head를 몇 번 돌려도 복구되지 않습니다. 데이터베이스는 최신 리비전이라고 보고하면서 그 리비전이 추가한 컬럼이 없는 상태로 남습니다. 없는 컬럼 때문에 테스트 하나가 실패하기 전까지 이상해 보이는 것이 없고, 그 실패는 지금 손에 든 코드의 결함처럼 읽힙니다. 여기서 실제로 그랬고, 기록된 리비전과 실제 스키마를 대조하기 전에 이미 엉뚱한 곳을 뒤지기 시작한 뒤였습니다.

그러니 자리표시자가 필요한 작업에는 버릴 데이터베이스를 하나 두고 끝나면 지우십시오. 사슬을 잇는 것이 자리표시자의 목적이라 기록 자체는 피할 수 없습니다. 피할 수 있는 것은 재사용뿐입니다.

부재를 확인하는 질의는 존재하는 것에 먼저 걸어 본다​

아무것도 찾지 못한 질의와 잘못 쓴 질의는 같은 답을 냅니다. 위의 오염을 확인하던 대조가 findings.assignee_user_id를 물었는데 실제 테이블은 vulnerability_findings라, 어느 데이터베이스에서든 0이 나왔습니다. 데이터베이스가 실제로 오염돼 있었으므로 결론은 그대로 살아남았고 어긋나 보이는 곳이 없었습니다. 이 종류가 잡기 어려운 이유가 그것입니다. 답은 맞았고 근거만 비어 있었습니다.

같은 질의를 존재한다고 아는 것에 먼저 걸어 보십시오. 그 양성 대조가 없으면 0은 아무 뜻도 없습니다.

단언에 그것이 무엇을 지키는지 적습니다​

빨간 테스트는 둘 중 하나입니다. 그 테스트가 지키려던 것을 내가 깨뜨렸거나, 그 테스트가 더 이상 없는 세계를 서술하고 있거나입니다. 둘은 똑같이 생겼고, 고치는 방법은 하나뿐입니다. 지금 필요해진 것으로 감싸서 통과시키는 쪽이 언제나 더 짧으니, 그 모호함은 가드를 지우는 방향으로 풀립니다.

빠져나오는 길은 단언이 자기 용도를 적어 두는 것입니다. 본문 내용 테스트 스물한 건이 "No QueryClient set"으로 붉어졌을 때 당연한 수는 provider로 감싸는 것이었습니다. 그런데 그 파일의 독스트링이 본문은 의도적으로 표면 비의존이며 안쪽이 드로어에 하드 의존을 가지면 여기가 먼저 깨진다고 적어 두었습니다. 그 한 문장이 기계적 수정을 설계 변경으로 바꿨습니다. 편집기는 표면이 채워 주는 슬롯이 되었습니다.

이유는 커밋 메시지가 아니라 실패가 읽히는 자리에 적습니다.

절반만 맞는 화면이 전부 틀린 화면보다 어렵습니다​

아무것도 실패하지 않고 오류도 없으며 화면 일부는 제대로 갱신됩니다. 캐시 무효화 키가 틀리면 이렇게 됩니다. 뮤테이션은 성공하고 드로어는 자기 쓰기로 갱신되며 표만 낡습니다. 시선은 틀려 보이는 절반인 표로 가는데 원인은 맞아 보이는 절반에 있습니다.

화면 일부만 낡았으면 낡은 쪽보다 그 둘을 잇는 것을 먼저 의심하십시오. 맞는 절반은 그 경로가 온전하다는 증거가 아닙니다. 깨진 단계를 아예 건너뛰는 이유로 맞은 경우가 많습니다.

통과하는 이웃은 허가가 아닙니다​

옆 파일이 쓰는 방식대로 썼는데 내 것만 검사에 걸리는 일은 여기서 흔합니다. 그러면 내가 세부를 틀렸다고 결론짓게 됩니다. 실제로 그럴 때도 있습니다. 그러나 이웃이 통과하는 이유가 나에게는 적용되지 않는 경우가 많고, 적어도 셋입니다.

  • 기록된 예외입니다. token:lint와 같은 계열은 새로 추가된 위반만 막고 기존 위반은 예산으로 들고 있습니다. 그래서 health 패널마다 있는 text-slate-600은 누군가 적어 둔 부채이지 관례가 아닙니다. 같은 방식으로 쓴 새 패널은 걸리고 옆의 것들은 통과합니다.
  • 그 자리에서만 정당했습니다. 저장소 전체에 nosemgrep이 세 곳 있는데, 어떤 디렉터리에는 같은 형태 스물세 곳이 그것 없이 쓰여 있습니다. 거기에 네 번째 억제를 다는 것은 다음 사람에게 그 규칙은 눌러도 된다고 가르치는 셈이었습니다.
  • 검사가 그것을 보지 못합니다. 옆 컴포넌트의 i18n 키가 추출 검사를 통과하고 똑같아 보이는 내 키는 걸린 적이 있는데, 그쪽 키가 템플릿 리터럴이라 정적 분석기가 읽지 못하고 동적 키로 분류한 것이었습니다. 그 위치는 애초에 검증된 적이 없습니다.

셋은 밖에서 보면 똑같이 생겼고, 그것이 셋을 다 적는 이유입니다. 베이스라인만 아는 사람은 베이스라인을 확인하고 아무것도 없으니 이웃이 관례라고 결론짓습니다. 복사하기 전에 어느 쪽인지 확인하십시오.

네 번째는 이웃이 통과하는 것과는 다른 경우입니다. 이웃에서 가져온 것이 옳으면서 동시에 틀릴 수 있고, 한 덩어리로 가져오면 그 이음매가 안 보입니다. 배정 캐리포워드 테스트가 옆 파일에서 픽스처 둘을 가져와야 했습니다. 세션과 데이터베이스 게이트입니다. 테스트가 파라미터 이름으로도 쓰는 픽스처를 import하면 재정의가 되므로 둘 다 로컬로 옮겨 썼습니다. 세션 픽스처는 공용 버전이 없었고 게이트는 있었습니다. 함께 옮기면서 ER66이 없애려던 사설 게이트를 다시 만들었습니다. 게이트를 가리키는 것은 아무것도 없었습니다. 멀쩡한 것에 붙어서 왔기 때문입니다. 하나보다 많이 가져올 때는 각각에 대해 따로 물으십시오.

로컬 실행: Postgres도 Redis도 실행마다 따로 씁니다​

이 스위트는 실제 Postgres와 실제 Redis에 붙고, 둘 다 자동으로 격리되지 않습니다. 같은 데이터베이스나 같은 Redis 인덱스를 가리키는 실행 둘은 서로 간섭하는데, 간섭하는 방식이 아주 다릅니다.

데이터베이스를 공유하면 곧바로 드러납니다. alembic이 거부하거나, 테스트가 자기가 쓰지 않은 행을 읽습니다. "브랜치마다 데이터베이스 하나"가 관행으로 자리 잡은 이유가 그것입니다. 실행마다 따로 만드십시오.

createdb trusca_mybranch # 또는 docker exec ... psql -c 'CREATE DATABASE ...'
export DATABASE_URL=postgresql+asyncpg://trustedoss:trustedoss@localhost:5432/trusca_mybranch

Redis 인덱스를 공유하면 조용합니다. 그래서 관행이 생기지 않았습니다. Celery 브로커가 거기 있으므로, 다른 실행이 띄운 워커가 이 실행이 발행한 메시지를 그대로 가져갑니다. 작업은 도착하지 않고 테스트는 시간 초과까지 기다리며, 그 실패는 지금 손에 든 코드의 결함처럼 보입니다. 아무도 쓰지 않는 인덱스를 잡으십시오.

export REDIS_URL=redis://localhost:6379/7 # 비어 있는 아무 인덱스

conftest.py가 세션 시작 시 이것을 확인해, 인덱스에 이미 키가 있으면 개수와 표본을 찍고 실행을 멈춥니다. 아무것도 지우지 않습니다. 공유 중인 인덱스를 비우면 실행 중인 다른 쪽이 깨지기 때문입니다. 자기 실행이 남긴 것이면 직접 지우고, 아니면 자리를 옮기십시오.

CI에는 이 준비가 필요 없습니다. 잡마다 Postgres와 Redis 컨테이너를 따로 받으므로 워크플로의 고정된 URL이 이미 그 잡 전용입니다.

데이터베이스가 없을 때의 판정은 실행 환경이 정한다​

스키마가 필요한 테스트는 tests._db_required를 부릅니다. 이 모듈이 예전에는 같이 다루던 두 상황을 나눕니다.

데이터베이스가 없는 것은 환경의 사실입니다. Postgres를 띄우지 않은 노트북이거나 존재하지 않는 데이터베이스를 가리키고 있으면 그 테스트들은 건너뜁니다. 아무것도 준비하지 않고도 스위트를 돌릴 수 있는 것이 이 덕분입니다. TRUSCA_TESTS_REQUIRE_DB가 다스리는 것이 이 경우입니다. CI에서는 켜 둡니다. 거기서는 데이터베이스가 있다고 약속된 환경이므로 없는 것이 잘못입니다.

데이터베이스는 응답하는데 마이그레이션이 되지 않는 것은 결함입니다. 그리고 CI에서든 노트북에서든 같은 결함입니다. 그래서 이쪽은 플래그와 무관하게 어디서나 실패합니다. 로컬에서 건너뛰면 마이그레이션을 깨뜨린 사람이 방금 돌린 실행이 아니라 CI에서 그 사실을 알게 됩니다.

둘 중 어느 쪽인지는 연결을 열어 판정하고, 마이그레이션이 낸 오류 문구를 읽어 판정하지 않습니다. 드라이버마다 판마다 문구가 달라지므로 맞춰 볼 대상이 아닙니다.

이렇게 나눈 이유는 알아 두는 편이 좋습니다. 이전 동작은 무해해 보였기 때문입니다. alembic upgrade head가 실패하면 모든 모듈이 건너뛰었으므로, 마이그레이션을 깨뜨린 PR은 스키마가 필요한 테스트를 전부 건너뛰게 만들고, 깨진 마이그레이션을 잡아야 할 잡이 0으로 끝났습니다. 일부러 깨뜨린 마이그레이션으로 확인한 값은 이렇습니다. 통합 쪽이 1824건, 단위 쪽이 989건을 건너뛰었고, 두 잡을 빨갛게 만든 것은 게이트가 아예 없던 소수의 모듈이었습니다. 게이트가 작동해서가 아닙니다.

같은 측정에서 헬퍼의 두 가지 성질이 따라 나옵니다.

  • 마이그레이션은 프로세스당 한 번만 시도하고 결과를 기억합니다. 실패도 기억합니다. 예전에는 모듈마다 각자 시도했으므로, 하나가 깨지면 첫 모듈만 진짜 오류를 보고하고 나머지는 중간에 멈춘 체인을 다시 올리다 만난 relation "..." already exists를 보고했습니다. 다시 시도해도 성공할 수 없고, 참인 진단을 거짓 진단으로 바꾸기만 합니다.
  • 데이터베이스를 쓰는 모듈은 그것에 대한 게이트를 가져야 합니다. 세션만 열고 마이그레이션을 부르지 않는 모듈은 앞선 다른 모듈이 스키마를 만들어 둔 동안만 통과하고 혼자 돌리면 실패합니다. test_database_gates_are_shared.py가 그런 모듈이 있으면 실패하고, 새 모듈이 헬퍼를 부르는 대신 게이트를 자기 손으로 복사하면 역시 실패합니다.

CI와 같은 조건으로 보려면 값을 켜고 돌립니다.

TRUSCA_TESTS_REQUIRE_DB=1 pytest tests/integration

Coverage​

pytest --cov=. --cov-report=term-missing --cov-report=xml

변경된 라인 기준 line coverage ≥ 80 %를 목표로 합니다. CI는 두 스위트를 별도 잡(test (backend-unit), test (backend-integration))으로 돌리고, coverage-gate (backend)가 두 잡의 커버리지 데이터를 합쳐 두 가지를 판정합니다. 전체 트리는 fail_under = 80으로, 브랜치가 바꾼 라인은 diff-cover로 같은 80 % 기준을 적용합니다. 둘 중 하나라도 미달이면 잡이 실패합니다.

레이아웃 가이드​

  • Unit: 테스트 대상 함수는 DB, HTTP, Celery를 사용하지 않습니다. 경계에서 mock합니다.
  • Integration: 라우트를 FastAPI TestClient로 종단 간 실행합니다. 실제 PostgreSQL은 pytest-testcontainers로 띄우고, SQLAlchemy는 mock하지 않습니다.
  • E2E (backend): worker가 별도 fixture로 실제 동작하는 상태에서 HTTPX로 API를 블랙박스로 구동합니다. Playwright가 주된 E2E이므로 절제해서 사용합니다.

Frontend — PortalPage 하네스 기반 Playwright​

apps/frontend/tests/_harness/PortalPage.ts가 도메인 언어로 된 Page Object를 정의합니다. 테스트 코드는 page.click(...)을 직접 호출하지 않습니다.

왜 하네스인가​

도메인 동사로 표현된 테스트는 UI 변화에도 살아남습니다. 동일 시나리오를 비교해 보세요.

// ❌ 부서지기 쉬움 — 모달 마크업이 바뀌면 깨짐
await page.click("button:has-text('New API key')");
await page.fill("input[name='label']", "ci-runner");
await page.click("button:has-text('Create')");

// ✅ 안정적 — 제품 언어로 표현
await portal.createApiKey({ label: "ci-runner", scope: "team", expiryDays: 90 });

하네스에 동사 추가​

새 화면이나 흐름을 추가할 때는 먼저 PortalPage에 동사를 추가한 다음 시나리오를 작성하세요.

// apps/frontend/tests/_harness/PortalPage.ts
async createApiKey(opts: { label: string; scope: ApiKeyScope; expiryDays: number }) {
await this.page.getByRole("button", { name: "New API key" }).click();
await this.page.getByLabel("Label").fill(opts.label);
await this.page.getByLabel("Scope").selectOption(opts.scope);
await this.page.getByLabel("Expiry").selectOption(`${opts.expiryDays}d`);
await this.page.getByRole("button", { name: "Create" }).click();
return this.captureKeyFromOneTimeRevealModal();
}

하네스에는 현재 ~17개 동사가 있습니다. PortalPage.ts만 읽고도 제품의 사용자 여정을 다시 풀어낼 수 있어야 합니다.

실행​

cd apps/frontend
npm run test:e2e # 모든 시나리오
npm run test:e2e -- --grep "api keys" # 필터링
npm run test:e2e:headed # 브라우저 표시, 디버깅 시 유용

E2E 실행 전 dev 스택이 떠 있어야 합니다(docker-compose -f docker-compose.dev.yml up -d).

적대적 입력 — parametrize 필수​

신뢰할 수 없는 입력을 파싱하는 코드는 적대적 케이스의 parametrize 매트릭스로 반드시 검증해야 합니다. 포털은 이미 한 번 당했습니다 — chore PR #7의 재귀적 normalize_spdx_id는 88 % 커버리지에서도 separator-only 토큰으로 DoS를 허용했습니다.

적용 대상​

  • 레지스트리 메타데이터 파서(packages/, npm, pypi, cargo, go.mod).
  • Webhook URL·페이로드 파서(GitHub, GitLab, Slack, Teams).
  • SPDX·CycloneDX 표현식 정규화기.
  • OAuth state·code 파서.
  • 사용자 콘텐츠가 regex, 경로, 셸로 보간되는 모든 곳.

매트릭스​

각 표면에 대해 최소 다음 적대적 입력으로 parametrize.

분류예시
Separator-only 토큰"AND", "OR", "WITH", "OR OR OR", " "
Scheme 남용"javascript:alert(1)", "file:///etc/passwd", "data:text/html,..."
과대 크기1 MiB 문자열, 65 535 nested parens, 10 000자 URL
제어 바이트CRLF ("\r\n"), null byte ("\x00"), BOM ("")
Unicode 트릭RTL override ("‮"), homoglyph("аpple" 키릴), zero-width("​")
빈 / 공백"", " ", "\t\n"

pytest.mark.parametrize를 사용하고 각 케이스에 라벨을 붙여 실패 메시지가 진단 정보가 되게 하세요.

@pytest.mark.parametrize(
"raw,expected",
[
pytest.param("MIT AND Apache-2.0", ["MIT", "Apache-2.0"], id="happy-path"),
pytest.param("AND", [], id="separator-only-token"),
pytest.param("javascript:alert(1)", [], id="scheme-abuse"),
pytest.param("(" * 10_000 + "MIT" + ")" * 10_000, ["MIT"], id="deep-nesting"),
pytest.param("MIT\r\nApache-2.0", ["MIT", "Apache-2.0"], id="crlf-injection"),
pytest.param("MIT\x00Apache-2.0", ["MIT"], id="null-byte"),
],
)
def test_normalize_spdx_id(raw: str, expected: list[str]) -> None:
assert normalize_spdx_id(raw) == expected

적대적 parametrize는 fuzzing의 대체가 아니라 보완입니다. 이미 알려진 케이스를 회귀 차단하기 위해 parametrize에 의존합니다.

보강 규칙 — 2026-06 검증 캠페인이 가르쳐 준 것​

외부 검증팀이 가이드에서 도출한 1,360케이스를 라이브 포털에 전수 실행해, 우리 unit / functional / e2e가 전부 green인 상태에서 고유 결함 70건을 찾아냈습니다. 사후 분석으로 구조적으로 확인되지 않던 범위를 추적했고, 아래 각 규칙이 그 범위를 하나씩 닫으며 그 근거가 된 결함 클래스를 함께 적습니다. 이 규칙들은 신규 PR에 구속력이 있습니다(CLAUDE.md §2와 동일). 6번부터 8번은 나중에 2026-09 도입 준비도 점검에서 추가됐습니다.

1. 보안 단언은 권한 × 상태 매트릭스로​

"타팀 → 404" 테스트도 있었고 "종결 → 409" 테스트도 있었지만 그 교차점은 없었고, 실제 누출이 정확히 거기 있었습니다(비소속 계정이 타 팀의 종결된 스캔을 찔러 409를 받아 존재가 확인됨). 권한 거부(404 존재 은닉 / 403)는 상태에서 파생된 409보다 항상 먼저여야 합니다. 새 409 표면은 apps/backend/tests/integration/test_existence_hide_state_matrix.py에 케이스를 추가합니다.

이 규칙에는 라우트 수준의 나머지 절반이 있습니다. tests/contracts/permission-matrix.json이 라우트마다 어떤 게이트를 다는지 선언하고, apps/backend/tests/unit/test_permission_baseline.py가 이를 양방향으로 단언합니다. 행이 없는 라우트는 실패하고(아무도 분류하지 않은 표면이 나갔다는 뜻), 라우트가 없는 행도 실패합니다(매트릭스가 낡아 오라클 구실을 못 한다는 뜻). 라우트를 추가하면 행도 추가합니다. 게이트를 바꾸면 픽스처를 바꿔야 하고, 그래야 변경이 API 모듈 여기저기에 흩어지지 않고 검토자 앞에 놓입니다. 역할마다 허용과 거부를 모두 단언합니다. 거부만 단언하면 게이트가 넓어져도 green이고, 모르는 역할은 어디서나 거부되므로 역할이 빠진 상태가 안전한 것처럼 읽힙니다.

2. 같은 어휘가 두 곳에 있으면 정합 테스트가 의무​

닫힌 어휘가 두 곳에 존재하면 — DB enum과 디스패처 카탈로그, emitter와 공표 목록, 백엔드 enum과 프런트엔드 미러 상수 — 모듈별 테스트는 green인 채로 둘이 어긋납니다(알림 kind 드리프트는 승인 트리거가 연결되기 전까지 잠복했습니다). 양쪽을 import해 집합 동등성을 단언합니다: apps/backend/tests/unit/test_catalog_contracts.py가 그 패턴입니다.

3. persist 경계 테스트는 실물 도구 출력 채록 픽스처로​

손으로 만든 최소 픽스처는 지나치게 깨끗합니다. 실제 컨테이너 이미지는 패키지당 CVE 여러 개가 정상이고, 컨테이너 스캔 persist 버그가 정확히 그 밀도에 살았습니다 — 패키지당 CVE 1개짜리 픽스처로는 도달할 수 없었습니다. 실물 도구 출력을 채록하고(tests/fixtures/trivy/), 기대 수치는 픽스처에서 도출해 재채록에도 단언이 깨지지 않게 합니다.

4. 문서가 오라클이다​

70건 중 34건이 가이드-구현 불일치였습니다 — 코드는 코드대로 일관되게 틀려서, 코드에서 파생한 테스트로는 구조적으로 보이지 않습니다. 문서가 약속한 것(상태코드, CLI 명령, 설정 키)마다 docs-uat 단언 또는 가드 테스트를 기능 DoD에 포함합니다.

설정 키에는 이미 가드가 있습니다. apps/backend/tests/unit/test_config_key_contract.py가 코드가 읽는 키와 .env.example, 레퍼런스 문서를 한 목록으로 묶습니다. 새 키는 템플릿이나 문서에 항목이 있어야 하고, 더 이상 읽히지 않는 키는 항목을 빼야 합니다. 그래야 운영자에게 아무 일도 하지 않는 설정을 내놓지 않습니다. 검사가 볼 수 없는 두 가지 형태는 그 파일에 선언합니다. 실행 중에 이름을 조합하는 키와, 읽지 않고 하위 프로세스로 전달만 하는 키입니다.

5. 라이프사이클 시퀀스는 별도 테스트 카테고리​

단일 동작 테스트가 전부 통과하는 동안 폐기 → 재등록은 영구 409였습니다(유니크 제약이 revoked 행까지 셌습니다). 생성 → 폐기 → 재생성, 보관 → 복원 → 사용: 동사 하나하나가 아니라 시퀀스를 테스트합니다.

6. 쓰기를 하는 코드는 그것을 실행하는 테스트를 가진다​

유지보수 태스크 두 개가 아무것도 쓰지 않으면서 성공을 보고했습니다. 행을 메모리에서 고치고 고친 건수를 요약에 담았지만 session.commit()을 부르지 않아, sync_session_scope가 닫히면서 작업이 그대로 버려졌습니다(이 헬퍼는 자동 커밋하지 않고 독스트링에 그렇게 적혀 있습니다). 두 태스크 모두 그것을 실행하는 테스트가 없었고, 태스크를 import만 하거나 태스크가 부르는 헬퍼만 단언하는 테스트로는 이 상태를 볼 수 없습니다.

이 규칙은 처음에 "백그라운드 태스크"라고 적혀 있었고, 그 좁은 문안 때문에 같은 결함을 다른 곳에서 다시 놓쳤습니다. 사용자 익명화 서비스가 flush만 하고 커밋하지 않았는데 get_db도 커밋하지 않아, 네 라우트가 값이 채워진 객체를 응답하면서 아무것도 저장하지 않았습니다. 요청을 열면 진짜 식별자가 담긴 201이 돌아오지만 그 행은 잠시 뒤 사라졌고, 2인 승인 절차가 처음부터 끝까지 무동작이었으며, 운영자 명령은 승인된 요청이 없다며 언제나 거부했고, 기한이 지난 삭제를 보여 주려고 만든 관리자 패널은 영구히 "밀린 것 없음"을 보고했습니다. 그 코드가 가진 테스트는 전부 서비스 함수를 직접 부르고 세션을 스스로 커밋했으므로 하나도 빠짐없이 통과했습니다.

결함이 사는 곳은 태스크가 아닙니다. 커밋되지 않는 쓰기이고, 라우트든 서비스든 스크립트든 태스크든 그것을 품을 수 있습니다. 쓰기를 하는 코드는 운영과 같은 방식으로 그것을 구동하는 테스트를 하나 이상 가지고, 그 뒤에 별도의 읽기로 쓰기가 남았는지 다시 물어봅니다.

7. 새 단언이 지키는 것을 깨뜨려 실패하는지 본다​

통과하는 테스트는 그것이 무엇을 지키는지 말해 주지 않습니다. 도입 준비도 점검에서 아무것도 지키지 않던 단언이 다섯 건 나왔습니다. 문서에 같은 숫자가 다른 뜻으로 있어 통과한 상한 검사, 주석에 이름만 있어도 통과한 호출 검사, 검사를 지워도 스캔이 다른 이유로 실패해 통과한 차단 검사, 이름에 이메일이 그대로 들어 있어도 통과한 형식 검사, 반복문이 담은 마지막 토큰만 읽던 무효화 검사입니다. 문자열 포함 검사, 실패했는지만 묻고 이유를 묻지 않는 단언, 반복문에서 덮어써지는 캡처를 특히 주의합니다. 단언을 쓴 뒤에는 그것이 지키는 것을 깨뜨려 테스트가 빨간불이 되는지 확인합니다.

tools/mutate/mutate.py가 그 일을 안전하게 하는 한 가지 방법이고 필수는 아닙니다. 파일 하나를 고치되 앵커가 없거나, 여러 개거나, 치환해도 파일이 그대로면 거부하고 0이 아닌 코드로 끝납니다. 그래서 &&로 이은 테스트 명령이 실행되지 않습니다. 마지막 거부가 값이 있는 쪽입니다. 앵커가 맞는데도 편집이 아무것도 바꾸지 않을 수 있고, 그때 뒤따르는 초록불이 단언이 약하다는 근거로 읽힙니다. --restore는 백업에서 되돌립니다. git checkout은 그 파일의 커밋하지 않은 작업까지 함께 지웁니다.

이 --restore는 파일을 무조건 덮어쓰지 않고 먼저 확인합니다. 변이를 걸어 둔 채로 그 파일에 실제 수정이 들어오면, 무조건 덮어쓰는 방식은 그 수정을 변이와 함께 아무 표시 없이 지웁니다. --restore는 현재 파일의 해시를 변이 직후에 찍어 둔 해시와 비교합니다. 같으면 예전처럼 백업에서 그대로 복원합니다. 다르면 변이한 문자열을 찾아 그 자리 한 곳만 되돌리고, 그동안 다른 곳에 들어온 편집은 그대로 둡니다. 변이한 문자열이 사라졌거나 두 곳 이상에 나타나면 어느 자리를 되돌릴지 정해지지 않으므로, 짐작하는 대신 거부하고 백업 경로를 알려 줘 손으로 비교하게 합니다.

단언이 실패할 수 없게 되는 방식은 되풀이되고, 자주 나온 여섯 가지에는 이름을 붙여 둘 만합니다.

  • 조작이 대상에 닿지 않은 경우. 아무것도 바뀌지 않은 상태에서 통과합니다. 규칙 8을 참고하십시오.

  • 쓰는 코드와 읽는 코드가 같은 경우. 어떤 구현이든 자기 자신과는 일치합니다.

  • 도구가 아무것도 찾지 못한 경우. 결과만으로는 찾을 것이 없었는지 그곳을 보지 않았는지 구분되지 않습니다. 저장소 린트를 엉뚱한 트리에서 돌리면 열어 보지도 않은 파일에 대해 0건을 보고합니다. 패턴은 내가 상상한 표기만 찾습니다. 손으로 만든 패턴이 눈앞의 것을 놓치는 방식이 둘입니다. gh도 EXPLAIN도 JSON을 돌려주는데 lint (backend) 같은 이름이나 SubPlan 아래 중첩된 노드는 괄호와 중첩을 예상하지 않은 패턴에 걸리지 않습니다. 값 안의 괄호·대괄호·점이 정규식에서는 다른 뜻입니다. 소스에서 세는 것도 같습니다. 테스트 파일의 5요소 튜플 다섯 중 grep은 셋만 찾았고, 놓친 둘은 패턴이 예상하지 못한 모양으로 쓰여 있었으며 그중 하나는 두 번째 헬퍼 안에 있었습니다. 패턴은 내가 상상한 표기를 찾고 파싱은 실제로 있는 것을 찾습니다. 구조를 파싱하거나 정확한 문자열로 비교하십시오. 이 저장소는 이미 여러 곳에서 AST로 단언하니 새로 꺼내는 도구도 아닙니다.

    파싱한 결과를 다시 텍스트로 읽을 수 있습니다. 그래서 파싱은 절반입니다. census가 ast.dump() 출력에서 "alembic"을 큰따옴표로 찾아 0건을 보고했는데 dump는 작은따옴표로 씁니다. 노드를 직접 읽으십시오.

    비교하려고 무엇을 지우는지가 곧 같음의 정의입니다. 결과를 정하는 것은 뒤따르는 비교가 아니라 그 정의입니다. 픽스처 중복을 세며 문자열 상수를 지웠더니 서로 다른 여덟 개가 하나로 묶였습니다. 어느 환경변수인지 어느 팀인지가 그 문자열에 담기기 때문입니다. 데코레이터를 지웠다면 수명이 다른 것들이 하나로 묶였을 것입니다. 정의를 넓히면 표본을 다시 봐야 합니다. 넓히기 전의 표본은 지금 걸리는 것을 덮지 못합니다. 데이터베이스에 닿는 두 번째 경로까지 세도록 넓혔더니 연결을 열지 않는 모듈 83개가 딸려 왔습니다. 세는 기준을 바꿀 때마다 무엇이 걸리는지 다시 읽으십시오. 그 census는 여섯 번 틀렸고 다섯 번이 넓힌 직후였습니다.

    구체적인 오답은 빈 답보다 위험합니다. 아무것도 내지 않는 도구는 아무도 믿지 않지만, "17개 중 13개가 실행되지 않았다"에는 숫자와 목록이 있고 그 세부가 신뢰의 근거가 됩니다. 계산이 된 것처럼 보이는데 틀린 전제 위에서 된 계산입니다. 세는 것이 그런 답에 이르는 흔한 길입니다. 세면 그 수가 무엇들 사이의 관계였는지가 버려지기 때문입니다. 인증서를 마운트한 차트를 렌더했더니 마운트 경로가 넷이었고 그것이 기대한 수였는데, 그중 하나는 뒤에 볼륨이 없어 Kubernetes가 거부할 파드였습니다. 새로 만든 검사가 그럴듯한 답을 내면 잡혀야 하는 입력을 하나 넣어 실제로 잡는지 확인하십시오. 답이 숫자라면 그 숫자가 무엇에 붙어 있는지까지 봅니다.

    반대쪽은 자기가 하지 않는 것을 말하는 도구입니다. tools/static-checks는 CI가 돌리는 것을 돌리고, 일부러 빼는 단계를 이유와 함께 이름으로 적어 둡니다. 검사를 조용히 빼고 성공을 보고하는 로컬 러너가 빠졌다고 밝히는 것보다 나쁘기 때문입니다. 계약 테스트가 그 목록을 워크플로와 대조하는데, 나중에 누가 CI에 단계를 더하면서 그 목록에 등록하지 않았을 때 그것을 알아챈 것이 검토가 아니라 그 테스트였습니다.

    검사기를 시험하는 데에도 같은 것이 있습니다. 심는 입력은 그 검사기가 잡겠다고 선언한 것이어야 합니다. 아니면 "안 걸렸다"가 검사기가 안 도는 것과 그것이 애초에 위반이 아닌 것을 동시에 뜻합니다. 한국어 문체 린트가 새 파일을 읽는지 보려고 어색한 문장을 심었는데 카탈로그에 그 형태의 규칙이 없어 안 걸렸고, 카탈로그에서 실제 규칙을 골라 심으니 바로 걸렸습니다.

  • 사례 하나에서 만든 규칙. 그 사례의 우연한 성질이 규칙 안에 들어가고, 본질적인 것과 구분되지 않습니다. 항목이 하나뿐이던 예외 목록이 "예외 모듈은 데이터베이스 게이트가 없다"를 단언하고 있었는데, 그것은 그 하나가 우연히 그랬던 것입니다. 뒤에 추가된 예외 셋은 자기 게이트를 의도적으로 유지하고, 그 단언은 셋 다에 대해 실패했습니다. 예외의 뜻은 "자기 방식을 유지해도 된다"이지 "게이트가 없다"가 아닙니다. 본질과 우연을 가르는 것은 두 번째 사례이고, 이것은 데이터에서만이 아니라 규칙에서도 그렇습니다.

  • 픽스처가 채우지 않은 필드. 그 필드를 보는 가드는 눈이 먼 상태이고 모든 입력에 성공을 보고합니다.

  • 격리가 잡음이 아니라 조건을 없앤 경우. 파일 하나만 돌리기, 최소 픽스처 쓰기, 의존성을 목으로 대체하기, 함수를 직접 부르기가 모두 조건을 지우는 것이 아니라 바꾸는 것입니다. celery 태스크 등록 검사가 그 파일만 돌리면 실패하고 전체 스위트에서는 통과했는데, 단독 실행의 실패를 "깨끗한 조건에서의 결과"로 읽었습니다. 다른 테스트 모듈이 수행하는 import가 그 단언이 기대던 조건의 일부였습니다. 격리한 실행에서 결론을 내리기 전에, 그 격리가 무엇을 없앴는지 먼저 이름 붙입니다.

    방향이 반대인 경우도 있고, 누적된 상태는 보통 실행을 깨뜨리는 쪽으로 이야기되기 때문에 놓치기 쉽습니다. 통과하게 만드는 쪽일 수도 있습니다. 어떤 초기화 테스트가 갓 마이그레이션한 데이터베이스에서는 실패하고 이전 실행들이 채워 둔 데이터베이스에서는 통과했습니다. 데모 슈퍼관리자를 지우는 것이 "마지막 활성 슈퍼관리자는 지울 수 없다" 트리거에 걸리는데, 다른 슈퍼관리자가 있으면 안 걸리기 때문입니다. 그 데이터베이스에는 무관한 모듈들이 남긴 슈퍼관리자가 155명 있었습니다. 그 의존을 선언한 곳은 없고 스위트가 늘 제공해 주고 있었을 뿐입니다. 누적된 상태에서 통과하는 것은 정확성의 증거가 아니므로 양쪽에서 다 돌려 보고, 한쪽만 통과하면 그것을 답이 아니라 질문으로 다루십시오.

  • 대조 실험을 그것이 답할 수 있는 범위 밖까지 읽은 경우. 같은 테스트를 내 브랜치와 main에서 돌리면 내 변경이 그 결과를 만들었는지만 알 수 있고 무엇이 만들었는지는 알 수 없습니다. 양쪽이 공유하는 것은, 테스트를 부른 방식까지 포함해 대조에 보이지 않습니다. 같은 celery 검사가 두 브랜치에서 똑같이 실패했는데, 그것으로 "내 변경 때문은 아니다"까지 확인해 놓고 "내가 부른 방식 때문도 아니다"까지 읽었습니다. 브랜치 둘이든 환경 둘이든 설정 둘이든 모든 A/B에 같은 한계가 있습니다.

8. 변이가 이유 없이 살아남으면 적용 여부를 먼저 보고, 그다음 두 번째 층을 찾는다​

먼저 변이가 실제로 적용됐는지 확인합니다. 적용되지 않은 변이는 초록불을 근거로 내주고, 거기서 내리는 결론은 사실과 반대입니다. 도입 준비도 점검에서 두 건이 나왔습니다. compose에 블록을 더했는데 중복 키가 되어 파서가 다른 쪽을 채택한 경우와, 대상 행이 이미 쓰려던 값을 담고 있어 UPDATE가 아무것도 바꾸지 않은 경우입니다. 둘 다 가드는 멀쩡한데 "가드가 이걸 못 잡는다"로 읽혔습니다.

변이 자체도 위 목록의 모든 것에 걸립니다. 변이가 시험하려는 그 문자열 매칭에도 걸립니다. 문서 계약이 변수가 페이지에서 사라진 것을 잡는지 보려고 GIT_SSL_CAPATH를 GIT_SSL_CAPATH_REMOVED_FROM_DOCS로 바꿨는데, 원래 이름이 부분 문자열로 남아 포함 검사가 그대로 통과했고 그것이 계약이 약하다는 근거로 읽혔습니다. 텍스트가 바뀌었다가 아니라 변이가 만들려던 상태를 단언하십시오. 여기서는 옛 이름이 없다는 것입니다.

적용됐는지를 묻는 것은 대량 변경 앞에서도 쓸모가 있습니다. 편집이 몇 개 파일에 닿아야 하는지를 먼저 선언하고 수가 어긋나면 아무것도 쓰지 않고 멈추게 하십시오. 아무것도 매칭하지 않은 패턴은 "할 일이 없다"로 보고하고 그것이 "해당 없음"으로 읽힙니다. 193개 모듈 변환에서 이 검사가 세 번 멈췄는데 세 번 다 도구가 아니라 작업자의 계산이었고, 그중 둘은 끝 개행이 없는 목록에 wc -l을 쓴 것입니다. 반대로 변환 자체가 틀린 것을 잡은 적은 없습니다. 어느 쪽 입력이 약한지를 말해 줍니다. 다만 그 수는 도구와 독립적으로 세야 하고, 도구가 세는 방식으로 예상도 세면 늘 일치해 아무것도 확인하지 못합니다. 기준은 크기가 아니라 방식을 따릅니다. 손으로 고친 파일 하나가 전 트리에 돌려 본 변환으로 고친 백 개보다 안전하지 않습니다.

선언한 수는 나와 어긋날 수도 있습니다. 어떤 배치가 하나 줄 것이라 예상했는데 그대로 나왔고, 그 파일이 어느 목록에 속하는지에 대한 모형이 틀렸던 것입니다. 적어 두지 않았으면 맞는 결과가 확인으로 읽혔을 것입니다. 그리고 두 번 바꿨는데 실패가 똑같으면 바꾸고 있는 그것이 변수가 아닙니다. 깨진 docstring을 세 번 고치면서 매번 삽입 위치를 옮겼고 매번 같은 오류가 났는데, 넣던 조각이 docstring을 닫는 따옴표를 달고 있었기 때문입니다.

같은 결과를 낼 수 있는 층이 둘이면 서로를 가립니다. 어느 쪽을 지워도 결과가 같으므로 둘 다 검증되지 않습니다. 반대 경우도 있습니다. 바깥 검사가 안쪽을 도달 불가로 만들고 있으면 겹쳐 보이는 방어가 실은 하나이고, 바깥을 완화하는 순간 안쪽이 조용히 사라집니다. 도입 준비도 점검에서 세 건이 나왔습니다. 매니페스트 사전 점검 때문에 도달할 수 없던 체크섬 검증, 라우터의 조기 거절과 카운터 가드가 서로를 덮던 로그인 잠금, GRANT 층에 가려 한 번도 시험된 적 없던 감사 트리거입니다. 각 층이 실제로 도달되는지와 혼자서도 작동하는지를 따로 단언합니다.

회귀망은 의도적으로 두 겹​

tests/verify-specs/는 검증팀의 결정적 스펙 모듈을 저장소에 포함한 것이고(동봉 PROVENANCE.md 참조) nightly(verify-specs-nightly.yml)가 신선하게 시드된 스택에 전수 실행합니다. 이 nightly는 우리 내부 회귀망입니다 — 검증팀의 독립 Tier-3 재검증을 대체하지 않으며, 후자의 가치는 오라클이 우리 것이 아니라는 데 있습니다.

디자인 게이트 — 색과 픽셀​

리뷰 코멘트로만 지켜지던 규칙 두 가지를 기계가 강제합니다.

디자인 토큰. npm run token:lint는 apps/frontend/src/ 아래의 raw hex와 Tailwind 팔레트 클래스(bg-amber-50, text-emerald-700)에서 실패합니다. 토큰을 쓰세요 — shadcn 시맨틱 계열, finding 심각도는 risk-*, 개체·동작 상태는 status-* 입니다. 디자인 시스템 레퍼런스를 참고하세요.

기존 부채는 scripts/token-lint-baseline.json에 파일별로 동결돼 있고, 게이트는 래칫입니다. 새 위반도, 늘어난 파일도 실패하며, 줄어든 파일도 실패시켜 낮아진 baseline을 커밋하게 합니다. 마지막 방향이 핵심입니다 — 상환했는데 기록하지 않은 예산은 다른 사람이 다시 쓸 수 있는 예산입니다.

apps/frontend/src 안에서 눈에 보이는 원시 색상 클래스가 그것이 허용된다는 증거는 아닙니다. 베이스라인에 올라 있는 항목일 수 있습니다. 옆 파일을 따르기 전에 베이스라인을 확인하십시오. 통과하는 이웃은 허가가 아닙니다를 참고하십시오.

npm run token:lint # 검사
npm run token:lint:update # 부채를 상환한 뒤, 결과를 커밋

시각 baseline. ui-gates.yml은 프런트엔드를 건드리는 모든 PR에서 돌며 픽셀 드리프트를 막습니다. 어떤 화면을 지킬지는 tests/visual/coverage-manifest.ts가 정합니다 — 라우터가 렌더하는 모든 화면이 represented(baseline 보유) 이거나 exempt(사유 명시)로 분류되고, 누락되면 visualCoverage.test.ts가 실패합니다. 화면 집합은 라우트마다 하나가 아니라 레이아웃 템플릿마다 하나로 의도적으로 제한합니다 — baseline 한 장은 유지보수 부채이고, 흔들리는 diff가 쌓이면 리뷰어는 빨간 표시를 넘겨보게 됩니다.

의도한 UI 변경 후에는 로컬이 아니라 CI에서 baseline을 다시 촬영하세요. macOS 폰트 힌팅은 리눅스 러너와 텍스트 밀집 화면에서 5~20 % 차이가 납니다.

gh workflow run ui-gates.yml --ref <branch> -f update_baselines=true
# 이후 `visual-baselines` 아티팩트를 내려받아 PNG 를 커밋합니다

skip 라벨은 의도적으로 두지 않습니다. 라벨이 필요할 만큼 변동이 큰 것(상대 시각 표시, dev 서버 전용 devtools 런처)은 spec에서 마스킹하거나 숨깁니다.

접근성. 같은 워크플로가 같은 화면들에 axe-core(WCAG 2.1 A/AA)를 돌립니다. 실제 브라우저가 필요한 이유는 color-contrast 입니다 — axe는 jsdom에서 이 규칙을 평가하지 못하며, 그래서 기존 badgeContrast.test.tsx는 컴포넌트 하나의 대비를 손으로 계산합니다.

토큰 린트와 같은 래칫이고 0을 요구하지 않습니다. 이 앱은 한 번도 스캔된 적이 없었고, 충족 불가능한 게이트는 결국 꺼집니다. 위반은 tests/a11y/a11y-baseline.json에 화면·규칙별로 동결되며 수치는 줄어들기만 합니다. 매 실행이 관측 결과(건수와 위반 선택자)를 a11y-observed 아티팩트로 게시하므로, 실패했을 때 어디를 봐야 할지 다시 파헤칠 필요가 없습니다.

갱신 방법은 시각 baseline과 같습니다 — update_baselines dispatch가 둘 다 처리합니다. 픽셀과 규칙 건수는 같은 이유로 함께 움직이기 때문입니다.

두 게이트는 화면 목록 하나(tests/_harness/screenIds.ts)를 공유하므로 커버 범위에 대해 서로 어긋날 수 없습니다.

Coverage 게이트 상세​

머지 게이트는 .github/workflows/ci.yml에서 강제됩니다.

  • Unit + integration 합산: 전체 line coverage ≥ 80 %, 그리고 PR이 바꾼 라인 기준 ≥ 80 %. 두 스위트는 별도 잡으로 돌고 coverage-gate (backend)가 합친 뒤에 판정합니다. 한쪽 잡만 놓고 보는 숫자는 뜻이 없기 때문입니다.
  • E2E (Playwright): PR 게이트에 포함되지 않습니다. 이 스위트는 나이틀리 일정과 수동 workflow_dispatch에서만 실행되므로 PR을 올려도 돌지 않습니다. 핵심 시나리오는 apps/frontend/tests/e2e/_core/에 있고 해당 기능과 함께 추가합니다. 사용자에게 보이는 흐름을 바꿨다면 직접 실행하거나 메인테이너에게 워크플로 실행을 요청하세요.
  • 디자인 토큰: 위의 token:lint 래칫.
  • 시각·접근성: 위의 ui-gates.yml.

합쳐진 coverage.xml은 실행에 backend-coverage 아티팩트로 첨부되고, 빠진 라인은 coverage-gate 로그에 나옵니다. diff coverage 실패는 apps/backend에서 diff-cover coverage.xml --compare-branch=origin/main으로 로컬에서 재현할 수 있습니다.

함께 보기​