/ PROGRAMMING, RUST

Rust - Test Automation

Rust에서 자동화된 test를 어떻게 작성하는지 알아보겠습니다.

Rust에서 Test란 non-test code가 정상적으로 동작하는지 확인하는 Rust 함수 입니다. 앞서 간략히 소개했듯 #[test] attribute를 명시하여 test 함수 정의가 가능합니다. 그리고 cargo test 명령어로 테스트 함수를 실행할 수 있죠.

Rust 맛보기

Test 함수 작성

test 함수의 body는 다음 3가지 action을 수행하도록 작성됩니다.

  • 필요한 데이터 및 state 준비
  • test 대상 코드를 실행
  • 결과를 검증(Assertion)

새 라이브러리 프로젝트를 만들어 보자

새 library project를 cargo로 생성하면, test function이 포함된 test module이 자동으로 생성됩니다.

$ cargo new mylib --lib
pub fn add(left: i32, right: i32) -> i32 {
    left + right
}

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

    #[test]
    fn test_add() {
        assert_eq!(add(1, 2), 3);
    }
}

테스트 함수는 메인 스레드와는 별도의 스레드에서 실행됩니다. Test 함수에서 패닉이 발생하면 해당 테스트는 실패한 것으로 간주되고, 메인 스레드가 그 결과를 보고합니다.

assert!() 매크로는 테스트 함수를 작성할 때 유용합니다. 만약 argument로 전달된 condition이 False로 평가되면 panic!()을 호출하게 됩니다.

위에서 정의된 tests 모듈은 inner module이므로 use super::*;를 사용하여 outer module의 함수를 이용 가능하게 해줄 필요가 있습니다.

assert_eq!()assert_ne!() 매크로는 각각 두 argument가 같은지 다른지를 확인합니다. 각 Argument는 PartialEqDebug trait를 구현해야 합니다. 값의 비교와 assertion이 실패 했을 때 값의 출력을 가능하게 하기 위해서죠. 표준 라이브러리의 대부분의 타입들은 이미 이러한 trait를 구현하고 있습니다. Custom struct 또는 enum의 경우에는 #[derive(PartialEq, Debug)] attribute를 추가해주면 됩니다.

assert!(), assert_eq!(), assert_ne!() 매크로의 두 번째 인자로 실패 시 출력되는 custom message를 추가할 수도 있습니다.

#[should_panic]으로 테스트 실패 확인

#[should_panic] attribute를 사용하여 특정 테스트가 패닉을 발생시키는지 확인할 수 있습니다.

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        let x = 200;
        assert!(x < 100);
    }
}

이 attribute를 명시하면 해당 테스트가 패닉을 발생시키지 않으면 실패합니다. 반대로 패닉이 발생하면 성공합니다.

단 이 attribute 사용할 때에는 프로그램이 우리가 예상한 이유와 다른 이유로 panic이 발생할 수 있으니 주의해야 합니다.

더 정확하게 하기 위해 expected parameter를 사용할 수도 있습니다.

이 parameter는 failure message가 argument로 전달된 문자열을 포함하는지 확인합니다.

#[should_panic(expected = "must be less than or equal to 100")]

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

Test가 실패하면, 문자열을 포함하는 Err가 리턴되며 failure message와 함께 출력됩니다.

pub fn divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        return Err("division by zero".to_string());
    }
    Ok(dividend / divisor)
}

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

    #[test]
    fn test_divide() {
        assert_eq!(divide(10, 2), Ok(5));
        assert_eq!(divide(10, 0), Err("division by zero".to_string()));
    }
}

CLI options for cargo test

cargo test 명령어에 여러 옵션을 추가할 수 있습니다.

cargo test 자체에 대한 옵션을 줄 수도 있고, 해당 명령어로 컴파일되는 테스트 바이너리에 옵션을 줄 수도 있습니다.

cargo test --helpcargo test에 대한 도움말을 출력합니다. cargo test -- --help 는 테스트 바이너리에 대한 것입니다. 중간에 삽입된 -- separator는 cargo test 자체에 어떠한 옵션도 명시하지 않음을 의미합니다. 그 다음에 오는 Command Line Argument부터는 테스트 바이너리에 전달되는 것이죠.

Test 바이너리 옵션 예시

테스트를 실행할때 기본적으로는 여러 개의 테스트가 스레드를 이용해 병렬로 실행됩니다.

cargo test -- --test-threads=1은 테스트를 실행하는 스레드의 수를 1로 제한합니다. 기본값은 CPU 코어 수입니다.

cargo test -- --show-outputprintln!()등으로 출력된 내용을 보여줍니다. 기본적으로는 stdout은 출력되지 않고 test 결과만 출력됩니다.

Test 함수의 이름

cargo test의 argument로 함수의 이름을 전달하면 해당 테스트 함수만 실행할 수가 있습니다.

#[test]
fn test_add() {
    assert_eq!(add(1, 2), 3);
}

#[test]
fn test_sub() {
    assert_eq!(sub(2, 1), 1);
}
$ cargo test test_add

주의할 점은, 함수 이름이 부분적으로 일치하는 경우에도 해당 이름이 포함된 함수가 전부 실행된다는 것입니다.

테스트 함수 무시

#[ignore] attribute를 사용하여 오버헤드가 큰 특정 테스트 함수를 무시할 수 있습니다.

#[test]
#[ignore]
fn expensive_test() {
    // code that takes an hour to run
}
$ cargo test -- --ignored

--ignored flag를 사용하면 무시된 테스트 함수만 실행됩니다.

반대로, --include-ignored flag를 사용하면 무시된 테스트 함수를 포함하여 모든 테스트 함수를 실행합니다.

Test Organization

Rust community는 test의 유형을 크게 두 가지로 분리합니다.

  • Unit tests : 각 모듈, 함수, 구조체 등의 개별적인 부분을 테스트하는 것

  • Integration tests : 여러 모듈이나 라이브러리를 함께 테스트하는 것이며, library에 대해 external합니다. 오직 public interface만을 사용하며, 각 test당 여러 module을 활용합니다.

Unit tests

여기까지 다룬 내용이 사실 모두 unit test에 해당하며, 어떤 코드가 정상적으로 동작하지 않는지 빠르게 pinpointing 하는 데에 목적이 있습니다.

테스트 대상 코드가 있는 파일에 tests 모듈을 정의합시다. 해당 모듈에 #[cfg(test)] attribute를 추가하면 test module로 간주됩니다.

#[cfg(test)]는 Rust 컴파일러에게 해당 코드가 cargo test 명령어로 실행될 때만 컴파일되어야 한다는 것을 알려줍니다.

test 함수에서는 private function 또한 사용할 수 있습니다. 당연하게도 자식 모듈은 부모 모듈의 private function을 사용할 수 있기 때문이죠. (확인해 볼 것)

Integration tests

Integration test는 프로젝트 최상단(src 디렉토리가 위치한 곳) tests 디렉토리에 있는 파일로 작성됩니다. 이 디렉토리에는 여러 test 파일들을 만들 수 있으며, Cargo는 각 파일을 개별 crate로 컴파일할 것입니다.

Cargo는 cargo test 명령어를 실행할 때에만 tests 디렉토리에 있는 파일들을 컴파일합니다.

그리고 tests 폴더 내에서도 모듈을 별도 파일로 분리할 수 있습니다. 테스트에 사용되는 helper function들을 따로 정의하는 별도 submoudle 파일을 만들 수도 있죠.

Integration Tests for Binary Crates

src/main.rs만 있는 binary crate가 있다면 integration test를 수행할 수 없습니다. 그 이유는 main.rs에 정의된 함수를 use statement로 integration tests의 scope로 가져올 수가 없기 때문입니다.

오직 library crate만 다른 crate가 사용 가능한 함수를 노출할 수 있습니다.

전형적으로 Rust 프로젝트들이 단순한 형태의 src/main.rs를 갖고 상세 구현은 src/lib.rs에 정의되는 것이 일반적인 하나의 이유이기도 합니다.


참고 문헌

Rust 맛보기