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
그리고 syn
과 quote
크레잇 기능도 필요하므로 다음과 같이 종속성을
추가한다.
[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 {
// ...
}
속성형 매크로를 위한 함수에는 파라미터가 두 개다. attr
은 p-test
속성의 입력에 대한 TokenStream
이고, item
은 p-test
속성을 추가한
대상 요소, 즉 테스트 함수 test_sum
의 TokenStream
이다.
#[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
인스턴스를 만들어 리턴한다.
Input
은 TestCase
의 리스트일 뿐이므로, 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
제약사항
속성에 입력으로 사용하는 테스트 케이스 이름은 유효한 함수 이름이어야 한다. 공백이나 특수문자를 포함한 일반 문자열로 테스트 케이스 이름을 지정할 수 있으면 좋을 것 같은데, 적절한 방법을 찾지 못했다.
참고
- p-test 크레잇을 Crates.io에 공개해 두었다.
- rstest: Crates.io에는 매개변수 테스트를 위한 크레잇이 있지만 rstest가 가장 널리 사용되는 것 같다.
- How can I create parameterized tests in Rust?