- Property-based testing은 QuickCheck 이후 여러 언어로 퍼졌지만, 2024년 7월 기준 많은 라이브러리가 2009년에 이미 정리된 상태 기반 테스트와 병렬 테스트를 충분히 제공하지 못함
- 핵심 격차는 상태 머신 모델로 순차 상태 변화를 검증하고, 같은 모델을 선형화 가능성(linearisability) 검사에 재사용해 병렬 실행의 race condition을 찾는 기능에 있음
- 조사 대상 다수는 상태 기반 테스트가 없거나 실험적이며, 병렬 테스트는 더 드물어 FsCheck, Gopter, RapidCheck, SwiftCheck, jsverify 등에는 관련 이슈가 수년째 남아 있음
- 약 400줄의 Haskell 구현은 상태 기반·병렬 property-based testing을 재현하고, 전통적 상태 머신 명세 대신 프로그래머에게 익숙한 fake 기반 참조 구현을 모델로 사용함
- 계약 테스트된 fake는 단일 컴포넌트 검증을 넘어, 실제 의존성 대신 주입하는 빠르고 결정적인 통합 테스트에도 재사용 가능함
QuickCheck 이후 벌어진 기능 격차
- Property-based testing은 “테스트를 쓰지 말고 생성하라”는 구호 아래 여러 프로그래밍 언어 커뮤니티로 확산됨
- 원래 Haskell 라이브러리인 QuickCheck의 Wikipedia 페이지에는 다른 언어 재구현이 57개 나열돼 있음
- 첫 QuickCheck 논문인 QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs는 ICFP 2000에 발표됐고, 첫 구현 전체 소스는 논문 부록의 약 300줄 코드였음
- 초기 QuickCheck는 순수 함수만 테스트할 수 있었으며, 2002년 Testing monadic code with QuickCheck가 mutable state, file I/O, networking 같은 효과 있는 코드를 다루는 기반을 마련함
상태 기반·병렬 테스트의 등장
- Quviq AB는 2006년 John Hughes와 Thomas Arts가 설립했고, Ericsson의 Erlang 프로젝트 테스트가 초기 사용 사례였음
- Erlang은 순수 함수형 언어가 아니고 동시성이 흔해, 기존 monadic QuickCheck만으로는 충분히 쓰기 편하지 않았음
- Quviq의 closed source Erlang QuickCheck에는 이후 여러 오픈소스 구현에서 빠진 두 기능이 포함됨
- 상태 머신 모델을 쓰는 순차 상태 기반 property-based testing
- 같은 순차 상태 머신 모델을 재사용해 race condition을 탐지하는 병렬 테스트
- 상태 기반 테스트는 QuickCheck testing for fun and profit (2007)에 현재 형태로 등장함
- 병렬 테스트는 Finding Race Conditions in Erlang with QuickCheck and PULSE (ICFP 2009)에서 자세히 다뤄졌고, Herlihy와 Wing의 Linearizability: a correctness condition for concurrent objects (1990)를 핵심 기법으로 삼음
- Quviq QuickCheck의 라이브러리 코드는 논문에 공유되지 않았고, 공개된 것은 API와 해당 API를 사용하는 테스트 예제였음
2024년 라이브러리 조사 결과
- 현재의 state-of-the-art는 상태 머신 모델 기반 stateful testing과, 같은 순차 모델에 linearisability를 결합한 parallel testing임
- 조사는 2024년 7월 기준 문서, issue tracker, 일부 소스 코드를 읽고 정리한 결과임
- 많은 라이브러리가 상태 기반 테스트를 제공하지 않거나 제한적으로 제공함
- QuickCheck(Haskell)는 상태 기반 테스트 추가 이슈가 2016년부터 열려 있음
- SwiftCheck도 상태 기반 테스트 추가 이슈가 2016년부터 열려 있음
- jsverify는 상태 기반 테스트 추가 이슈가 2015년부터 남아 있음
- proptest(Rust)는 별도 proptest-state-machine을 참고해야 함
- 병렬 테스트 지원은 더 드묾
- Gopter는 README에 “No parallel commands … yet?”라고 쓰여 있고 2017년 이슈가 있음
- FsCheck는 parallel support 추가 이슈가 2016년부터 열려 있음
- RapidCheck는 parallel support 추가 이슈가 2015년부터 열려 있음
- propcheck는 2020년부터 parallel testing 추가 이슈가 있음
- 두 기능을 모두 지원하는 오픈소스 예로 PropEr, Hedgehog, qcheck-stm, quickcheck-state-machine, stateful-check 등이 제시됨
- 병렬 기능이 있더라도 제한이 있는 사례가 있음
- QuickTheories의 병렬 테스트는 가능한 end state 수가 명령 수에 따라 빠르게 늘어나므로 command list를 보통 10개 이하로 제한해야 한다고 소스 주석에 적혀 있음
- ScalaCheck의 LevelDB와 Redis 예제는
threadCount = 1인 순차 예제로 제시됨 - fast-check의 race condition 지원은 Quviq QuickCheck의 병렬 테스트와 달리 순차 상태 머신 모델 재사용이나 linearisability 사용으로 보이지 않음
- 병렬 테스트가 나중에 추가된 명확한 예는 보이지 않으며, 처음부터 API 설계에 반영되지 않으면 상당한 재설계가 필요할 수 있음
기능 확산이 더뎠던 이유
- John Hughes가 제시한 이유는 세 가지임
- 상태 기반·병렬 테스트가 순수 함수 테스트만큼 유용하지 않음
- 상태 머신 모델 작성에는 일반 테스트와 다른 사고방식이 필요하고 교육이 필요함
- open source만으로는 산업 채택이 잘 되지 않았고, closed source 제품과 교육·컨설팅이 채택을 도왔음
- 순수 함수 조각만 property-based testing해도 많은 효과를 얻을 수 있지만, 산업 시스템에는 database, stateful protocol, concurrent data structure가 많아 상태 기반·병렬 테스트도 거의 비슷하게 중요함
- 상태 기반 명세가 항상 순수 함수 명세보다 더 어렵지는 않음
- key-value store 모델은 key-value pair 리스트만으로도 상당히 멀리 갈 수 있음
- LevelDB 사례에서는 단순 모델이 몇 분 안에 17-step 축소 counterexample을 찾았고, Google 수정 후 다시 몇 분 안에 31-step counterexample을 찾음
- 두 번째 문제는 background compaction process의 버그였으며, compaction은 읽기 성능 개선과 disk space 회수에 중요하지만 모델에 명시적으로 포함되지 않았음
- closed source가 산업 채택을 도왔을 수는 있어도, open source 채택에는 도움이 되지 않았다고 평가함
- 논문 결과를 Quviq QuickCheck 라이선스 없이 재현하려면 많은 reverse engineering이 필요해 거의 불가능하다고 봄
제안: 작고 공개된 구현과 쉬운 명세
- 개선 방향은 두 가지임
- 원래 QuickCheck의 약 300줄 구현처럼, 상태 기반·병렬 property-based testing의 짧은 오픈소스 구현을 제공함
- 상태 머신 대신 프로그래머가 이미 익숙한 mock과 test double 개념을 재사용해 명세 작성 부담을 줄임
- 이 가설을 검증하기 위해 두 가지를 보임
- 상태 기반·병렬 property-based testing을 약 400줄 코드로 구현함
- state machine 대신 in-memory reference implementation, 즉 fake를 모델로 사용함
순수 property-based testing 요약
- 순수 함수 테스트에서는 입력을 생성하고 함수 출력이 입력과 어떤 관계를 만족하는지 확인함
- 예를 들어
reverse는 임의 리스트xs에 대해reverse (reverse xs) == xs라는 property로 테스트할 수 있음 - QuickCheck는 기본적으로 100개 테스트를 생성하며, 실패하면 입력을 shrink해 최소 counterexample을 제시함
reverse xs == xs같은 잘못된 property는[0,1]같은 최소 반례로 축소됨- 자주 등장하는 property 패턴은 inverse, idempotency, associativity, abstract data type의 axiom, metamorphic property 등임
- inverse:
deserialise (serialise i) == i - idempotency:
sort (sort xs) == sort xs - associativity:
(i + j) + k == i + (j + k)
- inverse:
상태 기반 property-based testing
- 상태 있는 컴포넌트는 같은 입력이 항상 같은 출력을 만들지 않음
- counter의 첫 번째
incr결과와 두 번째incr결과는 이전 상태에 따라 달라짐 - database와 file system도 이전 입력 이력이 다음 출력에 영향을 줌
- counter의 첫 번째
- 순수 함수 테스트가 단일 입력을 다룬다면, 상태 기반 테스트는 입력 시퀀스를 생성해 시스템이 시간에 따라 어떻게 변하는지 확인함
- 모델은
m -> i -> (m, o)형태의 fake로 표현됨- 이전 모델 상태
m과 입력i에서 다음 모델과 출력o를 계산함 - 실제 시스템의 출력과 fake의 출력을 각 단계에서 비교함
- 불일치하면 입력 시퀀스를 shrink해 작은 counterexample을 찾음
- 이전 모델 상태
-
Counter 예제
- 전역 mutable variable을 사용하는 Haskell counter를 테스트 대상으로 삼음
incr는 counter를 증가시키고,get은 현재 값을 읽음- 모델은
Counter Int하나로 충분하며,StateModel인스턴스는 초기 상태Counter 0,Incr,Get,Incr_ (),Get_ Int,runFake,runReal, command generator를 정의함 incr42Bug처럼 counter 값이 42일 때 증가하지 않는 버그를 넣으면, QuickCheck는 66개 테스트 후 실패를 찾고 29번 shrink해 43번 increment 뒤Get하는 최소 반례를 제시함- 테스트 사이에 실제 전역 counter를
reset하지 않으면 모델은 항상 0에서 시작하지만 실제 counter는 이전 테스트 상태를 유지해 mismatch가 발생함
-
상태 기반 라이브러리 인터페이스
StateModel인터페이스는 테스트 대상 시스템을 black box로 보고 command를 입력, response를 출력으로 둠- 핵심 구성은
Command state,Response state,initialState,runFake,runReal,generateCommand임 - optional 구성은 다음과 같음
Reference: file handle처럼 이전 response가 만든 리소스를 이후 command에서 참조할 때 사용함PreconditionFailure: 열린 파일이 아닌 handle에서 read하지 못하게 하는 식의 precondition 실패를 표현함CommandMonad: 기본은IO지만 다른 monad를 쓸 수 있음monitoring,commandName: coverage와 통계에 사용함
- command 생성 시 실제 file handle 같은 값은 만들 수 없으므로
Var Int형태의 symbolic reference를 생성하고, 실행 중 실제 reference로 치환함 - shrink 후에는 precondition을 깨거나 scope 밖 symbolic reference를 사용하는 command를 제거함
-
Circular buffer 예제
- C로 작성된 circular queue를 Haskell FFI로 테스트하고, 모델은 단순한 리스트 기반 queue로 작성함
- C 구현은 error checking을 하지 않아 empty queue에서
get하면 초기화되지 않은 memory를 돌려줄 수 있음 - 실제 구현은 circular index로 효율적이지만 명백히 correct하지 않고, fake는 덜 효율적이지만 테스트용이므로 문제가 되지 않음
new가 queue reference를 반환하므로 모델은Map (Var Queue) FQueue로 여러 queue를 관리함- 처음에는 full queue에
put하는 precondition이 빠져, 크기 1 queue에0,1을 넣고get했을 때 모델은 FIFO라0을 기대하지만 C 코드는1을 반환함 - 이는 구현 버그가 아니라 모델 precondition 누락이어서
QueueIsFullprecondition을 추가해 수정함 Sizecommand가 generator에서 빠진 점은 coverage 출력으로 드러났고, 이를 추가하자 queue size 계산 버그가 발견됨- 크기 1 queue에 item 하나를 넣고
Size하면 기대값은 1이지만 실제값은 0이며,new에서 내부 buffer 크기를n + 1로 잡는 수정이 제시됨 - 이후
abs(q->inp - q->outp) % q->size는 크기 1에서는 통과하지만 크기 2에서 다시 실패하고, 최종 수정은(q->inp - q->outp + q->size) % q->size임
-
Die Hard 3 물통 퍼즐
- 3L와 5L 물통으로 정확히 4L를 만드는 퍼즐을 상태 기반 테스트로 풂
- 실제 구현 없이 모델과 fake만 실행해도, 특정 상태에 도달하면 테스트를 실패시켜 shrink된 action sequence를 얻을 수 있음
- 199개 테스트와 11번 shrink 후 제시된 sequence는 다음 흐름임
- 5L 물통 채우기
- 5L에서 3L로 붓기
- 3L 비우기
- 5L에서 3L로 다시 붓기
- 5L 채우기
- 5L에서 3L로 붓기
- trace에는 중간 상태가 표시되어 big jug가 4L가 되는 과정을 확인할 수 있음
병렬 property-based testing
- concurrent code의 버그는 thread interleaving이 실행마다 달라 재현과 수정 검증이 어려움
- 목표는 사용자가 추가 테스트 코드를 많이 쓰지 않고도, 순차 상태 기반 테스트처럼 병렬 테스트를 할 수 있게 하는 것임
- counter 예제에서
incr가readIORef후writeIORef를 비원자적으로 수행하면, 두 thread가 서로의 increment를 덮어써 race condition이 생김 - 병렬 테스트는 실행 중 command의 invocation과 response 시점을 모아 concurrent history를 만들고, 그 history가 어떤 순차 interleaving으로 설명되는지 검사함
- 어떤 interleaving 하나라도 순차 모델과 맞으면 history는 linearise한다고 보고 correct로 판단함
- 어떤 순차 interleaving도 실제 response를 설명하지 못하면 non-linearisable 결과로 처리됨
-
병렬 command 생성과 shrink
- 병렬 프로그램은
ParallelCommands와 여러Fork로 표현되고, 각Fork안의 command들은 병렬로 실행됨 - 예제 구현은 single, double, triple threaded execution을 다룸
- 병렬 실행에서는
Fork [Write "a" "foo", Write "a" "bar"]처럼 interleaving에 따라 가능한 모델 상태가 달라질 수 있음 - 병렬 모델은 단일 state가 아니라 state 집합을 기준으로 command 생성과 shrink를 수행함
parallelSafe는Fork안 command의 모든 permutation에서 precondition이 유지되는지 확인함- 예를 들어
Write "a"와Delete "a"가 같은 fork에 있으면 한 command가 다른 command의 precondition을 깨뜨릴 수 있음 - shrink 과정에서도 precondition과 symbolic reference scope를 유지하는 command만 남김
- 병렬 프로그램은
-
병렬 실행과 linearisability 검사
- 병렬 실행은 각 command의
Invoke와Okevent를 history로 기록함 - response에 새 reference가 포함되면 atomic counter로 환경을 확장해 thread 간 reference 번호 충돌을 피함
- history에서 가능한 모든 interleaving을
Rosetree로 열거함 linearisable은 이 tree의 어떤 path가 순차runFake모델과 response를 일치시키는지 확인함- 병렬 테스트는 결국 순차 모델을 재사용하므로, 사용자는 순차 모델을 작성한 뒤 적은 추가 코드로 병렬 테스트를 얻음
- 병렬 실행은 각 command의
-
Parallel counter 예제
- counter의 병렬 테스트를 활성화하기 위해 추가한 코드는
ParallelModel Counter인스턴스와 property뿐임 - 비원자적
incrRaceCondition을 쓰면 race condition을 찾음 - 더 작은 test case에도 race가 있더라도 다른 interleaving 때문에 실패가 재현되지 않으면, QuickCheck는 더 작은 test case가 통과한다고 보고 shrink를 멈출 수 있음
- 올바른 해결책은 deterministic thread scheduler이며, 병렬 테스트 논문은 이를 사용함
- 예제 구현은 더 단순한 workaround로 shared memory read/write 주변에 짧은 sleep을 넣어 같은 interleaving이 발생할 가능성을 높임
- sleep은 race를 찾기 위해서가 아니라, 찾은 race의 counterexample을 작게 만들기 위해 필요함
- sleep 추가 후 최소 반례는
ParallelCommands [Fork [Incr,Incr],Fork [Get]]로 축소됨
- counter의 병렬 테스트를 활성화하기 위해 추가한 코드는
-
Process registry 예제
- Erlang process registry처럼 thread를 spawn하고, 이름으로
ThreadId를 register·lookup·unregister·kill하는 시스템을 예제로 사용함 - 순차 모델은 생성된 thread id, 등록된 name-thread pair, killed thread id를 추적함
Register와Unregister는 실패할 수 있어 response에Either ErrorCall ()을 사용함- 실제 구현의 error location 정보는 fake와 맞추기 위해
abstractError로 제거함 monitoring은RegisterFailed,RegisterSucceeded,UnregisterFailed,UnregisterSucceededcoverage를 보여줌- 의도적으로
register가 기존 registry를 덮어쓰는 버그를 넣으면, 이미 등록한"e"를 unregister하지 못하는 순차 반례가 나옴 - 병렬 테스트에서는 더 긴 반례가 나오며,
SleepyIORef를 쓰면Fork [Register "b" (Var 0), Register "c" (Var 0)]형태로 shrink됨 - 문제는
readRegistry로 확인한 뒤atomicModifyIORef를 호출하는 사이에 다른 thread가 끼어들 수 있는 race임 register,unregister,kill에 global lock을 적용한 뒤 병렬 테스트가 통과함
- Erlang process registry처럼 thread를 spawn하고, 이름으로
Fake 기반 모델과 통합 테스트
- 전통적 state machine specification with post-conditions 대신 in-memory fake를 reference implementation으로 사용함
- Edsko de Vries의 2019년 글은 fake를 post-condition 기반 상태 머신 명세 위에 구현하는 방식을 제안한 첫 글로 소개됨
- fake는 mock과 비슷해 formal specification에 익숙하지 않은 프로그래머에게 더 쉬운 접근으로 제시됨
- fake에는 통합 테스트에서 의존 컴포넌트 대신 사용할 수 있다는 장점도 있음
- 실제 dependency를 시작하거나 enable하지 않아도 됨
- 더 빠르고 deterministic한 integration test를 구성할 수 있음
- fake가 틀릴 수 있다는 문제는 contract test로 다룸
- 상태 기반·병렬 property-based test가 fake와 실제 구현의 일치를 검증하므로, fake는 계약 테스트된 의존성 역할을 함
-
Queue fake로 테스트와 배포 분리
- queue 인터페이스
IQueue는iNew,iPut,iGet,iSize를 가짐 - 실제 구현은 C queue wrapper를 그대로 연결함
- fake 구현은 모델 상태를
IORef에 저장하고fNew,fPut,fGet,fSize를 통해 갱신함 - 컴포넌트는
IQueue q인터페이스에 대해 작성됨 - 테스트에서는
fake인스턴스를 사용하고, 배포에서는real인스턴스를 사용함 - 상태 기반 property-based test로 fake가 real에 faithful하다는 전제를 세움
- queue 인터페이스
-
File system fake
- file system 인터페이스
IFileSystem h는iMkDir,iOpen,iWrite,iClose,iRead를 가짐 - 실제 구현은
/tmp/qc-test아래 실제 file system을 사용함 - fake는 directory set, file content map, open handle map, next handle을 가진 in-memory
FakeFS로 구현됨 fOpen,fWrite,fClose,fRead는 busy file, 없는 directory, 닫힌 handle 같은 precondition failure를 모델링함- fake file system이 실제 file system과 faithful하다고 테스트되면, file system에 의존하는 컴포넌트는 fake로 integration test하고 배포 시 real file system으로 교체할 수 있음
- real로 교체했을 때 버그가 생기면 fake와 real 사이 mismatch가 상태 기반 property-based test를 어떻게 통과했는지 조사해야 함
- file system 인터페이스
-
더 큰 컴포넌트 시스템
- A가 B에 의존하고 B가 C에 의존하는 시스템도 같은 방식으로 확장됨
- 각 컴포넌트에 인터페이스를 둠
iC :: IO ICiB :: IC -> IO IBiA :: IB -> IO IA
- 테스트 전략은 다음과 같음
- C를 상태 기반·병렬 property-based test로 검증해 contract-tested fake C를 얻음
- B의 integration test에서는 fake C를 사용함
- A 테스트에서는 fake C를 사용하는 fake B를 사용함
- 이 방식은 더 많은 컴포넌트나 서비스에도 같은 패턴으로 확장됨
결론
- 상태 기반·병렬 property-based testing은 약 400줄 코드로 구현할 수 있으며, 이는 shrinking이 없던 첫 QuickCheck의 약 300줄 구현과 비교 가능한 규모임
- fake를 모델로 사용하면 상태 기반·병렬 테스트의 명세 작성이 더 익숙한 형태가 되고, 더 큰 시스템을 compositional하게 테스트하는 데 재사용할 수 있음
- 각 언어 커뮤니티가 실험을 이어가면 property-based testing 라이브러리의 상태를 개선할 여지가 있음