당신의 Unit Test는 기억한 케이스만 커버한다

당신은 reverse 함수를 작성했다. [1, 2, 3][4, 5, 6, 7]로 테스트했다. 통과했다. 배포했다.

한 사용자가 한 개의 원소를 가진 slice를 넘겼다. 당신의 함수는 그것을 무시하고 지나쳤다. 이슈가 열렸다. 당신은 테스트 파일을 응시하며 이렇게 뻔한 것을 어떻게 놓쳤는지 의아해한다.

놓친 이유는 example-based testing이 당신이 예상하는 버그만 잡기 때문이다. 테스트 스위트의 모든 assert_eq!은 실패가 어디에 숨어 있을지에 대한 추측이다. Property-based testing은 그 추측을 무작위 데이터와 수학적 invariant로 대체한다. 한 원소 버그도 찾아낼 것이다. 빈 벡터 버그, 정수 오버플로우 엣지 케이스, 그리고 당신이 존재조차 몰랐던 Unicode 코너 케이스도 찾아낼 것이다.

예시와 기대값이 아닌, 무작위 입력과 Invariant

Property-based testing은 판도를 뒤집는다. 입력을 선택하고 정확한 출력을 assert하는 대신, 모든 입력에 대해 성립해야 하는 property를 정의하고, 테스트 프레임워크가 그것을 깨려고 수백 개의 무작위 입력을 생성한다.

고전적인 예시는 리스트를 뒤집는 것이다. Property는 “[1, 2, 3]을 뒤집으면 [3, 2, 1]이다”가 아니다. Property는 “리스트를 두 번 뒤집으면 원본이 돌아온다”이다. 이 invariant는 모든 가능한 리스트에 대해 성립하므로, 프레임워크는 무작위 리스트를 던져서 무언가 깨지거나 반복 한도에 도달할 때까지 시도한다.

Rust에는 이를 위한 두 개의 성숙한 라이브러리가 있다. quickcheck은 Haskell에서 포팅된 첫 번째 인기 옵션이었다. proptest은 더 새로운 접근법으로, 결정적인 기능인 automatic shrinking을 제공한다. proptest가 실패하는 입력을 찾으면, 백 개의 원소를 가진 벡터를 출력하고 디버깅을 당신에게 맡기지 않는다. 입력을 단순화해서 가장 작은 counterexample을 찾을 때까지 축소한다. 이것이 실패한 테스트 출력을 읽는 것과 실제로 버그를 이해하는 것 사이의 차이다.

Shrinking이 실제 버그를 찾아내는 방식

Shrinking은 비밀 재료이다. proptest가 47개의 정수를 가진 무작위 벡터를 생성하고 당신의 property가 실패한다고 하자. 버그는 47번째 원소가 아니다. 버그는 아마도 빈 벡터나 부호 변화, 중복 값과 관련이 있다.

proptest은 이를 안다. 원소를 제거해 본다. 값을 0으로 만들어 본다. 벡터를 점점 더 작게 만들어 본다. 더 작은 입력으로도 테스트가 실패하면 계속 shrinking한다. 더 단순한 변형이 모두 통과할 때 멈춘다. 결과는 minimal counterexample이다.

이것은 그저 좋은 기능이 아니다. Property-based testing이 실무에서 사용 가능한 이유이다. Shrinking이 없으면, 다시 건초더미를 디버깅하게 된다. Shrinking이 있으면, 바늘을 얻는다.

실제 버그, 실제 테스트

여기에 미묘한 버그가 있는 reverse 함수가 있다. 테스트가 찾기 전에 당신이 먼저 발견할 수 있는지 보자.

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = Vec::with_capacity(xs.len());
    for i in 1..xs.len() {
        rev.push(xs[xs.len() - i].clone());
    }
    rev
}

범위가 0이 아닌 1에서 시작한다. 비어 있지 않은 모든 리스트에 대해 첫 번째 원소가 조용히 누락된다. [1, 2, 3]을 사용한 unit test는 작성자가 출력이 뒤집혀 보이는지만 확인하고 길이가 맞는지는 확인하지 않았다면 통과할 수 있다. 하지만 property는 즉시 이를 잡아낸다.

Cargo.tomlproptest을 추가하라:

[dev-dependencies]
proptest = "1.6"

그리고 property를 작성하라:

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
        let rev = reverse(&xs);
        let rev_rev = reverse(&rev);
        prop_assert_eq!(xs, rev_rev);
    }
}

cargo test를 실행하면 출력이 이야기를 들려준다:

thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
  left: `[0]`,
 right: `[]`'

proptest은 실패한 무작위 벡터를 단일 원소 [0]으로 축소했다. 첫 번째 호출은 []을 반환한다. 두 번째 호출도 []을 반환한다. Property는 [] == [0]을 요구하므로 실패한다. 입력이 최소화되면 버그는 명백해진다.

구조적 property를 직접 테스트할 수도 있다:

proptest! {
    #[test]
    fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
        prop_assert_eq!(xs.len(), reverse(&xs).len());
    }
}

이것은 동일한 최소 입력으로 실패하지만, 훨씬 더 명확한 오류를 제공한다: 출력 길이는 1이 아닌 0이다.

왜 여전히 일반 Unit Test가 필요한가

Property-based testing은 example-based test를 대체하는 것이 아니다. 보완이다.

Unit test는 의도를 문서화한다. 새로운 개발자가 reverse(&[1, 2, 3])[3, 2, 1]과 같아야 한다는 것을 읽을 때, 그 함수가 무엇을 해야 하는지 알게 된다. Property test는 “이 invariant가 성립한다”고 말하지만, 정상 케이스에서 출력이 어떻게 보이는지는 알려주지 않는다.

Property는 example보다 작성하기 어렵다. State machine, side effect, I/O는 순수 함수에 깔끔하게 매핑되지 않는다. proptest은 복잡한 타입을 위한 strategy를 가지고 있지만, 연결하는 데는 몇 개의 assert_eq! 호출보다 더 많은 사고가 필요하다.

시간 비용도 있다. Property test는 천 번의 반복을 실행할 수 있다. 대규모 코드베이스에서는 그것이 누적된다. Property test는 CI에서 실행하지, 개발 중 매 키 입력마다 실행하지는 않는다.

무작위 테스트도 재현 가능할 수 있다

무작위성은 사람들을 불안하게 한다. 테스트가 가끔만 실패하면, 그것은 코드 때문인가 테스트 때문인가?

proptest은 이를 실패한 seed를 지속적으로 저장함으로써 처리한다. Property가 실패하면, proptest-regressions/ 디렉터리의 파일에 seed와 shrunk input을 저장한다. 다음 실행은 새로운 무작위 데이터를 생성하기 전에 그 정확한 케이스를 재생한다. Flaky property test는 거의 항상 buggy property이지, flaky framework가 아니다.

엄격한 결정론이 필요하다면, 테스트 설정에서 seed를 고정할 수 있다. 대부분의 팀은 그렇게 하지 않는다. Regression 파일만으로 충분하다.

Property Test가 빛을 발하는 곳

Property-based testing은 코드에 명확한 수학적 invariant가 있고 입력 공간이 클 때 그 비용을 감당할 가치가 있다.

정렬이 전형적인 예시이다. Property는 단순하다: 출력은 정렬되어 있고, 입력의 permutation이며, 길이가 같다. 버그가 있는 정렬은 손으로 작성한 example은 통과할 수 있지만, 중복 원소가 있는 무작위 데이터에서는 실패할 수 있다.

Parser와 serializer는 또 다른 sweet spot이다. parse(serialize(x)) == x와 같은 round-trip property는 특정 바이트 시퀀스에서만 나타나는 인코딩 버그를 잡아낸다.

산술, collection, state transition이 있는 모든 것은 이익을 얻는다. 외부 API, 엄격한 타이밍, 인간의 판단이 관련된 모든 것은 그렇지 않다.

하나의 Property부터 시작하라

테스트 스위트를 다시 작성할 필요는 없다. 명확한 invariant를 가진 하나의 순수 함수를 고른다. dev-dependenciesproptest을 추가한다. 하나의 property를 작성한다. 실행하고 통과하는지 지켜본 다음, 미묘한 버그를 도입하고 수 초 안에 디버깅할 수 있는 무언가로 실패를 shrinking하는지 지켜본다.

무작위 테스트가 당신이 example을 작성할 수 없었던 버그를 찾는 것을 보면, 더 자주 property를 찾게 될 것이다. 모든 테스트를 위해서가 아니다. 중요한 테스트를 위해서만.