Claw Code 깊이 읽기 #7 — 테스팅 전략과 패리티 추적

조현상·2026년 4월 2일

ClaudeCode

목록 보기
7/17
post-thumbnail

Claude Code 클론 프로젝트 Claw Code의 소스를 한 줄씩 읽으며, 아키텍처 결정의 "왜"를 추적하는 시리즈입니다.


들어가며: Python과 Rust를 동시에 테스트한다는 것

Claw Code는 이중 언어 프로젝트다. Python 참조 구현과 Rust 프로덕션 구현이 공존하며, 두 세계가 같은 방향을 향하고 있는지 끊임없이 확인해야 한다. 일반적인 프로젝트에서는 "테스트를 작성한다"가 품질 보증의 전부지만, 여기서는 한 가지 질문이 더 추가된다 — "원본 TypeScript와 얼마나 같아졌는가?"

이번 편에서는 Python 26개 통합 테스트의 subprocess 패턴, Rust 12개 통합 테스트의 Mock 서버 아키텍처, 그리고 패리티 감사 시스템의 메트릭 추적까지 분석한다. 테스트 코드가 "무엇을 검증하는가"뿐 아니라 "왜 이런 방식으로 검증하는가"에 초점을 맞춘다.


1. Python 테스트: subprocess가 유일한 진실

1.1 왜 subprocess인가

tests/test_porting_workspace.py에는 26개의 테스트가 있다. 그런데 모든 통합 테스트가 같은 패턴을 따른다:

result = subprocess.run(
    [sys.executable, '-m', 'src.main', 'summary'],
    check=True,
    capture_output=True,
    text=True,
)
self.assertIn('Python Porting Workspace Summary', result.stdout)

mock도 없고, fixture도 없고, setUp/tearDown도 없다. 왜 이렇게 "원시적인" 방식을 선택했을까?

답은 "CLI가 제품이기 때문"이다. 이 Python 워크스페이스의 최종 인터페이스는 python3 -m src.main <command>다. 내부 함수를 직접 호출하는 단위 테스트는 "함수가 작동한다"를 증명하지만, "사용자가 CLI를 실행했을 때 올바른 결과가 나온다"를 증명하지 못한다. subprocess 테스트는 import 경로, argparse 파싱, 모듈 초기화, 출력 포맷팅까지 전 과정을 검증하는 진정한 엔드-투-엔드 테스트다.

sys.executable을 사용하는 점도 중요하다. 'python3'을 하드코딩하면 가상 환경에서 다른 인터프리터가 실행될 수 있다. sys.executable은 현재 테스트를 실행 중인 바로 그 Python을 사용하도록 보장한다.

1.2 26개 테스트의 전략적 분류

테스트들을 목적별로 분류하면 명확한 전략이 보인다:

구조 검증 (3개) — "프로젝트가 존재하는가?"

테스트어설션의미
test_manifest_counts_python_files>= 20개 Python 파일프로젝트 규모 최소 기준
test_subsystem_packages_expose_archive_metadataMODULE_COUNT > 0서브시스템 메타데이터 존재
test_command_and_tool_snapshots_are_nontrivial>= 150 커맨드, >= 100 도구스냅샷 충분성

이 테스트들은 "코드가 올바른가"가 아니라 "데이터가 충분한가"를 검증한다. 스냅샷 파일이 손상되거나 누군가 실수로 삭제했을 때 즉시 탐지된다.

CLI 실행 검증 (10개+) — "명령이 죽지 않는가?"

# 대표적 패턴
def test_cli_summary_runs(self):
    result = subprocess.run(
        [sys.executable, '-m', 'src.main', 'summary'],
        check=True, capture_output=True, text=True,
    )
    self.assertIn('Python Porting Workspace Summary', result.stdout)

check=True가 핵심이다. subprocess가 비정상 종료하면 CalledProcessError가 발생하고 테스트가 실패한다. 즉, "이 CLI 커맨드가 크래시 없이 실행된다"는 것 자체가 어설션이다. 그 위에 assertIn으로 출력 형식까지 확인한다.

세션 관리 검증 (3개) — "상태가 보존되는가?"

def test_load_session_cli_runs(self):
    # 1. 부트스트랩으로 세션 생성
    bootstrap_result = subprocess.run(
        [sys.executable, '-m', 'src.main', 'bootstrap', 'review MCP tool'],
        check=True, capture_output=True, text=True,
    )
    # 2. 세션 ID 추출
    session_id = extract_session_id(bootstrap_result.stdout)
    # 3. 세션 로드
    load_result = subprocess.run(
        [sys.executable, '-m', 'src.main', 'load-session', session_id],
        check=True, capture_output=True, text=True,
    )
    self.assertIn(session_id, load_result.stdout)

세션 생성 → ID 추출 → 세션 복원의 3단계가 하나의 테스트에서 실행된다. 이는 통합 시나리오 테스트로, 개별 함수 테스트로는 잡을 수 없는 직렬화/역직렬화 경계의 버그를 탐지한다.

권한 필터링 검증 (2개) — "차단이 작동하는가?"

def test_tool_permission_filtering_cli_runs(self):
    result = subprocess.run(
        [sys.executable, '-m', 'src.main', 'tools', '--limit', '10', '--deny-prefix', 'mcp'],
        check=True, capture_output=True, text=True,
    )
    self.assertIn('Tool entries:', result.stdout)
    self.assertNotIn('MCPTool', result.stdout)   # 핵심: 차단 확인

assertNotIn이 등장하는 유일한 테스트다. "있어야 할 것이 있다"보다 "없어야 할 것이 없다"를 검증하는 것이 더 어렵고 더 중요하다. 권한 시스템의 본질은 차단이기 때문이다.

1.3 조건부 어설션: 아카이브 가용성

def test_root_file_coverage_is_complete_when_local_archive_exists(self):
    audit = run_parity_audit()
    if audit.archive_present:
        self.assertEqual(audit.root_file_coverage[0], 18)
        self.assertGreaterEqual(audit.directory_coverage[0], 28)

if audit.archive_present — 아카이브가 로컬에 없으면 테스트를 건너뛴다. @unittest.skipIf가 아니라 조건부 어설션인 이유는, 아카이브 유무 자체는 테스트 실패가 아니기 때문이다. CI 환경에서 아카이브 없이도 테스트 스위트가 통과해야 하고, 로컬 개발 환경에서는 아카이브가 있을 때 더 엄격한 검증을 수행한다.


2. Rust 테스트: 수제 Mock 서버의 위력

2.1 Mock 서버 아키텍처

Rust 쪽은 client_integration.rs, openai_compat_integration.rs, provider_client_integration.rs 3개 파일에 12개 테스트가 있다. 모든 테스트의 기반이 되는 것은 수제(hand-crafted) Mock TCP 서버다:

fn spawn_server(responses: Vec<String>)
    -> (String, JoinHandle<()>, Arc<Mutex<Vec<CapturedRequest>>>)
{
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let port = listener.local_addr()?.port();
    // ...
}

127.0.0.1:0에 바인드하면 OS가 사용 가능한 포트를 자동 할당한다. 이 패턴은 테스트 간 포트 충돌을 원천 방지한다. 반환되는 3-튜플의 각 요소도 의미 깊다:

  • String: 서버 URL (http://127.0.0.1:{port})
  • JoinHandle: 서버 태스크 핸들 (정리용)
  • Arc<Mutex<Vec<CapturedRequest>>>: 캡처된 요청 (검증용)

CapturedRequest에 method, path, headers(HashMap), body가 저장되므로 테스트에서 "서버가 어떤 요청을 받았는가"를 사후 검증할 수 있다.

2.2 wiremock이 아닌 수제 파서를 쓴 이유

HTTP 요청을 직접 파싱한다:

fn find_header_end(buffer: &[u8]) -> Option<usize> {
    // \r\n\r\n 패턴 탐색
}

wiremock, mockall 같은 프레임워크를 쓸 수 있었는데 왜 직접 구현했을까? 이유는 SSE 스트리밍 테스트 때문이다. SSE 응답은 Content-Type: text/event-stream으로 길게 이어지는 데이터인데, 대부분의 mock 프레임워크는 이런 응답 패턴을 깔끔하게 지원하지 않는다. 직접 TCP 소켓에 바이트를 쓰면 SSE 프레임의 정확한 바이트 시퀀스를 제어할 수 있다.

2.3 4가지 핵심 테스트 시나리오

시나리오 1: JSON 요청/응답 라운드트립

클라이언트 → POST /v1/messages (stream: false)
            → x-api-key: test_key
            → Authorization: Bearer token
서버 ← 200 OK + MessageResponse JSON
검증: response.total_tokens() == input + output

API 클라이언트가 올바른 헤더와 바디를 보내고, 응답을 올바르게 역직렬화하는지 확인한다. total_tokens() 메서드가 input_tokens + output_tokens를 정확히 합산하는지까지 검증한다.

시나리오 2: SSE 스트리밍과 도구 사용

서버 → 6개 SSE 이벤트 순차 전송:
  message_start → content_block_start → content_block_delta
  → content_block_stop → message_delta → message_stop

검증:
  - 이벤트 6개 파싱 성공
  - tool_use 블록: name="get_weather", input={"city": "Paris"}
  - stream: true 헤더 전송 확인

이것이 수제 Mock 서버의 진가다. 6개 SSE 이벤트의 정확한 바이트 시퀀스를 제어하면서, 클라이언트의 SseParser가 각 이벤트를 올바른 타입으로 파싱하는지 검증한다. 5편에서 분석한 SseParserwindows() + drain() 패턴이 실제로 작동하는지를 증명하는 테스트다.

시나리오 3: 재시도 성공

1차 요청 → 429 Too Many Requests
2차 요청 → 200 OK
정책: max_retries=2, initial_backoff=1ms
검증: 최종 성공, 총 요청 2회

initial_backoff=1ms가 눈에 띈다. 테스트에서 실제로 지수 백오프를 기다리면 테스트 시간이 길어진다. 1ms로 설정해 재시도 로직만 검증하고 대기 시간은 최소화한다.

시나리오 4: 재시도 소진

1차 요청 → 503 Service Unavailable
2차 요청 → 503 Service Unavailable
정책: max_retries=1
검증: Err(ApiError::RetriesExhausted), attempts=2

시나리오 3과 짝을 이루는 실패 경로 테스트다. 성공 경로만 테스트하면 에러 핸들링 코드가 죽은 코드가 된다. attempts=2까지 검증해 "정말로 1번 재시도했는가"를 확인한다.

2.4 OpenAI 호환 테스트와 Provider 라우팅

openai_compat_integration.rs에는 OpenAI 호환 엔드포인트 테스트 4개가 있다:

// Grok 모델이 XAI 프로바이더로 라우팅되는지 확인
fn provider_client_routes_grok_aliases_through_xai() {
    // "grok-mini" → Xai provider resolution
}

// XAI 인증 정보 누락 시 에러
fn provider_client_reports_missing_xai_credentials_for_grok_models() {
    // Returns ApiError::MissingCredentials
}

환경 변수 기반 프로바이더 선택에서 Drop 가드 패턴으로 테스트 격리를 보장한다:

let _guard = env_lock();  // 글로벌 뮤텍스 획득
std::env::set_var("XAI_API_KEY", "test_key");
// 테스트 코드
drop(_guard);  // 원래 환경 복원

환경 변수는 프로세스 전역이므로 병렬 테스트에서 경쟁 조건이 발생할 수 있다. 뮤텍스 가드로 환경 변수 접근을 직렬화하는 것이다.


3. 패리티 감사 시스템: 포팅 진행률의 정량화

3.1 문제 정의

Claw Code의 목표는 TypeScript Claude Code를 Python + Rust로 재구현하는 것이다. 그런데 "얼마나 완성되었는가"를 어떻게 측정할까? parity_audit.py가 이 질문에 답한다.

3.2 아카이브 표면 스냅샷

비교의 기준선은 archive_surface_snapshot.json이다:

{
  "archive_location": "archive/claw_code_ts_snapshot/src",
  "root_files_count": 18,
  "root_directories_count": 35,
  "total_ts_like_files": 1902,
  "command_entries": 207,
  "tool_entries": 184
}

원본 TypeScript 코드베이스의 "표면적"을 숫자로 요약한 것이다. 1,902개의 TS 파일, 207개 커맨드, 184개 도구. 이것이 100% 패리티의 정의다.

3.3 이중 매핑 전략

패리티를 추적하는 방법은 두 가지 매핑 테이블이다:

루트 파일 매핑 (18개) — TypeScript 파일과 Python 파일의 1:1 대응:

QueryEngine.ts  → QueryEngine.py
commands.ts     → commands.py
context.ts      → context.py
main.tsx        → main.py
setup.ts        → setup.py
tools.ts        → tools.py
...

디렉토리 매핑 (36개) — 디렉토리 구조의 대응:

assistant   → assistant     (이름 보존)
bootstrap   → bootstrap     (이름 보존)
native-ts   → native_ts     (Python 네이밍 컨벤션으로 변환)
commands    → commands.py   (디렉토리 → 모듈 축약)
...

native-ts → native_ts 변환이 흥미롭다. TypeScript의 케밥 케이스가 Python의 스네이크 케이스로 자동 매핑된다. 이런 네이밍 컨벤션 차이를 매핑 테이블에 명시적으로 기록해두면, 나중에 "이 디렉토리의 Python 대응물이 뭐지?"라는 질문에 즉시 답할 수 있다.

3.4 ParityAuditResult: 불변 감사 결과

@dataclass(frozen=True)
class ParityAuditResult:
    archive_present: bool
    root_file_coverage: tuple[int, int]       # (매칭됨, 전체)
    directory_coverage: tuple[int, int]       # (매칭됨, 전체)
    total_file_ratio: tuple[int, int]         # (Python 파일, TS 파일)
    command_entry_ratio: tuple[int, int]      # (현재 커맨드, 아카이브 커맨드)
    tool_entry_ratio: tuple[int, int]         # (현재 도구, 아카이브 도구)
    missing_root_targets: tuple[str, ...]     # 누락된 루트 파일
    missing_directory_targets: tuple[str, ...]  # 누락된 디렉토리

모든 필드가 tuple이다. list가 아니라 tuple인 이유는 6편에서 분석한 것과 같다 — 감사 결과는 불변이어야 한다. 감사 시점의 스냅샷이 나중에 변경되면 추적의 의미가 없다.

missing_root_targetsmissing_directory_targets행동 가능한(actionable) 데이터다. "커버리지가 77%입니다"보다 "이 8개 디렉토리가 누락되었습니다"가 개발자에게 더 유용하다.

3.5 감사 흐름과 메트릭 계산

1. archive_surface_snapshot.json 로드
   ↓
2. src/ 디렉토리 스캔 (현재 Python 파일 목록)
   ↓
3. 루트 파일 매핑 비교 → root_file_coverage
   ↓
4. 디렉토리 매핑 비교 → directory_coverage
   ↓
5. 스냅샷 카운팅 (207 커맨드, 184 도구)
   ↓
6. 비율 계산 및 누락 항목 식별
   ↓
7. ParityAuditResult 반환 → 마크다운 리포트 생성

3.6 패리티 목표와 테스트 임계값

테스트에서 검증하는 임계값은 현재 달성된 수준의 회귀 방지다:

메트릭임계값의미
루트 파일 커버리지18/18 (100%)모든 핵심 파일 매핑 완료
디렉토리 커버리지≥ 28/36 (77.8%)주요 디렉토리 매핑
커맨드 항목≥ 150/207 (72.5%)커맨드 메타데이터
도구 항목≥ 100/184 (54.3%)도구 메타데이터

이 숫자들은 "목표"가 아니라 "바닥"이다. 한 번 달성한 수준 아래로 떨어지면 안 된다는 회귀 방지 기준이다. 새로운 커맨드를 포팅할 때마다 임계값을 올려가는 방식으로 진행률을 단조 증가시킨다.


4. Python과 Rust 테스트의 대칭과 비대칭

두 테스트 스위트를 나란히 놓으면 흥미로운 패턴이 보인다:

측면Python (26개)Rust (12개)
프레임워크unittesttokio::test
실행 방식subprocess CLImock TCP 서버
mock 수준없음 (실제 CLI)수제 HTTP 서버
검증 방식문자열 포함 확인타입 매칭 + 숫자 동등
대상 계층CLI → 전체 스택API 클라이언트 계층
fixture없음spawn_server + 환경 가드

비대칭의 이유: Python 테스트는 외부에서 안으로 검증한다. CLI 명령이 올바른 출력을 내는가? Rust 테스트는 내부에서 밖으로 검증한다. API 클라이언트가 올바른 HTTP 요청을 보내고 응답을 파싱하는가?

이 차이는 두 코드베이스의 역할 차이와 일치한다. Python은 참조 구현(사용자 인터페이스가 CLI), Rust는 프로덕션 라이브러리(소비자가 다른 크레이트). 테스트 전략이 제품의 성격을 반영하는 것이다.


5. 보조 그래프와 시각화 도구

5.1 CommandGraph: 3가지 분류

@dataclass(frozen=True)
class CommandGraph:
    builtins: tuple[PortingModule, ...]      # 기본 내장
    plugin_like: tuple[PortingModule, ...]   # source_hint에 'plugin' 포함
    skill_like: tuple[PortingModule, ...]    # source_hint에 'skills' 포함

207개 커맨드를 source_hint의 경로 패턴으로 3가지로 분류한다. 이 분류는 포팅 우선순위를 결정하는 데 사용된다 — builtins를 먼저 포팅하고, plugin/skill은 나중에 처리하는 식이다.

5.2 BootstrapGraph: 7단계 시퀀스

stages = (
    "top-level prefetch side effects",
    "warning handler and environment guards",
    "CLI parser and pre-action trust gate",
    "setup() + commands/agents parallel load",
    "deferred init after trust",
    "mode routing: local / remote / ssh / teleport / direct-connect / deep-link",
    "query engine submit loop",
)

Rust 쪽의 12단계 부트스트랩을 7단계로 압축한 것이다. "prefetch → guards → parser → setup → deferred init → mode routing → query loop" 순서는 의존 관계의 위상 정렬이다. 프리페치(비동기 사이드 이펙트)가 가장 먼저 시작해야 나중 단계와 병렬 실행이 가능하다.


6. CI/CD의 부재와 그 의미

놀랍게도 .github/workflows/ 디렉토리에 CI 파이프라인이 없다. 테스트 실행은 전적으로 로컬 개발자에게 맡겨진다:

# Python 테스트
python3 -m unittest discover -s tests -v

# Rust 테스트
cd rust && cargo fmt && cargo clippy --workspace -- -D warnings && cargo test --workspace

이것이 의미하는 바는 두 가지다. 첫째, 아직 초기 개발 단계여서 CI 설정보다 기능 구현이 우선이다. 둘째, 테스트가 subprocess 기반이라 환경 의존성이 낮다 — 특별한 CI 설정 없이도 python3 -m unittest만으로 전체 스위트를 실행할 수 있다. subprocess 패턴의 또 다른 장점이다.

다만 Rust 쪽의 cargo clippy -- -D warnings(경고를 에러로 취급)는 CI에서 게이트로 사용될 준비가 되어 있다. unsafe_code = "forbid"와 함께 이미 엄격한 린팅 기준이 설정되어 있으므로, CI 파이프라인 추가는 시간 문제일 것이다.


7. 테스트 전략에서 배우는 설계 원칙

이번 편의 분석에서 추출할 수 있는 테스트 설계 원칙을 정리하면:

원칙 1: 제품 경계에서 테스트하라. Python은 CLI에서, Rust는 API 클라이언트에서. 내부 구현이 아니라 외부 계약을 테스트하면 리팩토링에 강한 테스트가 된다. assertIn('Python Porting Workspace Summary', result.stdout)는 출력 형식이 바뀌지 않는 한 내부가 어떻게 변해도 통과한다.

원칙 2: mock은 필요할 때만. Python 테스트는 mock이 전혀 없고, Rust 테스트는 HTTP 서버만 mock한다. 외부 의존성(네트워크)만 대체하고 나머지는 실제 코드를 실행한다. 과도한 mock은 "mock이 올바른지 테스트하는" 상황을 만든다.

원칙 3: 실패 경로도 테스트하라. Rust의 재시도 소진 테스트, Python의 assertNotIn 권한 테스트. 성공 경로만 테스트하면 에러 핸들링 코드는 한 번도 실행되지 않는 죽은 코드가 된다.

원칙 4: 임계값은 바닥이다. 패리티 감사의 >= 150 커맨드, >= 100 도구 기준은 "최소 이만큼은 유지하라"는 회귀 방지 장치다. 진행률이 올라가면 임계값도 올린다. 래칫(ratchet) 패턴이다.

원칙 5: 감사는 자동화하라. 수동으로 "우리 몇 퍼센트 완성했지?" 확인하는 것은 지속 불가능하다. python3 -m src.main parity-audit 한 줄로 정량적 진행률을 확인할 수 있어야 한다.


실습 가이드

# Python 전체 테스트 실행
python3 -m unittest discover -s tests -v

# Rust 전체 테스트 (린팅 포함)
cd rust && cargo fmt --check && cargo clippy --workspace -- -D warnings && cargo test --workspace

# 패리티 감사 보고서
python3 -m src.main parity-audit

# 커맨드 그래프 (builtin/plugin/skill 분류)
python3 -m src.main command-graph

# 부트스트랩 그래프 (7단계 시퀀스)
python3 -m src.main bootstrap-graph

# 누락 항목으로 포팅 우선순위 확인
python3 -m src.main parity-audit | grep "Missing"

시리즈 목차
1편: 프로젝트 배경과 아키텍처 오버뷰
2편: Rust 워크스페이스와 크레이트 구조
3편: API 통신과 SSE 스트리밍
4편: 대화 런타임과 세션 관리
5편: 도구 시스템과 권한 모델
6편: Python 포팅 워크스페이스 분석
7편: 테스팅 전략과 패리티 추적 ← 현재 글
8편: TUI 개선과 미래 로드맵

#ClawCode #ClaudeCode #Testing #ParityTracking #Rust #Python #코드분석 #테스트전략

profile
꿈꾸는 개발자

0개의 댓글