테스트 작성 방법

테스트 작성 방법

**테스트(test)**는 테스트가 아닌 코드가 기대한 대로 동작하는지 검증하는 Rust 함수예요. 테스트 함수의 본문은 전형적으로 세 가지 동작을 수행해요.

  • 필요한 데이터나 상태를 준비한다.
  • 테스트하고 싶은 코드를 실행한다.
  • 결과가 기대한 것인지 주장(assert)한다.

이 동작들을 수행하는 테스트를 작성하기 위해 Rust가 특별히 제공하는 기능을 살펴볼게요. 여기에는 test 어트리뷰트, 몇몇 매크로, should_panic 어트리뷰트가 포함돼요.

출처: The Rust Book

테스트 함수 구조 짜기

가장 단순하게는, Rust에서 테스트는 test 어트리뷰트로 어노테이션된 함수예요. 어트리뷰트는 Rust 코드 조각에 대한 메타데이터로, 5장에서 구조체와 함께 쓴 derive 어트리뷰트가 한 예시예요. 함수를 테스트 함수로 바꾸려면 fn 앞 줄에 #[test]를 추가하면 돼요. cargo test 명령으로 테스트를 실행하면, Rust는 어노테이션된 함수를 실행하고 각 테스트 함수가 통과하는지 실패하는지 보고하는 테스트 러너(runner) 바이너리를 만들어요.

Cargo로 새 라이브러리 프로젝트를 만들 때마다, 테스트 함수가 들어 있는 테스트 모듈이 자동으로 생성돼요. 이 모듈은 테스트 작성 템플릿 역할을 해서, 새 프로젝트를 시작할 때마다 정확한 구조와 문법을 찾아볼 필요가 없게 해 줘요. 원하는 만큼 테스트 함수와 테스트 모듈을 추가할 수 있죠!

실제 코드를 테스트하기 전에, 먼저 템플릿 테스트를 실험해 보면서 테스트가 어떻게 동작하는지 몇 가지 측면을 살펴볼게요. 그런 다음 우리가 직접 작성한 코드를 호출하고 그 동작이 올바른지 주장하는 실제 세계의 테스트를 작성할 거예요.

두 숫자를 더하는 adder라는 새 라이브러리 프로젝트를 만들어 볼게요.

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

adder 라이브러리의 src/lib.rs 파일 내용은 리스팅 11-1과 같아야 해요.

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);
    }
}

이 파일은 테스트할 무언가가 있도록 예시 add 함수로 시작해요.

지금은 it_works 함수에만 집중할게요. #[test] 어노테이션을 주목하세요. 이 어트리뷰트는 이것이 테스트 함수임을 나타내므로, 테스트 러너가 이 함수를 테스트로 취급한다는 걸 알 수 있어요. tests 모듈에는 흔한 시나리오를 준비하거나 흔한 연산을 수행하는 데 도움이 되는 테스트가 아닌 함수가 있을 수도 있으니, 어떤 함수가 테스트인지 항상 표시해 둘 필요가 있어요.

예시 함수 본문은 assert_eq! 매크로를 사용해, 2와 2를 add로 호출한 결과를 담은 result가 4와 같다고 주장해요. 이 주장은 전형적인 테스트의 형식을 보여주는 예시예요. 실행해서 이 테스트가 통과하는지 확인해 볼게요.

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

Cargo가 테스트를 컴파일하고 실행했어요. running 1 test라는 줄이 보여요. 다음 줄은 생성된 테스트 함수의 이름인 tests::it_works와, 그 테스트를 실행한 결과가 ok임을 보여줘요. 전체 요약인 test result: ok.는 모든 테스트가 통과했음을 뜻하고, 1 passed; 0 failed 부분은 통과하거나 실패한 테스트의 개수를 합산해 줘요.

테스트를 특정 인스턴스에서 실행되지 않도록 **무시(ignored)**로 표시하는 것도 가능한데, 이 장 뒷부분의 "특별히 요청하지 않는 한 테스트 무시하기" 절에서 다룰 거예요. 여기서는 그렇게 하지 않았으므로 요약이 0 ignored를 보여줘요. cargo test 명령에 문자열과 이름이 일치하는 테스트만 실행하라는 인자를 넘길 수도 있는데, 이를 **필터링(filtering)**이라고 하고 "이름으로 테스트의 일부 실행하기" 절에서 다룰 거예요. 여기서는 실행되는 테스트를 필터링하지 않았으므로 요약 끝에 0 filtered out이 보여요.

0 measured 통계는 성능을 측정하는 벤치마크 테스트용이에요. 벤치마크 테스트는 이 글을 쓰는 시점에서 nightly Rust에서만 사용할 수 있어요. 자세한 내용은 벤치마크 테스트 문서를 참고하세요.

테스트 출력의 다음 부분인 Doc-tests adder부터는 문서 테스트(documentation test)의 결과예요. 아직 문서 테스트는 없지만, Rust는 API 문서에 나오는 코드 예시를 컴파일할 수 있어요. 이 기능은 문서와 코드를 동기화 상태로 유지하는 데 도움이 돼요! 문서 테스트 작성 방법은 14장의 "문서 주석을 테스트로 사용하기" 절에서 다룰 거예요. 지금은 Doc-tests 출력은 무시할게요.

테스트를 우리의 필요에 맞게 커스터마이즈하기 시작해 볼게요. 먼저 it_works 함수의 이름을 exploration 같은 다른 이름으로 바꿔볼게요.

// 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);
    }
}

그다음 다시 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처럼 보이게 해 볼게요.

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");
    }
}

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`

ok 대신 test tests::another 줄이 FAILED를 보여줘요. 개별 결과와 요약 사이에 두 개의 새 섹션이 나타나요. 첫 번째는 각 테스트 실패의 자세한 이유를 보여줘요. 이 경우 tests::anothersrc/lib.rs 파일의 17번째 줄에서 Make this test fail 메시지로 패닉에 빠져 실패했다는 세부 정보를 얻을 수 있어요. 다음 섹션은 실패한 모든 테스트의 이름만 나열하는데, 테스트가 많고 자세한 실패 출력이 많을 때 유용해요. 실패한 테스트의 이름을 사용해 그 테스트만 실행하면 더 쉽게 디버깅할 수 있어요. 테스트 실행 방법에 대해서는 "테스트 실행 방법 제어하기" 절에서 더 이야기할 거예요.

요약 줄은 마지막에 표시돼요. 전체적으로 우리의 테스트 결과는 FAILED예요. 한 테스트가 통과했고 한 테스트가 실패했죠.

이제 다양한 시나리오에서 테스트 결과가 어떤지 봤으니, 테스트에서 유용한 panic! 외의 몇몇 매크로를 살펴볼게요.

assert!로 결과 확인하기

표준 라이브러리가 제공하는 assert! 매크로는 테스트에서 어떤 조건이 true로 평가되는지 확실히 하고 싶을 때 유용해요. assert! 매크로에 불리언으로 평가되는 인자를 줘요. 값이 true면 아무 일도 일어나지 않고 테스트는 통과해요. 값이 falseassert! 매크로가 panic!을 호출해 테스트를 실패시켜요. assert! 매크로를 쓰면 코드가 의도한 대로 동작하는지 확인하는 데 도움이 돼요.

5장 리스팅 5-15에서 우리는 Rectangle 구조체와 can_hold 메서드를 사용했는데, 여기 리스팅 11-5에 다시 나와 있어요. 이 코드를 src/lib.rs 파일에 넣고, assert! 매크로를 사용해 그에 대한 테스트 몇 개를 작성해 볼게요.

#[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
    }
}

can_hold 메서드는 불리언을 반환하므로 assert! 매크로에 딱 맞는 사용 사례예요. 리스팅 11-6에서 우리는 폭이 8이고 높이가 7인 Rectangle 인스턴스를 만들고, 폭이 5이고 높이가 1인 다른 Rectangle 인스턴스를 담을 수 있는지 주장함으로써 can_hold 메서드를 테스트하는 테스트를 작성해요.

#[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));
    }
}

tests 모듈 안의 use super::*; 줄을 주목하세요. tests 모듈은 7장의 "모듈 트리에서 항목을 가리키는 경로" 절에서 다룬 보통의 가시성 규칙을 따르는 일반 모듈이에요. tests 모듈은 내부 모듈이므로, 외부 모듈에 있는 테스트 대상 코드를 내부 모듈의 스코프로 가져와야 해요. 여기서는 글롭(glob)을 사용하므로 외부 모듈에 정의한 모든 것이 이 tests 모듈에서 사용 가능해요.

테스트 이름을 larger_can_hold_smaller이라고 지었고, 필요한 두 Rectangle 인스턴스를 만들었어요. 그런 다음 assert! 매크로를 호출하면서 larger.can_hold(&smaller) 호출 결과를 넘겼죠. 이 표현식은 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

통과하네요! 이번에는 작은 사각형이 큰 사각형을 담을 수 없다고 주장하는 테스트를 하나 더 추가해 볼게요.

// 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() {
        // --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에서 우리는 매개변수에 2를 더하는 add_two라는 함수를 작성하고, assert_eq! 매크로로 이 함수를 테스트해요.

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);
    }
}

통과하는지 확인해 볼게요!

$ 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 함수의 구현을 대신 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이며 leftright 값이 무엇인지 알려줘요. 이 메시지는 디버깅을 시작하는 데 도움이 돼요. add_two(2) 호출 결과가 있던 left 인자는 5였지만 right 인자는 4였거든요. 테스트가 많을 때 이게 특히 유용하리라는 걸 상상할 수 있겠죠.

몇몇 언어와 테스트 프레임워크에서는 동등성 주장 함수의 매개변수를 expectedactual이라고 부르고, 인자를 지정하는 순서가 중요하다는 점을 유의하세요. 하지만 Rust에서는 이들을 leftright라고 부르고, 기대하는 값과 코드가 만들어내는 값 중 무엇을 먼저 지정하든 상관없어요. 이 테스트의 주장을 assert_eq!(4, result)라고 써도, assertion 'left == right' failed를 표시하는 같은 실패 메시지가 나오게 돼요.

assert_ne! 매크로는 우리가 준 두 값이 같지 않으면 통과하고 같으면 실패해요. 이 매크로는 값이 무엇이 될지는 확신할 수 없지만, 값이 분명히 되지 말아야 할 것은 아는 경우에 가장 유용해요. 예를 들어 입력을 어떤 방식으로든 반드시 바꾸는 것이 보장된 함수를 테스트하는데, 입력이 바뀌는 방식이 테스트를 실행하는 요일에 따라 달라진다면, 주장하기 가장 좋은 것은 함수의 출력이 입력과 같지 않다는 것일 거예요.

표면 아래에서 assert_eq!assert_ne! 매크로는 각각 ==!= 연산자를 사용해요. 주장이 실패하면 이 매크로들은 디버그 포매팅으로 인자를 출력하므로, 비교되는 값들은 PartialEqDebug 트레이트를 구현해야 해요. 모든 원시 타입과 대부분의 표준 라이브러리 타입은 이 트레이트들을 구현해요. 여러분이 직접 정의한 구조체와 이늄에는, 그 타입들의 동등성을 주장하려면 PartialEq를 구현해야 해요. 주장이 실패할 때 값을 출력하려면 Debug도 구현해야 하고요. 두 트레이트 모두 파생 가능한 트레이트이므로, 5장 리스팅 5-12에서 언급했듯 구조체나 이늄 정의에 #[derive(PartialEq, Debug)] 어노테이션을 추가하면 되는 정도로 간단해요. 이 트레이트들과 다른 파생 가능한 트레이트에 대한 자세한 내용은 부록 C의 "파생 가능한 트레이트"를 참고하세요.

커스텀 실패 메시지 추가하기

assert!, assert_eq!, assert_ne! 매크로의 선택적 인자로 실패 메시지와 함께 출력될 커스텀 메시지를 추가할 수도 있어요. 필수 인자 뒤에 지정된 인자들은 모두 format! 매크로(8장의 "+ 또는 format!로 연결하기"에서 다룸)로 전달되므로, {} 자리표시자를 포함한 포맷 문자열과 그 자리표시자에 들어갈 값들을 넘길 수 있어요. 커스텀 메시지는 주장이 무엇을 의미하는지 문서화하는 데 유용해요. 테스트가 실패하면 코드의 문제가 무엇인지 더 잘 파악할 수 있으니까요.

예를 들어 이름으로 사람에게 인사하는 함수가 있고, 함수에 넘긴 이름이 출력에 나타나는지 테스트하고 싶다고 해볼게요.

// Filename: 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의 에러 조건이 우리가 기대한 때에 발생하는지 확인하는 테스트를 보여줘요.

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` 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

좋아 보여요! 이제 값이 100보다 클 때 new 함수가 패닉을 일으킬 조건을 제거해서 코드에 버그를 도입해 볼게요.

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 테스트는 우리가 기대했던 것과 다른 이유로 테스트가 패닉에 빠져도 통과할 거예요. should_panic 테스트를 더 정확하게 만들려면 should_panic 어트리뷰트에 선택적 expected 매개변수를 추가할 수 있어요. 테스트 하니스는 실패 메시지가 제공된 텍스트를 포함하는지 확인할 거예요. 예를 들어 리스팅 11-9의 수정된 Guess 코드를 생각해 볼게요. 이제 new 함수는 값이 너무 작은지 너무 큰지에 따라 다른 메시지로 패닉에 빠져요.

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);
    }
}

이 테스트는 통과할 거예요. 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이에요. 이제 버그가 어디 있는지 알아내기 시작할 수 있어요!

테스트에서 Result<T, E> 사용하기

지금까지의 모든 테스트는 실패할 때 패닉에 빠져요. Result<T, E>를 사용하는 테스트를 작성할 수도 있어요! 리스팅 11-1의 테스트를 Result<T, E>를 사용하고 패닉 대신 Err를 반환하도록 다시 쓴 모습을 볼게요.

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

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

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

it_works 함수는 이제 Result<(), String> 반환 타입을 가져요. 함수 본문에서는 assert_eq! 매크로를 호출하는 대신, 테스트가 통과하면 Ok(())를 반환하고 테스트가 실패하면 안에 String이 들어간 Err를 반환해요.

Result<T, E>를 반환하도록 테스트를 작성하면 테스트 본문에서 물음표 연산자(question mark operator)를 사용할 수 있게 돼요. 이는 안의 어떤 연산이 Err 변형을 반환하면 테스트가 실패해야 하는 테스트를 편리하게 작성하는 방법이에요.

Result<T, E>를 사용하는 테스트에는 #[should_panic] 어노테이션을 사용할 수 없어요. 어떤 연산이 Err 변형을 반환한다는 것을 주장하려면 Result<T, E> 값에 물음표 연산자를 사용하지 말고, 대신 assert!(value.is_err())를 사용하세요.

이제 테스트를 작성하는 여러 방법을 알게 됐으니, 테스트를 실행할 때 무슨 일이 일어나는지 살펴보고 cargo test와 함께 사용할 수 있는 다양한 옵션을 탐구해 볼게요.

더 알아보기 (Learn more)