Rust용 강력한 테스트 단언 라이브러리 Test That!의 출시와 GoogleTest에서 포크한 이유, 그리고 설계 개선 사항을 소개합니다.
Test That!의 출시를 발표하게 되어 매우 기쁩니다. Test That!는 Rust에서 테스트 단언을 위한 강력한 라이브러리입니다. 이것은 GoogleTest Rust의 포크입니다.
Test That!를 사용하면 자신의 의도 를 정확히 지정하는 테스트 단언을 작성할 수 있습니다:
let vec = vec![5, 123, -4];
verify_that!(vec, each(gt(0)))
그리고 테스트가 실패했을 때 정보가 풍부하고 의미 있는 진단을 얻을 수 있습니다:
Value of: vec
Expected: only contains elements that is greater than 0
Actual: [5, 123, -4],
whose element #2 is -4, which is less than or equal to 0
GoogleTest와 비교하면, Test That!는 몇 가지 개선점을 제공합니다:
verify_that! 매크로와 관련 매크로에서 컨테이너에 매칭하는 축약 문법을 matches_pattern! 같은 다른 매크로 기반 matcher에도 확장했습니다. 그래서 다음과 같은 것도 작성할 수 있습니다:matches_pattern!(MyStruct { a_vec: [eq(1), eq(2), eq(3)] })
Result는 이제 TestResult라고 부르므로, 사용하고 싶은 다른 Result 타입과 충돌하지 않습니다.unordered_elements_are!의 이름을 contains_exactly!로 바꾸고, 그 matcher에 in_order() 메서드를 추가했습니다. 따라서 이제 elements_are! 대신 contains_exactly![...].in_order()를 사용합니다. 기존 구조는 늘 마음에 걸렸습니다. 이것은 시간이 지나며 확장된 GoogleTest C++ library에서 물려받은 것이었습니다. 저는 깔끔한 단절을 원했고, 이 구조가 더 자연스럽게 느껴집니다. 더 강한 제약이 더 약한 제약보다 더 많은 문법을 요구합니다.contains_each! 및 “상위집합” is_contained_in! matcher는 이제 in_order() 메서드를 사용해 요소들이 대응하는 matcher와 같은 순서에 있도록 강제하는 것도 지원합니다.contains_exactly!에서 HashMap에 매칭하는 문법은 이제 키-값 쌍을 화살표 연산자 =>로 표현합니다:let value = HashMap::from([(1, "one"), (2, "two"), (3, "three")]);
verify_that!(value, contains_exactly![eq(1) => eq("one"), eq(2) => eq("two"), eq(3) => eq("three")])
이전에는 이를 쌍으로 표현했기 때문에, 그 matcher로 쌍의 Vec에 매칭할 수 없었습니다.
Matcher trait를 분리하여, describe() 메서드가 이제 Describable라는 새 trait에 들어가도록 했습니다. 이를 통해 특정 경우에 코드 중복을 줄일 수 있습니다.GoogleTest에서 Test That!로 포팅하는 데 관심 있는 분들을 위해: 걱정하지 마세요! 기존 코드를 더 쉽게 포팅할 수 있도록 별칭을 추가하는 몇 가지 features를 포함해 두었습니다.
저는 몇 년 전 Google에서 일할 때 GoogleTest crate를 주도했습니다. 목표는 GoogleTest C++ library의 강력한 단언 기능을 Rust로 가져오는 것이었습니다. 저는 2023년에 회사를 떠난 직후까지 GoogleTest에 관여했습니다. 버전 0.12에서는 라이브러리의 설계 가정에 상당한 영향을 미친 중대한 변경이 도입되었습니다. 저는 이 변경이 개발자 경험을 극적으로 악화시켰다고 봅니다. 어떻게 그런지 몇 가지 예를 통해 살펴보겠습니다.
먼저 간단한 데이터 모델부터 시작해 봅시다:
#[derive(Debug)]
struct AStruct {
value: u32,
string: String,
}
이 struct의 값을 하나 가지고 있다고 합시다:
let value = AStruct {
value: 123,
string: "Hello, world!".into(),
};
이제 struct 안의 데이터가 내가 기대한 것과 같은지 단언하고 싶다고 해 봅시다. GoogleTest 0.11에서는 다음과 같이 보였을 것입니다:
verify_that!(
value,
matches_pattern!(AStruct {
value: eq(123),
string: eq("Hello, world!"),
})
)
GoogleTest 0.12 이후에서 같은 일을 시도하면, 몇 가지 오류가 발생합니다:
error[E0277]: can't compare `&u32` with `{integer}`
--> src/main.rs:25:13
|
23 | / verify_that!(
24 | | value,
25 | |/ matches_pattern!(AStruct {
26 | || value: eq(123),
27 | || string: eq("Hello, world!"),
28 | || })
| ||______________^ no implementation for `&u32 == {integer}`
29 | | )
| |__________- required by a bound introduced by this call
|
잠깐, 뭐라고요? 참조와 무언가를 비교한다고 말하고 있습니다. 그런데 내 코드 어디에도 참조는 보이지 않습니다.
좋습니다. 그렇다면 숫자 앞에 참조를 붙여 보겠습니다:
verify_that!(
value,
matches_pattern!(AStruct {
value: eq(&123),
string: eq("Hello, world!"),
})
)
좋아요, 그럼 이제 GoogleTest에서는 숫자 앞에 참조를 붙여야 하나 보군요. 그럼 이 지식을 다른 곳에도 적용해 봅시다:
let value = 123;
verify_that!(value, eq(&123))
이제 컴파일하면 다음이 나옵니다:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:36:29
|
36 | verify_that!(value, eq(&123))
| --------------------^^^^^^^^-
| | |
| | no implementation for `{integer} == &{integer}`
| required by a bound introduced by this call
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
= help: the following other types implement trait `PartialEq<Rhs>`:
f128
f16
f32
f64
i128
i16
i32
i64
and 8 others
= note: required for `EqMatcher<&{integer}>` to implement `googletest::matcher::Matcher<{integer}>`
좋습니다. 그럼 참조를 없애고 무슨 일이 일어나는지 봅시다:
let value = 123;
verify_that!(value, eq(123))
이건 컴파일됩니다. 알고 보니 그 문맥에서는 참조를 붙이면 안 되고, 이전 문맥에서는 반드시 붙여야 합니다.
점점 헷갈리기 시작합니다.
다른 것을 시도해 봅시다. 다음과 같은 newtype이 있다고 합시다:
#[derive(Debug)]
struct NewType(u32);
이전에는 아마 다음과 같이 매칭했을 것입니다:
let value = NewType(123);
verify_that!(value, matches_pattern!(NewType(eq(123))))
요즘은 다음처럼 해야 하는 것 같습니다:
let value = NewType(123);
verify_that!(value, matches_pattern!(NewType(eq(&123))))
이것은 잘 컴파일되고 실행됩니다. 그런데 이제 NewType을 Copy가 되도록 바꾼다고 해 봅시다:
#[derive(Debug, Clone, Copy)]
struct NewType(u32);
그러자 갑자기 테스트가 더 이상 컴파일되지 않습니다!
error[E0277]: can't compare `u32` with `&{integer}`
--> src/main.rs:46:29
|
46 | verify_that!(value, matches_pattern!(NewType(eq(&123))))
| --------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-
| | |
| | no implementation for `u32 == &{integer}`
| required by a bound introduced by this call
|
help: the trait `PartialEq<&{integer}>` is not implemented for `u32`
but trait `PartialEq<u32>` is implemented for it
--> /home/hovinen/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/cmp.rs:1875:13
|
1875 | impl const PartialEq for $t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
1897 | / partial_eq_impl! {
1898 | | bool char usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f16 f32 f64 f128
1899 | | }
| |_____- in this macro invocation
= help: for that trait implementation, expected `u32`, found `&{integer}`
= note: required for `EqMatcher<&{integer}>` to implement `googletest::matcher::Matcher<u32>`
= note: 3 redundant requirements hidden
= note: required for `CompileAssertAndMatch<NewType, IsMatcher<'_, ...>>` to implement `googletest::matcher::Matcher<NewType>`
이건 정말 매우 이상합니다. 기존 struct에 Copy를 추가하는 것은 호환 가능한 변경이어야 합니다. 결국 타입에 기능(및 제약)을 추가하는 것 뿐이니까요. 그런데 그 때문에 그 타입의 downstream 사용자가 깨져서는 안 됩니다.
이것들은 몇 가지 단순한 예에 불과합니다. 더 많은 것을 하려 할수록 더 혼란스러워집니다. (ref 키워드 이야기는 시작도 하지 않겠습니다!) 여기에 깊고 복잡한 데이터 구조와 그 위에 얹힌 복잡한 단언이 가득한 거대한 코드베이스를 곱하면, 금세 엄청난 혼란으로 번집니다.
GoogleTest의 핵심 아이디어 중 하나는 단위 테스트를 작성하는 일이 수월해야 한다는 것이었습니다. 유용한 단언을 작성하는 데 많은 정신적 대역폭을 쏟아서는 안 됩니다. “이 속성 하나만 단언하면서도 단언 실패 메시지가 의미 있게 나오게 하려면 어떻게 해야 하지?”라고 고민할 필요가 없어야 합니다. matcher가 그것을 알아서 처리하게 두면 됩니다.
분명 충분히 오래 앉아서 고민하면 이것을 헤쳐 나갈 올바른 정신 모델을 찾을 수는 있을 것입니다. 하지만 그것은 상당한 정신적 대역폭을 요구하며, 그렇게 투자할 만한 매우 좋은 정당성이 있어야 합니다. 특히 그 투자를 저 혼자만 해야 하는 것이 아니라, 동료와 협업자들도 똑같이 하도록 설득해야 하니까요.
그렇다면 왜 이런 변경이 도입되었을까요? 사실 GoogleTest의 기존 설계에는 몇 가지 제한이 있었습니다. 아마 99%의 경우에는 잘 동작했겠지만, 특정한 코너 케이스에서는 꽤 심하게 실패했습니다. 아래에서는 그 제한 중 일부와, 제가 어떻게 그렇게 큰 대가를 치르지 않고 그것들을 제거할 수 있었는지를 보여 드리겠습니다.
저는 제 프로젝트들을 포팅하지 않고 GoogleTest 0.11에 머무르기로 했습니다. 하지만 시간이 지나면서 GoogleTest 0.11의 여러 결점과 한계를 무시하기가 점점 어려워졌습니다. 그리고 업그레이드는 제게 선택지가 아니었기 때문에, 문자 그대로 고아가 된 라이브러리를 쓰고 있었고, 그 문제들이 upstream에서 해결될 가능성도 없었습니다.
어떤 종류의 문제를 말하는 걸까요?
우선, matcher로 할 수 있는 일에는 성가신 제한이 몇 가지 있었습니다. 위 예제들에 등장한 matches_pattern! 매크로를 생각해 봅시다. 이것을 사용하면 메서드의 반환값에 매칭할 수 있었습니다:
impl AStruct {
fn get_value(&self) -> u32 {
self.value
}
}
verify_that!(value, matches_pattern!(AStruct {
get_value(): eq(123),
}))
하지만 반환값이 슬라이스나 문자열 슬라이스일 때는 이것이 동작하지 않았습니다:
impl AStruct {
fn get_string(&self) -> &str {
&self.string
}
}
verify_that!(value, matches_pattern!(AStruct {
get_string(): eq("Hello, world!"), // Compiler error!
}))
저는 Option<&SomeType>를 반환하는 메서드에는 매칭할 수 없다는 사실도 알게 되었고, 이는 anyhow 오류의 source에 대한 단언을 어렵게 만들었습니다.
또한 내부에 들고 있는 참조의 lifetime을 좁히는 메서드에서는 걸려 넘어졌고, 빌린 값이 아니라 소유한 값을 만들어 내는 컨테이너도 지원하지 않았습니다.
시간이 지나면서 이런 제한들이 쌓이기 시작했고 실제로 꽤 골치 아픈 문제를 일으켰습니다. 그래서 저는 라이브러리의 근본적인 구조를 바꾸지 않고 이 제한들을 어떻게 해결할 수 있을지 조사하기 시작했습니다.
GoogleTest 0.11에는 서로 상호작용하는 두 가지 설계 결함이 있습니다. 이것들을 고침으로써 저는 위에서 언급한 모든 한계를 해결할 수 있었습니다.
첫째, 저는 원래 API 표면을 가능한 한 작게 유지하고 싶었습니다. 이는 대부분의 matcher 함수가 불투명한 Matcher 구현을 반환하고, 구체적인 타입은 라이브러리 내부에 비공개로 남는다는 뜻이었습니다. (단순화한) matcher는 대략 다음처럼 생겼을 수 있습니다:
pub fn eq<T>(value: T) -> impl Matcher {
EqMatcher { value }
}
struct EqMatcher<T> {
value: T,
}
Matcher trait 자체는 어떤 타입에 대해 매칭할 수 있는지를 알아야 합니다. 이를 하는 방법은 두 가지입니다. Matcher의 타입 매개변수로 두는 방법과, 이 trait의 연관 타입으로 두는 방법입니다. 처음에는 타입 매개변수를 택했다가 타입 해석 문제에 데이고, 결국 연관 타입을 사용하기로 했습니다.
impl<T> Matcher for EqMatcher<T> {
type ActualT = ???
fn matches(&self, actual: &Self::ActualT) -> MatcherResult {...}
}
여기에는 그냥 T를 넣을 수도 있겠지만, 그러면 matcher가 조금 지나치게 경직됩니다. 예를 들어 문자열 슬라이스와 소유한 String의 동등성을 비교하는 것은 완전히 유효합니다:
let slice = "Hello, world";
let string = String::from("Hello, world");
assert!(slice == string);
따라서 actual 타입과 expected 타입이 같을 필요는 없습니다. 그저 서로 비교 가능하기만 하면 되며, 이는 PartialEq trait로 표현됩니다.
이를 지원하려면, 매칭 대상 타입을 나타내는 또 다른 타입 매개변수가 필요합니다.
impl<ExpectedT, ActualT: PartialEq<ExpectedT>> Matcher for EqMatcher<ExpectedT> {
type ActualT = ActualT
...
}
하지만 이것은 컴파일되지 않습니다. impl 블록의 타입 ActualT가 제약되지 않았기 때문입니다. 이는 expected 값의 타입과 비교 가능한 어떤 ActualT 타입에도 적용됩니다. 그러나 고정된 어떤 ExpectedT에 대해서든 구현은 하나만 있을 수 있습니다. 그래서 컴파일러는 어느 구현을 골라야 할지 알 수 없습니다.
이를 해결하려면 ActualT를 struct와 matcher 함수의 타입 매개변수로 만들어야 했습니다.
pub fn eq<ExpectedT, ActualT>(value: ExpectedT) -> impl Matcher {
EqMatcher { value, PhantomData }
}
struct EqMatcher<ExpectedT, ActualT> {
value: ExpectedT,
phantom: PhantomData<ActualT>,
}
여기서 문제가 생깁니다. 이것은 ActualT 타입이 eq의 호출 지점에서 고정된다는 뜻입니다. 이제 ActualT가 lifetime을 가진 참조라고 가정해 봅시다. 그 lifetime은 타입의 일부입니다. 그러면 이제 lifetime 이 고정됩니다. 그런데 실제 값을 closure를 통해 추출하는 matcher들이 있습니다. 예를 들어 matches_pattern!에서 속성값을 얻는 방식이 바로 이것입니다. 그런 경우, 그 lifetime은 호출 지점에서 알 수 없습니다. 코드는 개념적으로 다음처럼 동작합니다:
let matcher = eq(expected);
let closure = |s: &MyStruct| s.get_value();
matcher.matches(closure(actual));
MyStruct::get_value()가 'static lifetime을 가진 소유 값을 반환한다면 문제는 없습니다. 하지만 closure의 매개변수 lifetime에 묶인 값을 반환한다면, matcher의 타입은 너무 경직되어 있습니다. 매칭 대상 타입은 고정된 lifetime에 대해서가 아니라 모든 lifetime에 대해 lifetime bound를 만족해야 합니다.
Test That!는 세 가지 핵심 변경으로 이것을 해결합니다:
Matcher trait는 이제 다시 actual 값을 타입 매개변수로 받습니다. 이를 통해 여러 타입에 대해 자유롭게 인스턴스화할 수 있습니다. 특히 actual 타입의 lifetime을 더 이상 고정할 필요가 없습니다.Matcher impl이 아니라 구체적인 struct를 그대로 반환합니다.결과는 대략 다음과 같습니다:
pub fn eq<ExpectedT>(value: ExpectedT) -> EqMatcher<ExpectedT> {
EqMatcher { value }
}
struct EqMatcher<ExpectedT> {
value: ExpectedT,
}
impl<ActualT, ExpectedT> Matcher<ActualT> for EqMatcher<ExpectedT> {
fn matches(&self, actual: &ActualT) -> MatcherResult {...}
}
이 구조는 GoogleTest의 오래된 버전들에 알려진 제한을 해결합니다. 이제 슬라이스와 문자열 슬라이스를 반환하는 메서드도 matches_pattern!와 자연스럽게 동작합니다. 참조를 포함한 구조체를 반환하는 메서드도 여러 조합에서 마찬가지입니다. 몇 가지 추가적인 기법을 통해, 참조가 아니라 소유 값을 순회하는 컨테이너에 대한 지원도 추가할 수 있었습니다. 지원되어야 마땅한데 지원되지 않는 경우는 아직 찾지 못했습니다. (self를 소비하는 메서드 같은 일부 경우는 의도적으로 계속 지원하지 않습니다.)
그리고 이렇게 해도 downstream 코드에서는 Matcher 구현 자체 외에는 아무 변경도 필요하지 않습니다. 따라서 기존의 단언 사용은 깨지지 않고, 라이브러리의 근본적인 설계 결정도 그대로 유지됩니다.
제 생각에는 이미 그 배는 떠났습니다. GoogleTest에 대한 변경은 매우 깊었습니다. 그것은 이를 사용하는 거의 모든 단언 코드에 영향을 줍니다. 위에서 설명한 변경을 upstream에 넣어 원래의 단언 모델을 복원하려면, GoogleTest에 의존하는 모든 코드에서 그 작업을 다시 되돌려야 할 것입니다. Google의 사람들에게 그 대가를 치르도록 설득하는 데 제가 성공할 것 같지는 않습니다. 그리고 Test That!가 제공하는 개선을 활용하는 일을 그런 일이 일어나는 데 달려 있게 하고 싶지도 않습니다.
저는 Rust가 훌륭한 테스트 단언 라이브러리를 갖기를 정말 바랍니다. 그래서 Test That!가 성공하기를 정말 바랍니다. 버전 1.0을 릴리스하기 전에 피드백을 모으고 crate를 조금 더 다듬을 계획입니다. 이것이 Rust 생태계에서 테스트를 위한 대표적인 선택지가 되기를 바랍니다.
그러니 부디 직접 써 보세요. 여러분의 피드백을 기대하겠습니다!