속성 기반 테스트에서 말하는 ‘속성’이 실제로는 생성기와 어떻게 얽혀 있으며, PBT 라이브러리 설계에서 왜 그 경계가 중요한지를 탐구하는 글입니다.
postsresumeresearchprojectstalksdocuments |
속성이란 무엇인가?.
게시일 2026-04-05 :: 8분 읽기 :: 태그:🏷testing
속성 기반 테스트(Property-Based Testing)에 대해 이야기할 때, 우리는 대개 매우 추상적인 용어로 말합니다. 올바름을 정의하는 속성이 있고, 정의역을 정의하는 생성기가 있으며, PBT 프레임워크는 속성과 생성기를 결합해 버그를 찾는 속성 기반 테스트를 작성할 수 있는 API를 제공합니다. 전부 아주 깔끔하고 단순해 보입니다.
제 시간 중 (놀라울 정도로) 큰 부분은 서로 다른 PBT 프레임워크를 탐색하는 데 들어가고, 많은 경우 기존 PBT 작업 부하를 다른 프레임워크 대신 새로운 프레임워크로 포팅합니다. 그러려면 PBT 프레임워크가 무엇인지에 대한 추상화를 세워야 하는데, 첫 문단에서 제가 제시한 단순한 정의가 PBT를 잘 포착했다면 이는 아주 쉬웠어야 합니다. 안타깝게도 그렇지 않으니, 무엇이 문제인지 봅시다. 속성이란 가능한 모든 입력에 대해 성립해야 하는 전칭적으로 한정된 계산입니다. 프로그래밍 언어에서 속성의 가장 단순한 모델은 아래와 같이 불리언을 반환하는 함수입니다.
property :: a -> Bool
예를 들어, \l -> reverse (reverse l) == l는 하나의 속성입니다. 이는 리스트를 두 번 뒤집으면 원래 리스트가 된다는 것을 단언합니다. 이는 전제조건(preconditions) 때문에 약간 복잡해집니다. 전제조건은 어떤 입력이 유효한지 아닌지를 나타내는 규칙입니다. 그래서 우리는 다음과 같은 것을 작성할 수 있습니다.
data Database = ...
execute :: Database -> Query -> Database
query :: Database -> Query -> [[Value]]
(==>) :: Bool -> Bool -> Maybe Bool
(==>) precondition property =
if precondition then Just property else Nothing
prop_insert_select :: Database -> String -> [Value] -> Maybe Bool
prop_insert_select db table values =
let insert = Insert table values
select = Select table "*"
in
hasTable db table ==> (values `elem` query (execute db insert) select)
여기서 ==>는 함의 연산자로, 전제조건(왼쪽 항)이 만족되지 않으면 그 속성은 테스트할 수 없다는 뜻입니다. 우리의 경우 전제조건은 hasTable db table이며, 데이터베이스에 지정된 테이블이 있는지 검사합니다. 없다면 결과는 중요하지 않으므로 그냥 버립니다. 데이터베이스에 해당 테이블이 있다면 insert를 실행하고, select 쿼리가 우리가 삽입한 값을 반환하는지 확인합니다.
이제 속성이 있으니, 여기에 사용할 난수 생성기가 필요합니다. 모든 입력 타입에 대해 Arbitrary 인스턴스를 작성하고 나머지는 QuickCheck가 처리하게 할 수 있습니다.
data Value = Number Int | String String | ...
instance Arbitrary Value where
arbitrary = oneof [Number <$> arbitrary, String <$> arbitrary, ...]
instance Arbitrary Database where
arbitrary = ...
음, 사실은 그렇지 않습니다. 우리가 이런 인스턴스를 작성하면 QuickCheck가 무엇을 할 것 같나요? 무작위 데이터베이스, 무작위 문자열, 무작위 Value들의 리스트를 생성한 뒤 prop_insert_select 함수를 실행합니다. 무작위 문자열이 데이터베이스에 실제로 존재하는 유효한 테이블 이름일 확률은 몇 퍼센트나 될까요?
우리가 원하는 것은 의존적 생성기입니다. 어떤 값들은 다른 값들에 의존할 수 있어야 합니다.
-- 무작위로 테이블 생성
genTable :: Gen Table
genTable = ...
tableName :: Table -> String
tableName = ...
genValuesFor :: Database -> String -> Gen [Value]
genValuesFor db table = ...
gen :: Gen (Database, String, [Value])
gen = do
-- 몇 개의 테이블을 만들지 결정
numTables <- choose (1, 10)
-- 테이블들을 무작위로 생성
tables <- vectorOf numTables genTable
-- 빈 데이터베이스 생성
let db0 = emptyDatabase
-- 생성된 테이블들로 데이터베이스 채우기
let db = foldl createTable db0 tables
-- 이제 유효한 테이블 이름과 값들을 생성할 수 있음
let tableNames = map tableName tables
table <- elements tableNames
values <- genValuesFor db table
return (db, table, values)
이제 입력이 구성 단계에서부터 유효하므로 전제조건 실패를 걱정할 필요가 없습니다. 테이블은 데이터베이스에 이미 존재하는 테이블 목록에서 선택되므로 절대 실패하지 않습니다. 그러면 테스트는 어떻게 실행할까요? 개념적인 API는 다음과 같습니다.
quickCheck :: Gen t -> (t -> Maybe Bool) -> Int -> Gen (Maybe t)
quickCheck gen property n =
if n == 0 then pure Nothing
else do
-- 주어진 생성기를 사용해 무작위 입력 생성
input <- gen
-- 생성된 입력으로 속성 검사
case property input of
-- 테스트 통과, 다른 입력 생성
Just True -> quickCheck gen property (n - 1)
-- 테스트 실패, 실패한 입력 반환
Just False -> pure (Just input)
-- 전제조건 불만족, 다른 입력 생성
Nothing -> quickCheck gen property (n - 1)
이것은 QuickCheck가 실제로 동작하는 방식은 아닙니다(예를 들어 shrinking과 실제 테스트 러너 메커니즘은 무시합니다). 하지만 그 세부사항은 아마 다른 글로 미루는 편이 좋을 것입니다. 여기서 제가 집중하고 싶은 것은 다른 점이기 때문입니다. 바로 생성기가 속성과 독립적이지 않다 는 사실입니다. 공정하게 말하면, 이것은 흔한 요구사항입니다. 단지 흥미로운 입력을 우연히 만날 것을 기대하며 데이터를 무작위 샘플링할 수는 없고, 테스트 대상 시스템을 고려해야 합니다. 하지만 여기에는 더 시급한 상황이 있습니다. 생성기는 난수 생성 자체와는 겉보기에 관련 없어 보이는 계산도 실행합니다. foldl createTable ... 호출은 데이터베이스에 생성된 테이블들을 추가하기 위해 데이터베이스와 함께 실행됩니다. 이는 어떤 데이터 타입을 구성하기 위해 몇 가지 무작위 결정을 내리는, 우리가 보통 떠올리는 난수 생성기의 정신적 모델과 대조적입니다.
오히려 여기서는 Database가 처음부터 생성하기엔 너무 복잡하므로, 아주 단순한 버전을 생성하고 그것의 자체 API를 사용해 이를 구성합니다. 이참에 더 나아갈 수도 있습니다. 생성기는 이미 (db, table) 쌍을 반환하고, 속성은 이어서 hasTable db table로 유효성을 검사합니다. 그런데 이 특정 생성기에 대해서는 db가 항상 table을 가진다는 것을 알고 있으니, 그 검사를 제거할 수 있습니다. 다른 생성기들에 대해서는 그 검사를 생성기 안으로 넣어서 생성기를 전체 함수가 아닌 부분 함수로 만들고, 속성은 부분 함수가 아니라 전체 함수로 만들 수도 있습니다. 아래 예시에서 생성기는 평범한 Gen 대신 Gen (Maybe ...)를 반환하고, hasTable 검사가 생성기로 이동했기 때문에 속성은 Maybe Bool 대신 Bool을 반환합니다.
gen :: Gen (Maybe (Database, String, [Value]))
gen = do
db <- arbitrary :: Gen Database
table <- arbitrary :: Gen String
if hasTable db table then do
values <- arbitrary :: Gen [Value]
return $ Just (db, table, values)
else
return Nothing
prop_insert_select :: (Database, String, [Value]) -> Bool
prop_insert_select (db, table, values) =
let insert = Insert table values
select = Select table "*"
in
values `elem` query (execute db insert) select
속성의 일부를 생성기로 옮길 수 있다면, 나머지도 그렇게 할 수 있지 않을까요?
gen :: Gen (Maybe Bool)
gen = do
db <- arbitrary :: Gen Database
table <- arbitrary :: Gen String
if hasTable db table then do
values <- arbitrary :: Gen [Value]
let insert = Insert table values
select = Select table "*"
in
return $ Just (values `elem` query (execute db insert) select)
else
return Nothing
짜잔, 이제 다시 원래의 속성만 있는 버전으로 돌아왔습니다. 다만 이제는 생성기의 영역 안에 있기 때문에, 무엇인가를 둘로 나누고 처음부터 다시 작성하지 않고도 생성 과정을 그 자리에서 바꿀 수 있습니다. QuickCheck는 속성을 Gen 아래에 직접 작성하지 않고도 이런 스타일의 속성 기반 테스트 작성을 이미 지원합니다.
prop_insert_select :: Property
prop_insert_select =
forAll arbitrary $ \db ->
forAll arbitrary $ \table ->
hasTable db table ==>
forAll arbitrary $ \values ->
let insert = Insert table values
select = Select table "*"
in
values `elem` query (execute db insert) select
이 스타일에서는 각 forAll 조합자가 생성기를 받아 생성된 값에 접근할 수 있는 문맥을 만듭니다. 이렇게 하면 생성기와 함께 작업하고 있다는 사실을 감추면서도, 번거로움 없이 의존적 생성기를 작성할 수 있습니다. 여기서 속성은 불리언을 반환하는 함수가 아니라, 생성과 단언을 함께 포착하는 테스트입니다. 공정하게 말하면, 제가 여기서 쓴 내용이 혁신적인 것은 아닙니다. forAll은 지난 26년 동안 QuickCheck의 일부였고, PBT를 작성하는 사람이라면 누구나 속성 기반 테스트가 속성과 생성기가 완전히 분리된 것이 아니라 둘의 결합이라는 사실을 알고 있습니다. Hypothesis intro는 이를 실용적인 관점에서 특히 잘 설명합니다.
여러분은 자신이 기술한 범위 안의 모든 입력에 대해 통과해야 하는 테스트를 작성하고, Hypothesis가 그 입력들 중 어떤 것을 검사할지 무작위로 선택하게 합니다
본질적으로 속성 기반 테스트는 테스트 대상 프로그램에 대한 전칭적 명제로서 속성을 활용하지만, 많은 경우 추상화 경계를 깨지 않고서는 그것들을 사용할 수 없습니다. 이는 라이브러리를 구현할 때 특히 중요하게 생각해야 합니다. 예를 들어 Haskell의 QuickCheck를 Rust로 포팅한 quickcheck는 이 추상화 경계를 잘못 잡고 있습니다.
이 라이브러리의 quicktest는 Testable 인스턴스를 기대하는데, 여기에는 fn result(&self, _: &mut Gen) -> TestResult라는 함수가 있습니다. 이 인스턴스는 최대 8개 원소를 가진 튜플에 대해 다음과 같이 구현됩니다.
impl<T: Testable,
$($name: Arbitrary + Debug),*> Testable for fn($($name),*) -> T {
#[allow(non_snake_case)]
fn result(&self, g: &mut Gen) -> TestResult {
let self_ = *self;
let a: ($($name,)*) = Arbitrary::arbitrary(g);
let ( $($name,)* ) = a.clone();
let mut r = safe(move || {self_($($name),*)}).result(g);
if r.is_failure() {
let mut a = a.shrink();
while let Some(t) = a.next() {
let ($($name,)*) = t.clone();
let mut r_new = safe(move || {self_($($name),*)}).result(g);
if r_new.is_failure() {
{
let ($(ref $name,)*) : ($($name,)*) = t;
r_new.arguments = Some(debug_reprs(&[$($name),*]));
}
// The shrunk value *does* witness a failure, so remember
// it for now
r = r_new;
// ... and switch over to that value, i.e. try to shrink
// it further.
a = t.shrink()
}
}
}
r
}
여기서 let a: ($($name,)*) = Arbitrary::arbitrary(g);라는 줄은 입력 튜플에 대한 arbitrary를 호출하고, 그 결과를 속성에 전달해 let mut r = safe(move || {self_($($name),*)}).result(g);에서 결과를 계산합니다. 이 설계는 우리가 방금 불충분하다고 말한 바로 그 경계를 가정합니다. 왜냐하면 생성과 계산이 서로 교차되어야 할 수도 있기 때문입니다. 제가 알기로 Proptest도 같은 결정을 내립니다. QuickCheck와의 비교는 제가 개인적으로 그다지 중요하다고 느끼지 않는 차이들을 언급하면서도, 이 글이 초점을 맞추는 능력에 대해서는 언급하지 않습니다. 새롭게 등장한 Hegel은 tc: TestCase 인자를 유지하여 생성과 테스트 케이스를 섞을 수 있게 하고, 이를 통해 Haskell의 원래 기능을 Hypothesis가 한동안 사용해 온 것과는 다른 메커니즘으로 제공합니다.
이 글은 사실 특정한 결론으로 이끌기 위해 쓰인 것은 아니고, 오히려 일반적으로 PBT 라이브러리에서 무엇이 표현 가능해야 하는지를 탐구하려는 것이었습니다. 재미있게 읽으셨고 통찰도 얻으셨길 바랍니다. 이견이 있다면 [email protected]로 기꺼이 토론하고 싶습니다.
이 주제에 관한 저의 관련 작업: