본문으로 건너뛰기

안동민 개발노트

본문 시작

패닉과 Result 테스트

should_panic과 Result 반환 테스트로 실패 조건과 오류 메시지를 검증합니다.

should_panic 속성으로 패닉 발생 검사하기

코드의 반환 값을 검사하는 것에 더하여, 예상한대로 에러 조건을 잘 처리하는지 검사하는 것도 중요합니다.

예를 들어 8장의 예제 8-13에서 만들었던 Guess 타입을 생각해보세요.

Guess 타입을 사용하는 다른 코드는 Guess 인스턴스가 1에서 100 사잇값임을 보장하는 기능에 의존합니다.

이런 경우, 범위를 벗어난 값으로 Guess 인스턴스를 만들면 패닉이 발생하는지 검사하는 테스트를 작성하면 이를 확실하게 보장할 수 있습니다.

패닉 검사 테스트 함수에는 should_panic 속성을 추가합니다.

이 테스트는 내부에서 패닉이 발생해야 통과되고, 패닉이 발생하지 않으면 실패합니다.

예제 10-8은 Guess::new의 에러 조건이 의도대로 작동하는지 검사하는 테스트를 보여줍니다.

예제 10-8: panic! 발생 테스트
src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

#[should_panic] 속성은 #[test] 속성과 적용할 함수 사이에 위치시켰습니다.

테스트 성공 시 결과를 살펴봅시다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo와 테스트 실행기의 출력 형식, 실행 파일 해시, 파일 경로는 도구체인 버전과 환경에 따라 달라질 수 있습니다. 아래 출력 예시들도 같은 원칙으로 읽으세요.

괜찮아 보이네요!

이제 new 함수의 패닉 발생 조건 중 100보다 큰 값일 때의 조건을 지워서 버그를 만들어 보죠.

// --생략--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

예제 10-8 테스트를 실행하면 다음과 같이 실패합니다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures.

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures.
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

에러 메시지는 그다지 유용하지 않지만, 테스트 함수를 살펴보면 #[should_panic]으로 어노테이션된 함수라는 걸 알 수 있습니다.

즉, 테스트 함수에서 패닉이 발생하지 않아서 실패했다는 뜻이죠.

should_panic을 사용하는 테스트는 정확하지 않을 수 있습니다.

의도한 것과는 다른 이유로 패닉이 발생하더라도 should_panic 테스트는 통과할 것입니다.

should_panic 속성에 expected 매개변수를 추가해, 포함되어야 하는 실패 메시지를 지정하면 더 꼼꼼한 should_panic 테스트를 작성할 수 있습니다.

예제 10-9는 new 함수에서 값이 너무 작은 경우와 큰 경우에 서로 다른 메시지로 panic!을 발생시키도록 수정한 Guess 코드입니다.

예제 10-9: 특정한 부분 문자열을 포함하는 패닉 메시지를 사용한 panic!에 대한 테스트
src/lib.rs
// --생략--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

should_panic 속성의 expected 매개변숫값이 Guess::new 함수에서 발생한 패닉 메시지 문자열의 일부이므로 테스트는 통과합니다.

발생해야 하는 패닉 메시지 전체를 명시할 수도 있습니다.

이 경우 Guess value must be less than or equal to 100, got 200.이 되겠죠.

expected 매개변수에 명시할 내용은 패닉 메시지가 얼마나 고유한지 혹은 동적인지, 그리고 테스트에 요구되는 정확성에 따라 달라집니다.

이번 경우에는, 패닉 메시지 문자열 일부만으로도 실행된 함수 코드가 else if value > 100 상황에 해당함을 확신할 수 있으니 충분합니다.

expected 메시지를 지정한 should_panic 테스트가 실패하면 어떻게 되는지 알아보죠.

if value < 1 코드 단락과 else if value > 100 코드 단락을 서로 바꾸어 버그를 만들어 보았습니다.

        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        }

이번에는 should_panic 테스트가 실패합니다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures.

---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures.
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

테스트에서 패닉이 발생하긴 했지만, 지정한 "less than or equal to 100" 문자열이 패닉 메시지에 포함되어 있지 않다는 것을 알려줍니다.

실제로 발생한 패닉 메시지는 Guess value must be greater than or equal to 1, got 200.입니다.

이제 이 메시지를 단서로 버그를 찾아낼 수 있습니다!


Result<(), E>를 이용한 테스트

지금까지는 실패 시 패닉을 발생시키는 테스트만 작성했습니다.

#[test] 함수의 반환 타입은 테스트 실행기가 성공·실패 종료 상태로 바꿀 수 있도록 Termination 트레이트를 구현해야 합니다. 기본적인 () 외에 T: TerminationE: Debug를 만족하는 Result<T, E>도 이 경계를 만족할 수 있으며, 테스트에서는 성공값이 없는 Result<(), E> 형태를 주로 사용합니다.

다음은 예제 10-1 테스트를 Result<(), String>을 사용하도록 수정한 예시입니다.

패닉을 발생시키는 대신 Err을 반환합니다.

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

이제 it_works 함수는 Result<(), String> 타입을 반환합니다.

함수 본문에서는 assert_eq! 매크로를 호출하는 대신, 테스트 성공 시에는 Ok(())를 반환하고 실패 시에는 String을 갖는 Err을 반환합니다.

Result<(), E>를 반환하는 테스트에서는 ? 연산자를 사용할 수 있기 때문에, 내부 작업이 Err를 반환할 경우 실패해야 하는 테스트를 작성하기 편리합니다.

#[should_panic] 테스트 함수의 반환 타입은 ()여야 하므로 Result<(), E> 반환 테스트와 함께 사용할 수 없습니다.

연산이 Err 배리언트를 반환하는 것 자체가 기대 결과라면 그 Result 값에 물음표 연산자를 사용하지 마세요. ?는 그 오류를 테스트 함수의 실패로 곧바로 전파하기 때문입니다.

assert!(value.is_err())Err 여부만 확인할 수도 있지만, 오류 종류나 내용까지 계약이라면 assert!(matches!(value, Pattern))로 패턴을 단언하거나 오류 값을 꺼내 assert_eq! 등으로 구체적으로 단언하세요. matches! 자체는 bool을 반환할 뿐 테스트 실패를 일으키지 않습니다.

불리언 불변식, 값의 동등 또는 부등, 예상한 패닉 메시지 부분 문자열, 성공해야 하는 준비 작업의 Result, 기대한 Err 결과에 맞춰 assert, should_panic, Result 반환 테스트와 구체적 오류 단언을 선택하는 표와 모바일 카드

Rust · test contract · semantic table

실패 모양이 아니라 검증할 계약에 맞춰 도구를 고른다

예상 밖 오류와 예상한 오류를 먼저 구분합니다. 성공해야 하는 준비 작업의 Err?로 실패시키지만, 오류 자체가 기대 결과라면 ?를 쓰지 않고 종류와 내용을 직접 단언합니다.

검증할 계약 × 적합한 Rust 테스트 도구 × 통과·실패 근거 × 중요한 경계
검증할 계약도구와 적합한 질문통과·실패 근거중요한 경계
bool assert!(condition) · 조건 하나가 참이어야 하는가? true면 계속하고 false면 패닉으로 실패합니다. 필요하면 커스텀 메시지를 붙입니다. 조건은 truthy 값이 아니라 실제 bool 타입이어야 합니다.
값 비교 assert_eq! · assert_ne! · 두 값을 함께 봐야 하는가? 비교 계약이 깨지면 패닉으로 실패하고 좌우 값을 Debug 표현으로 보여줍니다. 비교에는 PartialEq, 실패 진단에는 Debug가 필요합니다.
패닉 #[should_panic(expected = "...")] · 특정 패닉 계약인가? 함수가 패닉하고 지정 문자열이 패닉 메시지의 부분 문자열이면 통과합니다. 반환 타입은 ()여야 합니다. 문자열 일치는 오류 종류를 타입으로 증명하지 않으며 다른 패닉이 우연히 맞을 수 있습니다.
준비 오류 fn test() -> Result<(), E>? · 파일 읽기와 파싱이 성공해야 하는가? Ok(())는 성공, 전파된 Err(E)Termination을 통해 실패로 보고됩니다. E: Debug가 필요합니다. #[should_panic]과 결합하지 않습니다.
기대 오류 assert!(matches!(...)) 또는 오류 값을 꺼낸 뒤 구체적으로 단언 · 어느 오류인가? ?를 쓰지 않고 오류 variant, 필드, 메시지 등 계약한 내용을 비교해 통과 여부를 정합니다. is_err()만으로는 Err 여부만 알 수 있어 오류 종류·내용 계약에는 부족할 수 있습니다.

boolean contract

assert!

실제 bool 조건 하나가 참이어야 할 때 씁니다. true면 계속하고 false면 패닉으로 실패하며 커스텀 메시지를 붙일 수 있습니다.

equality contract

assert_eq! · assert_ne!

두 값의 동등 또는 부등이 계약이고 실패 시 좌우 값을 함께 봐야 할 때 씁니다. 비교에는 PartialEq, 진단 출력에는 Debug가 필요합니다.

panic contract

#[should_panic] + expected

테스트가 패닉하고 expected = "..."의 문자열이 패닉 메시지의 부분 문자열이면 통과합니다. 반환 타입은 ()여야 하며, 다른 패닉이 같은 문자열을 포함할 가능성은 남습니다.

fallible setup

Result<(), E> + ?

성공해야 하는 읽기와 파싱의 Err(E)를 곧바로 테스트 실패로 전파합니다. Ok(())는 성공이며 E: Debug가 필요하고 #[should_panic]과 결합하지 않습니다.

expected error

기대한 Err에는 ?를 쓰지 않기

assert!(matches!(...))로 오류 형태를 단언하거나 오류 값을 꺼내 종류, 필드, 메시지를 구체적으로 단언합니다. is_err()만으로는 variant 여부만 확인합니다.

#[test] 함수의 반환 타입은 Termination을 구현해야 합니다. 일반적인 Result<(), E> 테스트에서 Ok(())는 성공으로, Err(E)는 실패로 보고되며 EDebug를 구현해야 합니다.

여러 테스트 작성 방법을 배웠으니, 테스트를 실행할 때 어떤 일들이 일어나는지 알아보고 cargo test 명령어 옵션을 살펴봅시다.