Rust 매개변수 테스트

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Rust 매개변수 테스트

Rust에서는 #[test] 속성을 이용해 테스트 함수를 작성할 수 있지만, 매개변수 테스트를 위한 별도 지원은 없다. 매개변수 테스트를 작성하려면 rstest와 같은 외부 크레잇을 사용해야 한다.

예를 들어 다음과 같이 정의된 sum 함수를 테스트하고 싶다고 하자.

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

rstest로는 다음과 같이 매개변수 테스트를 작성할 수 있다.

#[rstest]
#[case(1, 1, 2)]
#[case(2, 3, 5)]
#[case(4, 5, 9)]
fn test_sum(#[case]a: i32, #[case]b: i32, #[case]expected: i32) {
    assert_eq!(sum(a, b), expected);
}

테스트를 실행하면 다음과 같은 결과를 얻는다.

$ cargo test
...

running 3 tests
test test_sum::case_1 ... ok
test test_sum::case_2 ... ok
test test_sum::case_3 ... ok

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

마음에 들지 않는다. 매개변수 테스트를 기술하는 방법도 마음에 들지 않고, 결과가 표시되는 방식도 마음에 들지 않는다.

case가 테스트 결과가 case_1, case_2와 같은 식으로 표시되는데, 특정 case에 테스트가 실패한다면 어떤 경우 테스트가 실패했는지 알기 위해 숫자를 보고 위에서부터 하나씩 세어 확인해야 한다.

매개변수 테스트 매크로 레이아웃

매개변수 테스트를 다음과 같은 식으로 작성할 수 있으면 좋겠다 싶어 매크로를 만들어보기로 했다.

#[p_test(
    (sum_1_1, 1, 1, 2),
    (sum_2_3, 2, 3, 5),
    (sum_4_5, 4, 5, 9),
)]
fn test_sum(a: i32, b: i32, expected: i32) {
    assert_eq!(sum(a, b), expected);
}

p_test 속성에 튜플의 리스트를 지정하며, 각 튜플은 테스트 케이스를 나타낸다. 튜플의 첫 번째 요소는 테스트 케이스 이름으로 매크로를 확장했을 때 테스트 함수로 전환될 것이다.

그 다음부터는 인자 목록인데, 매개변수 테스트 함수 test_sum의 인자로 사용된다. 이 예에서는 마지막 인자를 expected로 사용했지만, 사실 인자 순서는 중요하지 않고 test_sum 함수의 인자와 순서가 같으면 된다.

크레잇 생성

절차형 매크로는 별도 크레잇에 정의해야 한다. 매크로를 위한 크레잇을 다음과 같이 생성한다. 이름은 p-test로 정했다. parameterized나 parameterized_test 같은 이름은 이미 다른 크레잇이 선점해 쓸 수 없다. p는 당연히 parameterized를 뜻한다.

$ cargo new p-test --lib

p-test 디렉터리로 들어가 Cargo.toml 파일을 열어 다음을 추가한다.

[lib]
proc-macro = true

그리고 synquote 크레잇 기능도 필요하므로 다음과 같이 종속성을 추가한다.

[dependencies]
quote = "1.0"
syn = {version = "2.0", features = ["full"]}

매크로 작성 계획

이제 매크로를 작성할 차례다. #[p-test(...)]는 속성형 매크로이므로 lib.rs 파일을 열어 다음과 같이 함수를 정의한다.

#[proc_macro_attribute]
pub fn p_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    // ...
}

속성형 매크로를 위한 함수에는 파라미터가 두 개다. attrp-test 속성의 입력에 대한 TokenStream이고, itemp-test 속성을 추가한 대상 요소, 즉 테스트 함수 test_sumTokenStream이다.

#[p_test(
    (sum_1_1, 1, 1, 2),                      // + attr: TokenStream
    (sum_2_3, 2, 3, 5),                      // |
    (sum_4_5, 4, 5, 9),                      // |
)]
fn test_sum(a: i32, b: i32, expected: i32) { // + item: TokenStream
    assert_eq!(sum(a, b), expected);         // |
}                                            // |

이 매크로를 확장해 다음과 같이 코드를 생성해야 한다.

fn test_sum(a: i32, b: i32, expected: i32) {
    assert_eq!(sum(a, b), expected);
}

#[cfg(test)]
mod test_sum {
    use super::*;
    #[test]
    fn sum_1_1() {
        test_sum(1, 1, 2);
    }
    #[test]
    fn sum_2_3() {
        test_sum(2, 3, 5);
    }
    #[test]
    fn sum_4_5() {
        test_sum(4, 5, 9);
    }
}

item에 대해서는 특별한 작업이 없다. 코드를 그대로 복사해주면 된다. 그러나 attr은 직접 파싱해 원하는 형태로 코드를 생성해야 한다.

속성 파싱

속성을 파싱한 데이터를 담아둘 구조체가 필요하다. 먼저 각 테스트 케이스를 담을 TestCase 구조체를 다음과 같이 정의한다.

struct TestCase {
    name: Ident,
    args: Punctuated<Expr, Token![,]>,
}

그리고 속성 전체 파싱 결과를 담을 구조체를 다음과 같이 정의한다.

struct Input {
    test_cases: Punctuated<TestCase, Token![,]>,
}

TestCase를 위한 Parse 트레잇은 다음과 같이 구현한다.

impl Parse for TestCase {
    fn parse(input: ParseStream) -> Result<Self> {
        let content;
        let _ = parenthesized!(content in input);
        let name = content.parse()?;
        let _ = content.parse::<Token![,]>()?;
        let args = Punctuated::<Expr, Token[,]>::parse_terminated(&content)?;
        Ok(TestCase { name, args })
    }
}

인자 리스트에서 각 인자는 튜플이므로 먼저 parenthesized!로 괄호를 벗겨내고 각 요소를 파싱해 테스트 케이스 이름과 인자를 알아낸 다음 TestCase 인스턴스를 만들어 리턴한다.

InputTestCase의 리스트일 뿐이므로, Input을 위한 Parse 트레잇은 다음과 같이 간단히 구현할 수 있다.

impl Parse for Input {
    fn parse(input: ParseStream) -> Result<Self> {
        let test_cases =
            Punctuated::<TestCase, Token![,]>::parse_terminated(input)?;
        Ok(Input { test_arguments })
    }
}

매크로 정의

이제 p_test 매크로 함수 안에서 다음과 같이 attr를 파싱해 Input을 얻을 수 있다.

#[proc_macro_attribute]
pub fn p_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    let attr_input = parse_macro_input!(attr as Input);
    // ...

코드를 생성할 때 테스트 함수를 호출해야 하므로, 다음과 같이 item을 파싱해 테스트 함수를 생성한다.

#[proc_macro_attribute]
pub fn p_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    // ...
    let item = parse_macro_input!(item as ItemFn);
    let p_test_fn_sig = &input.sig;
    let p_test_fn_name = &input.sig.ident;
    let p_test_fn_block = &input.block;

    let mut output = quote! {};
    output.extend(quote! {
        #p_test+fn_sig {
            #p_test_fn_block
        }
    });
    // ...
}

그리고 다음과 같이 TestCase 데이터를 이용해 코드를 생성한다.

#[proc_macro_attribute]
pub fn p_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    // ...
    let mut test_functions = quote! {};

    for case in attr_input.test_cases {
        let name = case.name;
        let mut arg_list = quote! {};
        for e in case.args {
            arg_list.extend(quote! { #e, });
        }
        test_functions.extend(quote! {
            #[test]
            fn #name() {
                #p_test_fn_name(#arg_list);
            }
        });
    }

    output.extend(quote! {
        #[cfg(test)]
        mod #p_test_fn_name {
            use super::*;
            #test_functions
        }
    });
    output.into()
}

테스트

매크로를 테스트하려면 별도 패키지가 필요하다. 다음과 같이 패키지를 만든다.

$ cargo new p-test-test

Cargo.toml을 열어 다음과 같이 p-test 종속성을 추가한다.

[dependencies]
p-test = { path = "../p-test" }

그리고 src/main.rs를 열어 다음 코드를 추가한다.

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

use p_test::p_test;

#[p_test(
    (sum_1_1, 1, 1, 2),
    (sum_2_3, 2, 3, 5),
    (sum_4_5, 4, 5, 9),
)]
fn test_sum(a: i32, b: i32, expected: i32) {
    assert_eq!(sum(a, b), expected);
}

VS Code에서 Rust를 개발한다면 rust-analyzer: Expand macro recursively at caret 명령을 이용해 매크로가 어떻게 확장되는지 쉽게 확인할 수 있다.

테스트를 실행하면 다음과 같은 결과를 볼 수 있다.

$ cargo test
...

running 3 tests
test test_sum::sum_1_1 ... ok
test test_sum::sum_2_3 ... ok
test test_sum::sum_4_5 ... ok

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

제약사항

속성에 입력으로 사용하는 테스트 케이스 이름은 유효한 함수 이름이어야 한다. 공백이나 특수문자를 포함한 일반 문자열로 테스트 케이스 이름을 지정할 수 있으면 좋을 것 같은데, 적절한 방법을 찾지 못했다.

참고