Rust 실전 강좌 #6 테스트: 파서 단위 테스트에서 CLI 통합 테스트까지

4 분 소요

파서(3편), 집계(4편), 병렬화(5편)까지 기능이 쌓였으니, 다음 편의 대수술(릴리스 최적화, 크로스 컴파일) 전에 안전망을 두를 차례입니다. Rust는 테스트 러너가 언어에 내장되어 있어서 프레임워크 선택부터 시작할 필요가 없습니다. cargo test 하나로 시작합니다.

단위 테스트: 코드 옆에 두는 검증 #

Rust의 단위 테스트는 검증 대상과 같은 파일에 삽니다. 파서 모듈 아래에 붙입니다.

src/parser.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_normal_line() {
        let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512"#;
        let entry = parse_line(line).unwrap();
        assert_eq!(entry.ip, "203.0.113.9");
        assert_eq!(entry.method, "GET");
        assert_eq!(entry.path, "/api/users");
        assert_eq!(entry.status, 200);
        assert_eq!(entry.bytes, 512);
    }

    #[test]
    fn dash_bytes_becomes_zero() {
        let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "HEAD / HTTP/1.1" 301 -"#;
        assert_eq!(parse_line(line).unwrap().bytes, 0);
    }

    #[test]
    fn empty_line_is_empty_error() {
        assert!(matches!(parse_line("   "), Err(ParseError::Empty)));
    }

    #[test]
    fn garbage_is_rejected() {
        assert!(parse_line("완전히 다른 형식의 라인").is_err());
    }
}

#[cfg(test)]는 이 모듈을 cargo test일 때만 컴파일하라는 조건부 컴파일 지시라서, 배포 바이너리에 테스트 코드가 들어가지 않습니다. 케이스 선정이 테스트의 본론입니다. 정상 라인 하나, 3편에서 “관대하게 받겠다"고 결정한 - 바이트, “실패지만 실패로 안 센다"고 결정한 빈 라인, 그리고 완전한 쓰레기 입력. 파서를 만들며 내린 설계 결정 하나하나가 테스트 케이스 하나하나로 문서화되는 것입니다. 에러 종류까지 확인할 때는 matches! 매크로가 Err(ParseError::Empty) 같은 패턴을 그대로 검사해 줍니다(기초 5편의 패턴 매칭이 테스트에서도 일합니다). 3편에서 LogEntryPartialEq를 파생해 둔 것이 assert_eq!를 위한 준비였다는 복선도 여기서 회수됩니다.

테스트에서는 unwrap이 정당하다는 것(기초 6편)도 다시 확인해 둡니다. 테스트에서 실패는 곧 테스트 실패이고, 그것이 정확히 원하는 동작입니다.

통합 테스트: 사용자의 자리에서 실행하기 #

단위 테스트가 부품 검사라면, 통합 테스트는 완성품 검사입니다. 프로젝트 루트의 tests/ 디렉터리에 두는 파일은 외부 사용자와 같은 조건으로 크레이트를 사용하는 별도 바이너리로 컴파일됩니다. CLI 도구의 완성품 검사는 “실제로 실행해서 출력을 본다"이고, 그 도구가 assert_cmd입니다.

설치
cargo add --dev assert_cmd predicates tempfile

--dev는 개발·테스트에서만 쓰는 의존성이라는 표시로, 배포 바이너리와 무관합니다.

tests/cli.rs
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use std::io::Write;

#[test]
fn stats_counts_lines() {
    let mut log = tempfile::NamedTempFile::new().unwrap();
    writeln!(log, r#"203.0.113.9 - - [t] "GET /a HTTP/1.1" 200 10"#).unwrap();
    writeln!(log, r#"203.0.113.9 - - [t] "GET /b HTTP/1.1" 500 20"#).unwrap();
    writeln!(log, "깨진 라인").unwrap();

    Command::cargo_bin("loglens").unwrap()
        .args(["stats", log.path().to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("파싱 성공:  2"))
        .stdout(predicate::str::contains("파싱 실패:  1"));
}

#[test]
fn missing_file_fails_with_context() {
    Command::cargo_bin("loglens").unwrap()
        .args(["stats", "no-such-file.log"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("로그 파일을 열 수 없습니다"));
}

Command::cargo_bin("loglens")이 방금 빌드된 실제 바이너리를 찾아 실행하고, assert() 이후의 체인이 종료 코드(success/failure)와 출력(predicates의 문자열 조건)을 검증합니다. 두 번째 테스트가 특히 중요합니다. 2편에서 설계한 에러 메시지(“무엇을 하려다 실패했는지” + 종료 코드 1)가 사용자에게 실제로 도달하는지를 검증하는 것으로, 에러 처리도 기능이며 기능에는 테스트가 붙는다는 원칙의 실천입니다. 테스트 데이터는 tempfile이 만드는 임시 파일에 쓰고, 파일은 테스트가 끝나면 자동 삭제됩니다(기초 3편의 drop이 여기서도 일하고 있습니다).

cargo test가 병렬이라는 사실 #

실행
cargo test

한 줄로 단위·통합 테스트가 전부 돌고, 기본적으로 여러 스레드에서 병렬 실행됩니다. 테스트가 빨라지는 대신 규칙이 하나 생깁니다. 테스트끼리 같은 파일 경로나 전역 상태를 공유하면 순서에 따라 결과가 흔들립니다. 위에서 고정 경로 대신 tempfile을 쓴 것은 정리 자동화 때문만이 아니라, 테스트마다 독립된 파일을 주어 병렬 실행에서도 서로 간섭하지 않게 하려는 목적이 큽니다. 4편에서 “HashMap 출력은 정렬해서 안정화한다"고 한 것도 여기서 효력을 냅니다. 출력 순서가 실행마다 바뀌는 도구는 통합 테스트를 쓸 수 없습니다.

정리 #

  • 단위 테스트는 #[cfg(test)]로 코드 옆에 둡니다. 파서의 설계 결정(관대한 -, 실패로 안 세는 빈 라인)이 그대로 테스트 케이스가 됩니다.
  • 에러 종류 검증은 matches!, 구조체 비교는 PartialEq 파생 + assert_eq!가 기본 장비입니다.
  • 통합 테스트는 tests/ 디렉터리에서 완성품을 검사합니다. assert_cmd로 실제 바이너리를 실행해 종료 코드와 출력을 검증합니다.
  • 에러 메시지도 기능입니다. “파일 없음"의 메시지와 종료 코드까지 테스트가 지킵니다.
  • cargo test는 병렬 실행이 기본입니다. tempfile로 테스트별 독립 파일을 쓰고, 도구의 출력을 정렬로 안정화해 두는 것이 그 전제입니다.
  • 다음 편은 배포 준비의 전반부입니다. release 프로필을 조여 바이너리를 최적화하고, 다른 OS를 위한 크로스 컴파일까지 다루겠습니다.
X