Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

테스트 작성 방법

테스트(tests) 는 테스트 대상이 아닌 코드가 기대한 방식으로 동작하는지 검증하는 러스트 함수입니다. 테스트 함수 본문은 보통 다음 세 가지 일을 수행합니다.

  • 필요한 데이터나 상태를 준비한다.
  • 테스트하려는 코드를 실행한다.
  • 결과가 기대한 것과 같은지 단언(assert)한다.

이제 이런 작업을 위해 러스트가 특별히 제공하는 기능들을 살펴봅시다. test 속성, 몇 가지 매크로, 그리고 should_panic 속성이 여기에 포함됩니다.

테스트 함수의 구조

가장 단순한 형태에서, 러스트의 테스트는 test 속성으로 주석이 붙은 함수입니다. 속성(attribute)은 러스트 코드 조각에 대한 메타데이터입니다. 한 가지 예로 5장에서 구조체와 함께 사용했던 derive 속성이 있습니다. 함수를 테스트 함수로 바꾸려면, fn 앞 줄에 #[test] 를 추가하면 됩니다. cargo test 명령으로 테스트를 실행하면, 러스트는 이런 주석이 붙은 함수를 실행하는 테스트 러너 바이너리를 빌드하고, 각 테스트 함수가 통과했는지 실패했는지를 보고합니다.

Cargo로 새 라이브러리 프로젝트를 만들면, 테스트 함수 하나를 포함한 테스트 모듈이 자동으로 생성됩니다. 이 모듈은 새 프로젝트를 시작할 때마다 테스트의 정확한 구조와 문법을 외울 필요가 없도록 기본 템플릿을 제공합니다. 물론 여기에 원하는 만큼의 추가 테스트 함수와 테스트 모듈을 넣을 수 있습니다!

먼저 실제 코드를 테스트하기 전에, 이 자동 생성된 템플릿 테스트를 가지고 러스트 테스트가 어떻게 동작하는지 몇 가지 측면을 살펴보겠습니다. 그런 다음 우리가 직접 작성한 코드를 호출하고 그 동작이 올바른지 단언하는 현실적인 테스트를 작성해 보겠습니다.

두 숫자를 더하는 adder 라는 새 라이브러리 프로젝트를 만들어 봅시다.

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

adder 라이브러리의 src/lib.rs 파일 내용은 목록 11-1과 비슷할 것입니다.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-1: cargo new 가 자동으로 생성한 코드

파일은 먼저 예제용 add 함수를 정의해, 테스트할 무언가를 준비해 둡니다.

지금은 it_works 함수에만 집중합시다. #[test] 주석에 주목하세요. 이 속성은 이 함수가 테스트 함수라는 뜻이므로, 테스트 러너가 이 함수를 테스트로 취급합니다. 또한 공통 시나리오를 준비하거나 공통 작업을 수행하기 위한 비테스트 함수도 tests 모듈 안에 둘 수 있으므로, 어떤 함수가 테스트인지 항상 명시해 주어야 합니다.

예제 함수 본문은 assert_eq! 매크로를 사용해, add(2, 2) 의 결과를 담은 result4 와 같다고 단언합니다. 이것은 전형적인 테스트 형식을 보여 주는 예입니다. 직접 실행해 이 테스트가 통과하는지 봅시다.

cargo test 명령은 프로젝트 안의 모든 테스트를 실행합니다. 목록 11-2를 보세요.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

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

   Doc-tests adder

running 0 tests

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

Listing 11-2: 자동 생성된 테스트를 실행한 출력

Cargo가 테스트를 컴파일하고 실행했습니다. running 1 test 라는 줄이 보입니다. 그 다음 줄은 자동 생성된 테스트 함수 이름 tests::it_works 와, 그 테스트 결과가 ok 임을 보여 줍니다. 마지막의 test result: ok. 요약은 모든 테스트가 통과했다는 뜻이고, 1 passed; 0 failed 부분은 통과하거나 실패한 테스트 개수를 보여 줍니다.

테스트를 무시(ignore) 처리해 특정 실행에서는 돌지 않게 만들 수도 있는데, 이는 이 장 뒤의 [“특별히 요청한 경우가 아니면 테스트 무시하기”][ignoring] 절에서 다룹니다. 여기서는 그런 처리를 하지 않았으므로 요약에 0 ignored 가 표시됩니다. 또한 cargo test 에 인수를 주어 이름이 특정 문자열과 일치하는 테스트만 실행할 수도 있는데, 이를 필터링(filtering) 이라고 하며 [“이름으로 테스트 일부만 실행하기”][subset] 절에서 다룹니다. 지금은 필터링하지 않았기 때문에 요약 끝에 0 filtered out 이라고 나옵니다.

0 measured 라는 통계는 성능을 측정하는 벤치마크 테스트를 뜻합니다. 이 글을 쓰는 시점에서 벤치마크 테스트는 nightly Rust 에서만 가능합니다. 자세한 내용은 [벤치마크 테스트 문서][bench]를 참고하세요.

테스트 출력에서 Doc-tests adder 부터 시작하는 다음 부분은 문서 테스트 결과입니다. 지금은 문서 테스트가 없지만, 러스트는 API 문서 안에 있는 코드 예제도 컴파일할 수 있습니다. 이 기능은 문서와 코드가 계속 맞물려 있도록 유지하는 데 도움이 됩니다! 문서 테스트 작성법은 14장의 [“문서 주석을 테스트로 사용하기”][doc-comments] 절에서 설명합니다. 지금은 Doc-tests 출력은 무시해도 됩니다.

이제 이 테스트를 우리 필요에 맞게 조금 바꿔 봅시다. 먼저 it_works 함수 이름을 예를 들어 exploration 같은 다른 이름으로 바꿉니다.

파일명: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

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

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

그다음 다시 cargo test 를 실행합니다. 이제 출력에는 it_works 대신 exploration 이 보일 것입니다.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

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

   Doc-tests adder

running 0 tests

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

이제 테스트 하나를 더 추가해 보겠습니다. 이번에는 실패하는 테스트를 만들 것입니다! 테스트 함수 안에서 무언가가 패닉하면 테스트는 실패합니다. 각 테스트는 새 스레드에서 실행되며, 메인 스레드는 테스트 스레드가 죽었다는 사실을 감지하면 그 테스트를 실패로 표시합니다. 9장에서 가장 단순하게 패닉하는 방법은 panic! 매크로를 호출하는 것이라고 배웠습니다. another 라는 이름의 새 테스트 함수를 추가해 src/lib.rs 를 목록 11-3처럼 바꿔 보세요.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

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

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: panic! 매크로를 호출해 실패하게 되는 두 번째 테스트 추가하기

다시 cargo test 로 테스트를 실행해 봅시다. 출력은 목록 11-4와 비슷할 것입니다. exploration 테스트는 통과하고, another 는 실패합니다.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

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

error: test failed, to rerun pass `--lib`
Listing 11-4: 하나는 통과하고 하나는 실패할 때의 테스트 결과

ok 대신 test tests::another 줄에 FAILED 가 표시된다는 점에 주목하세요. 개별 결과와 최종 요약 사이에는 두 개의 새 섹션이 생깁니다. 첫 번째는 각 테스트 실패 이유를 자세히 보여 줍니다. 이 경우 tests::anothersrc/lib.rs 17번째 줄에서 Make this test fail 메시지와 함께 패닉했기 때문에 실패했다는 정보를 얻습니다. 그 다음 섹션은 실패한 테스트 이름만 나열해 주는데, 테스트와 실패 출력이 많을 때 유용합니다. 실패한 테스트 이름을 이용하면 그 테스트만 따로 실행해 더 쉽게 디버깅할 수 있습니다. 테스트 실행 방법은 [“테스트 실행 방식 제어하기”] [controlling-how-tests-are-run] 절에서 더 이야기합니다.

끝 줄의 요약은 전체적으로 테스트 결과가 FAILED 였다고 알려 줍니다. 즉 하나는 통과했고 하나는 실패했습니다.

이제 테스트 결과가 다양한 상황에서 어떻게 보이는지 확인했으니, 테스트에 유용한 panic! 외 다른 매크로들을 살펴봅시다.

assert! 매크로로 결과 확인하기

표준 라이브러리가 제공하는 assert! 매크로는, 테스트 안의 어떤 조건이 true 로 평가되는지 확인하고 싶을 때 유용합니다. assert! 매크로에는 불리언으로 평가되는 인수를 넘깁니다. 값이 true 이면 아무 일도 일어나지 않고 테스트는 통과합니다. 값이 false 이면 assert! 매크로가 panic! 을 호출해 테스트를 실패시킵니다. assert! 는 우리의 코드가 우리가 의도한 대로 동작하는지 확인하게 도와줍니다.

5장의 목록 5-15에서 Rectangle 구조체와 can_hold 메서드를 사용했습니다. 그 코드를 목록 11-5에 다시 실었습니다. 이 코드를 src/lib.rs 파일에 넣고, assert! 매크로를 사용해 여기에 대한 테스트를 작성해 봅시다.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: 5장에서 가져온 Rectangle 구조체와 can_hold 메서드

can_hold 메서드는 불리언을 반환하므로, assert! 매크로를 쓰기에 딱 좋은 경우입니다. 목록 11-6에서는 너비 8, 높이 7인 Rectangle 인스턴스를 만들고, 그것이 너비 5, 높이 1인 다른 Rectangle 을 실제로 담을 수 있는지 assert! 로 검사합니다.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: 큰 직사각형이 작은 직사각형을 담을 수 있는지 검사하는 can_hold 테스트

tests 모듈 안의 use super::*; 줄에 주목하세요. tests 모듈도 일반 모듈이며, 7장의 [“모듈 트리의 항목을 경로로 가리키기”][paths-for-referring-to-an-item-in-the-module-tree] 절에서 설명한 일반 공개 범위 규칙을 그대로 따릅니다. tests 는 내부 모듈이기 때문에, 테스트 대상이 되는 바깥 모듈의 코드를 그 안으로 가져와야 합니다. 여기서는 글롭을 사용해, 바깥 모듈 안의 모든 항목이 tests 모듈 안에서 보이도록 했습니다.

우리는 이 테스트 이름을 larger_can_hold_smaller 로 지었고, 필요한 두 Rectangle 인스턴스를 만들었습니다. 그런 다음 larger.can_hold(&smaller) 호출 결과를 assert! 에 전달했습니다. 이 식은 true 를 반환해야 하므로, 테스트도 통과해야 합니다. 실제로 그런지 확인해 봅시다.

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

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

   Doc-tests rectangle

running 0 tests

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

정상적으로 통과합니다! 이번에는 더 작은 직사각형이 더 큰 직사각형을 담을 수는 없다는 사실을 확인하는 테스트를 하나 더 추가해 봅시다.

파일명: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

이 경우 can_hold 의 올바른 결과는 false 이므로, 그 결과를 assert! 에 넘기기 전에 ! 로 부정해야 합니다. 따라서 can_holdfalse 를 반환하면 테스트는 통과합니다.

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

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

   Doc-tests rectangle

running 0 tests

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

두 테스트 모두 통과했습니다! 이제 코드에 버그를 하나 넣어 테스트 결과가 어떻게 바뀌는지 보겠습니다. can_hold 구현에서 너비를 비교하는 연산자를 > 에서 < 로 바꿉니다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

이제 테스트를 다시 실행하면 다음과 같은 결과가 나옵니다.

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

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

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

테스트가 버그를 잡아냈습니다! larger.width8, smaller.width5 이므로, 지금의 can_hold 너비 비교는 false 를 반환합니다. 8은 5보다 작지 않기 때문입니다.

assert_eq!assert_ne! 로 동등성 테스트하기

기능을 검증하는 흔한 방법은, 테스트 대상 코드의 결과와 기대값이 같은지를 확인하는 것입니다. 이를 위해 assert! 매크로에 == 를 사용하는 식을 넘길 수도 있습니다. 하지만 이 일은 너무 흔해서 표준 라이브러리는 assert_eq!assert_ne! 라는 한 쌍의 매크로를 제공합니다. 이 매크로는 각각 두 인수를 동등 또는 비동등으로 비교합니다. 단언이 실패하면, 이 매크로들은 비교한 두 값을 함께 출력해 주므로 왜 테스트가 실패했는지 더 쉽게 볼 수 있습니다. 반면 assert!== 식이 false 였다는 사실만 알려 줄 뿐, 어떤 값 때문에 false 가 되었는지는 출력하지 않습니다.

목록 11-7에서는 add_two 라는 함수를 정의해, 인수에 2를 더한 값을 반환하게 하고, 이 함수를 assert_eq! 매크로로 테스트합니다.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

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

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: assert_eq! 매크로로 add_two 함수 테스트하기

테스트가 통과하는지 확인해 봅시다!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

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

   Doc-tests adder

running 0 tests

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

우리는 add_two(2) 호출 결과를 result 라는 변수에 저장한 다음, result4assert_eq! 에 넘깁니다. 이 테스트의 출력 줄은 test tests::it_adds_two ... ok 형태이며, ok 는 테스트가 통과했다는 뜻입니다.

이번에는 버그를 넣어 assert_eq! 가 실패할 때 어떻게 보이는지 확인해 봅시다. add_two 구현이 2 대신 3을 더하도록 바꿉니다.

pub fn add_two(a: u64) -> u64 {
    a + 3
}

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

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

테스트를 다시 실행해 보세요.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

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`

테스트가 버그를 잡아냈습니다! tests::it_adds_two 테스트가 실패했고, 메시지는 실패한 단언이 left == right 였는지, 그리고 각각의 left, right 값이 무엇이었는지를 알려 줍니다. leftadd_two(2) 호출 결과인 5 였고, right4 였습니다. 테스트가 많아지면 이런 정보가 특히 유용합니다.

일부 언어와 테스트 프레임워크에서는 동등성 단언 함수의 매개변수를 expectedactual 로 부르고, 인수 순서가 중요하기도 합니다. 하지만 러스트에서는 left, right 라고 부르며, 기대값과 실제값을 어느 순서로 적든 상관없습니다. 예를 들어 이 테스트에서 assert_eq!(4, result) 라고 써도, assertion `left == right` failed 를 포함한 같은 실패 메시지가 나옵니다.

assert_ne! 매크로는 두 값이 같지 않으면 통과하고, 같으면 실패합니다. 이 매크로는 값이 무엇일지는 모르지만 적어도 무엇이 아니어야 하는지 는 아는 경우에 특히 유용합니다. 예를 들어 어떤 함수가 입력값을 반드시 변화시키긴 하지만, 어떻게 변하는지는 테스트 실행 날짜 같은 외부 조건에 따라 달라진다면, “함수 출력이 입력과 같지 않다”는 것을 단언하는 것이 최선일 수 있습니다.

내부적으로 assert_eq!assert_ne! 는 각각 ==, != 연산자를 사용합니다. 단언이 실패하면 인수들을 디버그 형식으로 출력하므로, 비교 대상 값들은 PartialEqDebug 트레이트를 구현하고 있어야 합니다. 모든 기본 타입과 대부분의 표준 라이브러리 타입은 이 트레이트들을 이미 구현합니다. 여러분이 직접 정의한 구조체와 enum에 대해서는, 그런 타입의 동등성 단언을 하려면 PartialEq 를 구현해야 하고, 실패 시 값을 출력하려면 Debug 도 구현해야 합니다. 둘 다 5장의 목록 5-12에서 언급한 파생 가능한 트레이트이므로, 보통은 구조체나 enum 정의 앞에 #[derive(PartialEq, Debug)] 를 추가하는 정도로 충분합니다. 자세한 내용은 부록 C의 [“파생 가능한 트레이트”][derivable-traits]를 참고하세요.

사용자 정의 실패 메시지 추가하기

assert!, assert_eq!, assert_ne! 매크로에는 실패 메시지와 함께 출력할 사용자 정의 메시지를 선택적 인수로 추가할 수도 있습니다. 필수 인수 뒤에 오는 모든 추가 인수는 format! 매크로로 전달됩니다([8장의 “+ 또는 format! 으로 이어 붙이기” 절][concatenating] 참고). 따라서 {} 플레이스홀더가 들어 있는 포맷 문자열과 그 자리에 채울 값들을 넘길 수 있습니다. 사용자 정의 메시지는 단언의 의미를 문서화하는 데 유용합니다. 테스트가 실패했을 때, 코드에서 무엇이 문제인지 더 잘 알 수 있기 때문입니다.

예를 들어 어떤 사람의 이름으로 인사하는 함수가 있고, 함수 출력 안에 우리가 넣은 이름이 실제로 포함되어 있는지 테스트하고 싶다고 합시다.

파일명: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

이 프로그램의 요구사항은 아직 완전히 합의되지 않았고, 인사말 앞부분의 Hello 텍스트는 아마 바뀔 가능성이 큽니다. 따라서 요구사항이 바뀔 때마다 테스트를 수정하고 싶지는 않아서, greeting 함수가 반환한 값과 완전히 같은지를 검사하는 대신, 출력이 입력 매개변수 텍스트를 포함하는지만 검사하기로 합니다.

이제 이 코드에 버그를 넣어, greetingname 을 제외하도록 바꿔 보고, 기본 실패 출력이 어떤 모양인지 확인해 봅시다.

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

이 테스트를 실행하면 다음과 같은 결과가 나옵니다.

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

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`

이 결과는 단언이 실패했다는 사실과, 그 단언이 어느 줄에 있었는지만 알려 줍니다. 더 유용한 실패 메시지라면 greeting 함수가 실제로 반환한 값을 보여 줘야 할 것입니다. 이제 greeting 함수에서 얻은 실제 값을 자리표시자로 채운 포맷 문자열을 사용해 사용자 정의 실패 메시지를 추가해 봅시다.

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

이제 테스트를 실행하면 더 많은 정보를 담은 에러 메시지를 얻게 됩니다.

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

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 으로 패닉 확인하기

반환값을 검사하는 것 외에도, 코드가 에러 상황을 우리가 기대한 방식으로 처리하는지 검사하는 것도 중요합니다. 예를 들어 9장의 목록 9-13에서 만든 Guess 타입을 생각해 봅시다. Guess 를 사용하는 다른 코드는, Guess 인스턴스 안에는 1에서 100 사이 값만 들어 있다는 보장에 의존합니다. 그러므로 범위를 벗어난 값으로 Guess 를 만들려 하면 실제로 패닉하는지 테스트를 쓸 수 있습니다.

이를 위해 테스트 함수에 should_panic 속성을 추가합니다. 함수 안의 코드가 패닉하면 테스트는 통과하고, 패닉하지 않으면 실패합니다.

목록 11-8은 Guess::new 의 에러 조건이 우리가 기대한 대로 발생하는지 검사하는 테스트입니다.

Filename: 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);
    }
}
Listing 11-8: 어떤 조건이 panic! 을 일으키는지 테스트하기

#[test] 뒤, 해당 테스트 함수 앞에 #[should_panic] 속성을 붙입니다. 이 테스트가 통과할 때 결과가 어떤지 봅시다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [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

좋습니다! 이제 코드에 버그를 하나 넣어, new 함수가 값이 100보다 클 때 패닉하는 조건을 제거해 보겠습니다.

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            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);
    }
}

이 상태에서 목록 11-8의 테스트를 실행하면 실패합니다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [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 at src/lib.rs:21:8

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 속성에 선택적 expected 매개변수를 넣을 수 있습니다. 그러면 테스트 하네스가 실패 메시지 안에 제공된 문자열이 포함되어 있는지도 확인합니다. 예를 들어 목록 11-9의 Guess 코드는, 값이 너무 작은지 너무 큰지에 따라 new 함수가 서로 다른 메시지로 패닉하도록 바꾼 버전입니다.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

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);
    }
}
Listing 11-9: 지정한 부분 문자열이 포함된 패닉 메시지를 기대하는 panic! 테스트

이 테스트는 should_panic 속성의 expected 매개변수에 넣은 값이, Guess::new 가 패닉하며 출력한 메시지의 부분 문자열이기 때문에 통과합니다. 이 경우 전체 패닉 메시지인 Guess value must be less than or equal to 100, got 200 를 그대로 적을 수도 있었을 것입니다. 얼마나 많은 부분을 지정할지는 메시지 안에서 얼마나 많은 부분이 고정된 값인지, 그리고 테스트를 얼마나 엄밀하게 만들고 싶은지에 따라 달라집니다. 여기서는 부분 문자열만으로도 테스트 함수가 else if value > 100 분기를 실행했다는 사실을 확인하기에 충분합니다.

이제 expected 메시지가 있는 should_panic 테스트가 실패하면 어떤 일이 벌어지는지 보기 위해, 다시 코드에 버그를 넣어 봅시다. if value < 1else if value > 100 블록의 본문을 서로 바꿔치기합니다.

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        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}."
            );
        }

        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 테스트를 실행하면 실패합니다.

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [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 src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
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 이었습니다. 이제 버그가 어디에 있는지 추적하기 시작할 수 있습니다.