[rust] 11. 자동화 테스트 작성하기

About_work·2024년 7월 28일
0

rust

목록 보기
13/16
  • 러스트는 언어 자체적으로 자동화된 소프트웨어 테스트 작성을 지원

1. 테스트 작성 방법

1.1. 테스트 함수 파헤치기

  • 러스트에서 테스트란 test 속성(attribute)이 어노테이션된 함수
  • 속성(attribute): 러스트 코드 조각에 대한 메타데이터
  • 앞서 5장에서 구조체에 사용했던 #[derive(Debug)]의 derive도 속성 중 하나
  • 함수의 fn 이전 줄에 #[test]를 추가하면 테스트 함수로 변경됨
  • 테스트는 cargo test 명령어로 실행되며, 이 명령을 실행하면
    • 러스트는 속성이 표시된 함수를 실행하고 결과를 보고하는
    • 테스트 실행 바이너리를 빌드
  • 카고로 새 라이브러리 프로젝트를 생성할 때마다,
    • 테스트 함수가 포함된 테스트 모듈(namespace 관리를 위한 기본 단위)이 자동 생성
  • 이 모듈이 테스트 작성을 위한 템플릿을 제공하므로,
    • 새 프로젝트를 시작할 때마다, 정확한 구조 및 테스트 함수 문법을 찾아볼 필요는 없습니다.
  • 테스트 모듈과 테스트 함수는 여러분이 원하는 만큼 추가할 수 있습니다!
  • 라이브러리 프로젝트 생성 법
$ cargo new adder --lib
     Created library `adder` project
$ cd adder

1.1.1. 라이브러리 프로젝트 VS 바이너리 프로젝트

  • Rust에서 cargo new adder --lib 명령어를 사용하여 새 라이브러리 프로젝트를 생성하면, 일반 프로젝트와는 몇 가지 차이점이 있습니다.
  1. 프로젝트 타입:

    • 라이브러리 프로젝트: 다른 프로젝트에서 사용할 수 있는 재사용 가능한 코드 모음을 작성하는 데 중점
    • 일반(바이너리) 프로젝트: 독립 실행형 프로그램을 작성하는 데 중점
  2. 디렉토리 구조:

    • 라이브러리 프로젝트: src 디렉토리 안에 lib.rs 파일이 생성
      • lib.rs 파일은 라이브러리의 루트 파일로, 모듈과 함수를 정의하는 데 사용
      my_library/
      ├── Cargo.toml
      └── src
          └── lib.rs
    • 일반(바이너리) 프로젝트: src 디렉토리 안에 main.rs 파일이 생성
      • main.rs 파일은 프로그램의 진입점(엔트리 포인트)으로 사용
      my_project/
      ├── Cargo.toml
      └── src
          └── main.rs
  3. 컴파일 출력:

    • 라이브러리 프로젝트: 컴파일하면 .rlib 파일이 생성
      • 이 파일은 다른 Rust 프로젝트에서 Cargo.toml 파일에 의존성으로 추가하여 사용할 수 있음
    • 일반(바이너리) 프로젝트: 컴파일하면 실행 가능한 바이너리 파일이 생성

  • src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}
  • cargo test 명령어는 프로젝트 내 모든 테스트를 실행
  • 어떤 테스트를 무시하도록 표시하여, 특정 인스턴스에서는 실행되지 않도록 할 수도 있습니다.
$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

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
  • 0 ignored: 어떤 테스트를 무시하도록 표시하여 특정 인스턴스에서는 실행되지 않도록 할 수도 있음
  • 0 filtered out: cargo test에 인수를 넘겨서 어떤 문자열과 이름이 일치하는 테스트만 실행하도록 할 수도 있음
  • 0 measured: 성능 측정 벤치마크 테스트용
    • 러스트 나이틀리 (nightly) 에서만 사용 가능
  • 테스트 출력 결과 중 Doc-tests adder: 문서 테스트 결과를 나타냄
    • 러스트는 API 문서에 작성해 놓은 예제 코드도 컴파일 할 수 있음
  • 러스트의 이 기능은 작성한 코드와 문서의 내용이 달라지지 않도록 유지보수하는 데에 매우 유용
  • 각각의 테스트는 새로운 스레드에서 실행되며,
    • 메인 스레드에서 테스트 스레드가 죽은 것을 알게 되면 해당 테스트는 실패한 것으로 처리

1.2. assert! 매크로로 결과 검사하기

  • 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));
    }
}
  • 내부 모듈인 tests 모듈에서, 외부 모듈의 코드를 테스트하려면 먼저 내부 스코프로 가져와야 합니다.
  • use super::*;
    • tests 모듈 내에서, 상위 모듈의 모든 항목을 가져와서 사용할 수 있게 합니다.
    • 이를 통해 tests 모듈 내에서
      • 상위 모듈에 정의된 Rectangle 구조체와 can_hold 메서드를 직접 사용할 수 있게 됩니다.
  • assert!(!smaller.can_hold(&larger));에서 smaller 앞의 ! 문법 가능

1.3. assert_eq!, assert_ne! 매크로를 이용한 동등 테스트

assert_eq!(4, add_two(2));
  • 몇몇 프로그래밍 언어, 프레임워크에서는 동등 단언 함수의 매개변수를 expected, actual라고 지칭하며, 코드를 작성할 때 인수의 순서를 지켜야 합니다.
  • 하지만 러스트에서는 left, right라고 지칭할 뿐, 예상값과 테스트 코드로 만들어진 값의 순서는 상관없습니다.
  • 내부적으로 assert_eq!, assert_ne! 매크로는 각각 ==, != 연산자를 사용합니다.
  • 단언에 실패할 경우, 매크로는 인수를 디버그 형식으로 출력하는데,
    • 즉 assert_eq!, assert_ne! 매크로로 비교할 값은 PartialEq, Debug 트레이트를 구현해야 합니다.
    • 모든 기본 타입 및 대부분의 표준 라이브러리 타입은 이 두 트레이트를 구현
  • 직접 정의한 구조체나 열거형의 경우에는 PartialEq 트레이트를 구현하여, 해당 타입의 값이 같음을 단언할 수 있도록 할 필요가 있습니다.
  • 또한 단언 실패 시 값이 출력될 수 있도록 Debug 트레이트도 구현해야 합니다.
  • 5장 예제 5-12에서 설명했듯 PartialEq, Debug 두 트레이트 모두 파생 가능한 트레이트이기 때문에,
    • 구조체, 열거형 정의에 #[derive(PartialEq, Debug)]를 어노테이션하는 것이 일반적입니다.
  • 파생 가능한 트레이트? (derivable traits)
    • 파생 가능한 트레이트는 컴파일러가 자동으로 구현할 수 있는 트레이트
    • PartialEq 트레이트: 두 값이 같은지 비교할 수 있는 기능을 제공(==)
    • Debug 트레이트: 구조체나 열거형의 내용을 쉽게 출력할 수 있는 기능을 제공
      • {:?} 형식 지정자를 사용하여 값을 디버그 출력할 수 있습니다.
      • println!("{:?}", rectangle);
  • #[derive(PartialEq, Debug)] 어노테이션
    • 컴파일러에게 PartialEq와 Debug 트레이트의 기본 구현을 자동으로 생성하라고 지시

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

  • 필수적인 인수들 이후의 인수는 format! 매크로로 전달됩니다.
    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{}`",
            result
        );
    }

1.5. should_panic 매크로로 패닉 발생 검사하기

  • 코드의 반환 값을 검사하는 것에 더하여, 예상한대로 에러 조건을 잘 처리하는지 검사하는 것도 중요
  • Guess 타입을 사용하는 다른 코드는 Guess 인스턴스가 1에서 100 사잇값임을 보장하는 기능에 의존
    • 이런 경우, 범위를 벗어난 값으로 Guess 인스턴스를 만들면 패닉이 발생하는지 검사하는 테스트를 작성하면 이를 확실하게 보장할 수 있음 (#[should_panic])
    • should_panic을 사용하는 테스트는 정확하지 않을 수 있습니다. 의도한 것과는 다른 이유로 패닉이 발생하더라도 should_panic 테스트는 통과할 것입니다.
  • should_panic 속성에 expected 매개변수를 추가해, 포함되어야 하는 실패 메시지를 지정하면 더 꼼꼼한 should_panic 테스트를 작성할 수 있습니다.
  • should_panic 속성의 expected 매개변숫값이 Guess::new 함수에서 발생한 패닉 메시지 문자열의 일부이므로 테스트는 통과
// --생략--

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

1.6. Result<T, E>를 이용한 테스트

#[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"))
        }
    }
}
  • Result<T, E>를 반환하는 테스트에서는 ? 연산자를 사용할 수 있기 때문에,
    • 내부 작업이 Err를 반환할 경우 실패해야 하는 테스트를 작성하기 편리
  • ? 연산자
    • 러스트에서는 에러를 전파하는 패턴이 너무 흔하여, 이를 더 쉽게 해주는 물음표 연산자 ?를 제공
    • 만일 값이 Err라면, (return 키워드로 에러 값을 호출하는 코드에게 전파하는 것처럼) Err의 값이 반환될 것
    • ? 연산자를 사용할 때의 에러 값들은 from 함수를 거친다는 것
      • from 함수(From trait에 정의):
        • ? 연산자가 얻게 되는 에러를,
        • ? 연산자가 사용된 현재 함수의 반환 타입에 정의된 에러 타입으로 변환
        • 이는 어떤 함수가 다양한 종류의 에러로 인해 실패할 수 있지만, 모든 에러를 하나의 에러 타입으로 반환할 때 유용
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
  • Result<T, E> 테스트에서는 #[should_panic] 어노테이션을 사용할 수 없습니다.
  • 연산이 Err 배리언트를 반환하는 것을 단언하기 위해서는,
    • Result<T, E> 값에 물음표 연산자를 사용하지 마세요.
  • 대신 assert!(value.is_err())를 사용하세요.

2. 테스트 실행 방법 제어하기

  • cargo test 명령어는 코드를 테스트 모드에서 컴파일하고 생성된 바이너리를 실행
  • cargo test에 의해 생성된 바이너리의 기본 동작: 모든 테스트를 병렬로 실행하고 테스트가 수행되는 동안 발생된 출력을 캡처하는 것
  • cargo test에 전달할 인수를 먼저 나열하고, -- 구분자 (separator) 를 쓰고, 그 뒤에 테스트 바이너리에게 전달할 인수를 나열
  • cargo test --help 명령어: cargo test 명령어에 사용 가능한 옵션을 표시
  • cargo test -- --help 명령어: 구분자 이후에 사용 가능한 옵션을 표시

2.1. 테스트를 병렬 혹은 순차적으로 실행하기

  • 여러 테스트가 동시에 실행되므로, 각 테스트가 공유 상태(공유 자원, 현재 작업 디렉터리, 환경 변수 등)를 갖거나 다른 테스트에 의존해서는 안 됩니다.
  • 한 가지 해결책은 각 테스트가 서로 다른 파일에 작성하도록 만드는 것일 테고,
  • 다른 해결책은 테스트를 한 번에 하나씩 실행하는 것
$ cargo test -- --test-threads=1

2.2. 함수 출력 표시하기

  • 테스트에서 println! 매크로를 호출해도, 해당 테스트가 성공하면 터미널에서 println!의 출력을 찾아볼 수 없습니다.
  • 테스트가 실패하면 표준 출력으로 출력됐던 모든 내용이 실패 메시지 아래에 표시
  • 성공한 테스트에서 출력한 내용도 보고 싶다면, 러스트에게 --show-output 옵션을 전달하여 성공한 테스트의 출력도 표시하도록 할 수 있습니다.
  • $ cargo test -- --show-output

2.3. 이름을 지정해 일부 테스트만 실행하기

2.3.1. 테스트 하나만 실행하기

  • cargo test 명령어에 테스트 함수 이름을 전달하여 해당 테스트만 실행할 수 있음
  • $ cargo test one_hundred

2.3.2. 테스트를 필터링하여 여러 테스트 실행하기

  • 테스트 이름의 일부만 지정하면 해당 값에 맞는 모든 테스트가 실행됩니다.
  • 예를 들어, cargo test add 명령어를 실행하면,
    • add가 포함된 함수 모두가 실행됩니다.
  • 모듈 이름으로 필터링하면 해당 모듈 내 모든 테스트를 실행할 수 있습니다.

2.4. 특별 요청이 없다면 일부 테스트 무시하기

  • 시간이 오래 걸리는 테스트에 ignore 속성을 어노테이션하면 됩니다.
#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
    // code that takes an hour to run
}
  • cargo test -- --ignored 명령어를 사용하면 무시된 테스트만 실행할 수 있습니다.
  • 무시되었건 말건 간에 모든 테스트를 실행하고 싶다면,
    • cargo test -- --include-ignored를 실행할 수 있습니다.

3. 테스트 조직화

  • 러스트 커뮤니티는 테스트를 크게 유닛 테스트 (unit test, 단위 테스트라고도 함), 통합 테스트 (integration test) 두 종류로 나눕니다.
  • 유닛 테스트:
    • 작고 더 집중적
    • 한 번에 하나의 모듈만 테스트하며, 모듈의 비공개 인터페이스도 테스트할 수 있음
  • 통합 테스트
    • 완전히 라이브러리 외부에 위치
    • 따라서 여러분이 작성한 라이브러리를 외부 코드에서 사용할 때와 똑같은 방식을 사용
  • 하나의 테스트에서 잠재적으로 여러 모듈이 사용되기도 함

3.1. 유닛 테스트

  • 유닛 테스트의 목적
    • 각 코드 단위를 나머지 코드와 분리
    • 제대로 작동하지 않는 코드가 어느 부분인지 빠르게 파악하는 것
  • 유닛 테스트
    • src 디렉터리 내의 각 파일에, 테스트 대상이 될 코드와 함께 작성
  • 각 파일에 tests 모듈을 만들고, cfg(test)를 어노테이션하는 게 일반적인 관례

3.1.1. 테스트 모듈과 #[cfg(test)]

  • #[cfg(test)]:
    • 이 코드가 cargo build 명령어가 아닌 cargo test 명령어 실행 시에만 컴파일 및 실행될 것을 러스트에게 전달
  • 라이브러리 빌드 시 테스트 코드는 제외되므로, 컴파일 소요 시간이 짧아지고, 컴파일 결과물 크기도 줄어듦
  • 통합 테스트는 별도의 디렉터리에 위치하기 때문에 #[cfg(test)] 어노테이션이 필요 없음
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}
  • cfg 속성(attribute)은 설정 (configuration) 을 의미
    • 러스트는 이 아이템을 특정 설정(configuration) 옵션 적용 시에만 포함
    • 이 경우 옵션 값은 러스트에서 테스트를 컴파일, 실행하기 위해 제공하는 test
  • cfg 속성을 사용하면 카고는 cargo test 명령어를 실행할 때만 테스트 코드를 컴파일

3.1.2. 비공개 함수 테스트하기

  • 러스트의 비공개 규칙은 비공개 함수를 테스트하도록 허용
  • src/lib.rs
pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}
  • 자식 모듈 내의 아이템은 자기 조상 모듈에 있는 아이템을 사용할 수 있습니다.
  • 이 테스트에서는 use super::*를 사용하여 test 모듈의 부모에 있는 아이템을 모두 스코프 안으로 가져오고 있고, 따라서 테스트가 internal_adder를 호출할 수 있습니다.

3.2. 통합 테스트

  • 통합 테스트: 외부 코드와 마찬가지로, 여러분이 만든 라이브러리의 공개 API만 호출 가능
  • 통합 테스트의 목적: 라이브러리의 여러 부분을 함께 사용했을 때 제대로 작동하는지 확인하는 것

3.2.1. tests 디렉터리

  • 카고는 디렉터리 내 통합 테스트 파일을 자동으로 인식
  • 그런 다음에는 원하는 만큼 통합 테스트 파일을 만들 수 있고, 카고는 각 파일을 개별 크레이트로 컴파일
    • 즉, integration_test.rs 가 하나의 개별 크레이트가 된다.
adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs
  • tests/integration_test.rs
use adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}
  • tests 디렉터리의 각 파일은 별개의 크레이트이므로, 각각의 테스트 크레이트의 스코프로 우리가 만든 라이브러리를 가져올 필요가 있습니다.
  • 이러한 이유로 코드 최상단에 use adder를 추가했는데, 이는 유닛 테스트에서는 필요 없던 것이지요.

tests/integration_test.rs 내 코드는 #[cfg(test)]가 필요 없습니다.

  • 카고는 tests 디렉터리를 특별 취급하여, 디렉터리 내 파일을 cargo test 시에만 컴파일함
$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

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

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test 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
  • 출력에 유닛 테스트, 통합 테스트, 문서 테스트 세 가지 절이 만들어졌네요.
  • 어떤 절 안에 어떠한 테스트라도 실패하면, 그다음 절은 실행되지 않음을 유의
  • 특정 통합 테스트 파일의 모든 테스트를 실행하려면,
    • cargo test 명령어에 --test 인수로 파일명을 전달하면 됩니다.

3.2.2. 통합 테스트 내 서브 모듈

  • tests 내 각 파일은 각각의 크레이트로 컴파일되는데,
    • 이는 각 통합 테스트 파일이 각각의 크레이트로 취급된다는 점 때문에
    • 여러분이 만든 크레이트를 사용할 실제 사용자처럼 분리된 스코프를 만들어 내는 데에는 유용
  • src 디렉터리에서 코드를 모듈과 파일로 분리하여 동일한 동작을 공유하는 것을
    • tests 디렉터리 내 파일에서는 할 수 없음을 의미
  • 여러 통합 테스트 파일에서 유용하게 사용할 도우미 함수 묶음을 공통 모듈로 분리하려 할 때, tests 디렉터리 파일의 동작 방식은 걸림돌이 됩니다.
  • 테스트 출력 결과에서 common을 제외하려면
    • tests/common.rs 파일 대신
    • tests/common/mod.rs 파일을 생성해야 합니다.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs
  • 이러한 방식의 파일명 규칙을 따르는 파일은 통합 테스트 파일로 취급하지 않습니다.
  • tests 디렉터리의 서브 디렉터리 내 파일은 별도 크레이트로 컴파일되지 않고, 테스트 결과 출력에서 별도의 출력 절이 생성되지도 않습니다.
  • tests/common/mod.rs 파일을 생성하고 나면 다른 통합 테스트 파일에서 모듈처럼 사용할 수 있습니다.
use adder;

mod common; 

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}
  • use
    • adder는 src/lib.rs 파일에서 정의된 라이브러리 크레이트
    • src/lib.rs에 정의된 모든 공개(public) 항목을 가져오는 역할
  • mod
    • mod 키워드는 새로운 모듈을 정의하거나 이미 존재하는 모듈을 선언하는 데 사용
    • 이 경우, mod common;은
      • common 모듈이 tests/common/mod.rs 파일에 정의되어 있음을 나타냅니다.

3.2.3. 바이너리 크레이트에서의 통합 테스트

  • src/lib.rs 파일이 없고 src/main.rs 파일만 있는 바이너리 크레이트라면,
    • tests 디렉터리에 통합 테스트를 만들어서 src/main.rs 파일에 정의된 함수를 use 구문으로 가져올 수 없습니다.
  • 다른 크레이트에서 사용할 수 있도록 함수를 노출하는 건 라이브러리 크레이트 뿐입니다.
  • 바이너리 크레이트는 자체적으로 실행되게 되어있습니다.
  • 바이너리를 제공하는 러스트 프로젝트들이 src/main.rs 파일은 간단하게 작성하고, 로직은 src/lib.rs 파일에 위치시키는 이유 중 하나가 이 때문입니다.
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글