1P by GN⁺ | ★ favorite | 댓글 1개
  • 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)

상태 기반 property-based testing

  • 상태 있는 컴포넌트는 같은 입력이 항상 같은 출력을 만들지 않음
    • counter의 첫 번째 incr 결과와 두 번째 incr 결과는 이전 상태에 따라 달라짐
    • database와 file system도 이전 입력 이력이 다음 출력에 영향을 줌
  • 순수 함수 테스트가 단일 입력을 다룬다면, 상태 기반 테스트는 입력 시퀀스를 생성해 시스템이 시간에 따라 어떻게 변하는지 확인함
  • 모델은 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 누락이어서 QueueIsFull precondition을 추가해 수정함
    • Size command가 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 예제에서 incrreadIORefwriteIORef를 비원자적으로 수행하면, 두 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를 수행함
    • parallelSafeFork 안 command의 모든 permutation에서 precondition이 유지되는지 확인함
    • 예를 들어 Write "a"Delete "a"가 같은 fork에 있으면 한 command가 다른 command의 precondition을 깨뜨릴 수 있음
    • shrink 과정에서도 precondition과 symbolic reference scope를 유지하는 command만 남김
  • 병렬 실행과 linearisability 검사

    • 병렬 실행은 각 command의 InvokeOk event를 history로 기록함
    • response에 새 reference가 포함되면 atomic counter로 환경을 확장해 thread 간 reference 번호 충돌을 피함
    • history에서 가능한 모든 interleaving을 Rose tree로 열거함
    • linearisable은 이 tree의 어떤 path가 순차 runFake 모델과 response를 일치시키는지 확인함
    • 병렬 테스트는 결국 순차 모델을 재사용하므로, 사용자는 순차 모델을 작성한 뒤 적은 추가 코드로 병렬 테스트를 얻음
  • 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]]로 축소됨
  • Process registry 예제

    • Erlang process registry처럼 thread를 spawn하고, 이름으로 ThreadId를 register·lookup·unregister·kill하는 시스템을 예제로 사용함
    • 순차 모델은 생성된 thread id, 등록된 name-thread pair, killed thread id를 추적함
    • RegisterUnregister는 실패할 수 있어 response에 Either ErrorCall ()을 사용함
    • 실제 구현의 error location 정보는 fake와 맞추기 위해 abstractError로 제거함
    • monitoringRegisterFailed, RegisterSucceeded, UnregisterFailed, UnregisterSucceeded coverage를 보여줌
    • 의도적으로 register가 기존 registry를 덮어쓰는 버그를 넣으면, 이미 등록한 "e"를 unregister하지 못하는 순차 반례가 나옴
    • 병렬 테스트에서는 더 긴 반례가 나오며, SleepyIORef를 쓰면 Fork [Register "b" (Var 0), Register "c" (Var 0)] 형태로 shrink됨
    • 문제는 readRegistry로 확인한 뒤 atomicModifyIORef를 호출하는 사이에 다른 thread가 끼어들 수 있는 race임
    • register, unregister, kill에 global lock을 적용한 뒤 병렬 테스트가 통과함

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 인터페이스 IQueueiNew, iPut, iGet, iSize를 가짐
    • 실제 구현은 C queue wrapper를 그대로 연결함
    • fake 구현은 모델 상태를 IORef에 저장하고 fNew, fPut, fGet, fSize를 통해 갱신함
    • 컴포넌트는 IQueue q 인터페이스에 대해 작성됨
    • 테스트에서는 fake 인스턴스를 사용하고, 배포에서는 real 인스턴스를 사용함
    • 상태 기반 property-based test로 fake가 real에 faithful하다는 전제를 세움
  • File system fake

    • file system 인터페이스 IFileSystem hiMkDir, 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를 어떻게 통과했는지 조사해야 함
  • 더 큰 컴포넌트 시스템

    • A가 B에 의존하고 B가 C에 의존하는 시스템도 같은 방식으로 확장됨
    • 각 컴포넌트에 인터페이스를 둠
      • iC :: IO IC
      • iB :: IC -> IO IB
      • iA :: 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 라이브러리의 상태를 개선할 여지가 있음

댓글과 토론

Hacker News 의견들
  • 커버리지 기반 퍼징이 등장했고 Go에서 지원도 잘되는데, 속성 기반 테스트 라이브러리를 쓰지 않으면 무엇을 놓치는지 궁금함
    https://www.tedinski.com/2018/12/11/fuzzing-and-property-tes...
    아래 퍼즈 테스트와 그에 대응하는 불변 조건 검사를 보면, 사실상 속성 테스트와 거의 같은 것 아닌가 싶음
    https://github.com/ncruces/aa/blob/505cbbf94973042cc7af4d6be...
    https://github.com/ncruces/aa/blob/505cbbf94973042cc7af4d6be...

    • 속성 기반 테스트와 퍼징의 구분은 대체로 분위기상의 거친 묶음에 가까움
      실제 차이는 있지만 경계가 꽤 흐릿하고, 무엇이 퍼징이고 무엇이 속성 기반 테스트인지 정확히 가르는 일이 아주 중요하지는 않음
      빠르게 도는 테스트와 자세한 단언은 속성 기반 테스트, 오래 돌리고 충돌만 찾는 쪽은 퍼징, 그 사이는 애매함
      https://hypothesis.works/articles/what-is-property-based-tes...
    • 커버리지 기반 퍼징과 속성 기반 테스트는 충분히 결합할 수 있음
      Google에 있을 때 둘을 결합한 내부 도구가 정말 좋았음. 평소처럼 속성 기반 테스트를 작성하면, 실행 시 테스트 프레임워크가 커버리지를 얻도록 특수하게 컴파일하고 무작위 입력을 조정해 커버리지를 늘림. 물론 여러 머신 클러스터에서 완전히 자동으로 돌림
      전통적인 속성 기반 테스트는 보통 라이브러리로만 구현되므로, 무작위 입력 생성을 안내할 커버리지 정보가 반드시 있는 것은 아님
    • 속성을 단언하고 있으니, “레벨이 1보다 큰 모든 노드는 자식이 둘이다” 같은 정의상 속성 기반 테스트에 해당한다고 봄
      다만 라이브러리에 따라 편의 기능을 꽤 얻을 수 있음. 있으면 좋은 것 중 하나가 축소(shrinking)이고, 여기의 “Shrinking” 섹션을 참고하면 됨: https://tech.fpcomplete.com/blog/quickcheck-hedgehog-validit...
      생성기를 조합하는 조합자도 훌륭하고, 라이브러리에 따라 예외적 동작을 일으키는 알려진 “나쁜” 값 집합을 갖고 있기도 함
    • Go의 퍼즈 테스트가 링크한 글의 내용과 어떻게 다른지는 잘 모르겠지만, 그 글에서는 제대로 된 퍼저는 며칠이나 몇 주 동안 돌려야 하며 속성 기반 테스트를 퍼징보다 거의 항상 선택해야 한다고 했음
      한 단계 물러나 테스트에 대한 더 메타적인 질문을 던지고 싶음. 테스트 성공이 코드 성공을 뜻하고 그 반대도 성립하는가? Go의 계약에 같은 입력을 같은 코드에 넣으면 같은 출력이 나온다고 명시한 부분이 있는가?
    • API 관점에서 주로 얻는 것은 원하는 무작위 자료구조를 생성하는 조합자 라이브러리
      무작위 객체의 집합을 나타내는 Arbitrary 타입을 다루면 테스트 입력을 생성하는 재사용 가능한 함수를 쉽게 작성할 수 있음. 그런 라이브러리는 Go의 퍼징 프레임워크와도 꽤 쉽게 함께 쓸 수 있을 듯함
      그래도 map, filter, chain, oneOf 같은 일반적인 조합자는 조금 어색할 수 있다고 봐서, JavaScript용 새 속성 테스트 라이브러리를 작성 중임. 더 쓰기 좋게 만드는 것이 목표지만 아직 실험적이고 공개 전임
  • clojure.spec.alphatest.check와 함께 쓰든 아니든 훌륭한 경험이었는데, Python의 hypothesis를 써보니 정말 형편없었음
    Hypothesis는 단순하지만 “큰” 데이터 집합을 설계상 처리하지 못하는 것처럼 보였고, 여기서 “크다”도 실제로는 그리 크지 않음. [0] 너무 고통스러워서 직장 Python 테스트 스위트에서 Hypothesis와 생성 기반 테스트를 아예 들어냈음
    [0] https://github.com/HypothesisWorks/hypothesis/issues/3493

    • 이 경우 Hypothesis가 큰 데이터 집합을 못 다룬다기보다는, 축소된 사례 중 상당수를 거부하고 있었던 것처럼 들림
      Hypothesis는 버그가 0에서도 존재하는지 보려고 생성된 정수를 0으로 줄이려 했고, 테스트는 0이 들어 있다는 이유로 실패가 아니라 거부했음. 작은 사례에서는 비효율에 그쳤지만, 큰 사례에서는 Hypothesis가 포기할 정도가 됨
      그 스레드에서 누군가 0을 생성할 수 없는 다른 인스턴스 생성 전략을 쓰라고 제안했음. Hypothesis의 축소기가 가장 좋아하는 값을 생성한 뒤 거부하지 말고 처음부터 만들지 않는 방식임. 그걸 시도해 봤는지 궁금함
      clojure.spec.alpha는 이걸 어떻게 다르게 처리하는지도 궁금함
      https://news.ycombinator.com/item?id=40876437의 mjaniczek 댓글에서는 이 사례를 Hypothesis 접근법의 단점으로 꼽음
      “생성기가 이제 실패할 수 있는 바이트 목록 파서가 되므로 약간의 비효율이 생기고, 사용자가 내부 축소기가 완벽히 줄이지 못하는 이상한 생성기를 만들 수 있다. 그래도 세 접근법 중 개발자 경험은 가장 좋다…”라는 취지임
      물론 본인 테스트를 “이상한” 방식으로 작성했다고는 동의하지 않을 듯함
    • Clojure의 spec은 주변을 구성하기 정말 쉬워서 좋았는데, Elixir로 옮기고 나니 그런 테스트를 쓰려면 오래된 Erlang 라이브러리인 propEr까지 내려가야 했음. 꽤 실망스러움
    • GitHub 이슈에 올라온 예시는 filter를 자기 문제를 일으키는 방식으로 쓰고 있음
      무작위로 생성한 다음 어떤 속성에 맞는 것을 걸러내면, 생성 과정에서 사실상 복권을 긁는 셈임
  • 글에서 던진 “출판된 연구는 오픈소스 도구, 또는 적어도 대중과 다른 연구자에게 무료로 제공되는 도구로 재현 가능해야 한다는 요구가 왜 없을까?”라는 질문의 간단한 답은, 그런 요구의 즉각적인 결과가 그 조건을 만족하지 못하는 논문은 출판되지 않는다는 것임
    예컨대 저자와 다른 사람들에게 유용했던 것으로 보이는 Quviq QuickCheck 논문 같은 것도 출판되지 않았을 테고, 커뮤니티는 그 정보라는 선물을 잃었을 것임

    • 일부 출판사는 재현 가능성을 요구하고, 일부는 요구하지 않는 식이면 좋겠음
      모든 요구사항은 배제 효과가 있고, 요구사항을 충족하지 않아도 유용할 수 있는 논문이라는 경계 사례는 항상 있음
    • 이건 명확히 딱 잘리는 질문이 아니며 정치적 질문이라고까지 부를 수 있지만, 그렇다고 이 방어 논리는 별로 유효하지 않음
      이 논리를 유효하다고 보면, 그 말을 방패 삼아 어디까지든 갈 수 있음. 재현 가능성을 요구사항에서 버리면 설명하고 싶지 않은 것은 아무것도 설명하지 않아도 됨. 표본에 대한 데이터도, 통계적 유의성 검정도 제공할 필요가 없음. 어떤 결과를 달성했다고 주장하는 모호한 초록만으로 충분해짐
      심지어 Fermat가 Arithmetica 개인 사본 여백에 남긴 유명한 메모도 완전히 유효한 연구 논문이 됨. 유명 수학자가 어떤 정리에 간결하고 우아한 증명을 갖고 있다고 생각했다는 귀중한 정보를 잃고 싶지는 않을 테니 말임. 물론 실제로는 없었을 가능성이 높지만
      이 정치적 질문에 대한 내 생각은 현재 기준이 너무 느슨하다는 쪽임. 누구도 무언가를 출판하도록 강제받지 않음. 세상에는 독점적 가치 같은 이유로 어디에도 출판되지 않는 연구가 많고, 그런 연구는 사라지지 않을 것임
      하지만 학계에서 일하고, 더구나 연구비까지 받으며, 세상의 과학 지식을 전진시키는 것이 목표라고 말한다면 실제로 그 목표를 따르라고 요구하는 것이 공정함. 그저 학계 경력 사다리를 오르기 위해 그 목표를 따르는 척만 해서는 안 됨
    • 소스 코드를 심사자에게만 공개하는 방식도 가능하지 않을까 싶음
      코드를 실행하는 데 필요한 것들도 함께 제공하면 됨. 이미 그렇게 하고 있을 수도 있음
    • 재현 가능성은 과학적 방법의 초석이기 때문임
    • 논문은 저자들이 “중요도 지수”를 높이고 싶어 하기 때문에 출판되며, 이는 보수와 학계 경력 가능성과 매우 직접적으로 연결됨
      그 목적을 위해 요구사항이 더 늘어난다고 해서 출판 논문 수가 줄어들 가능성은 낮음
      출판 논문의 더 심각한 문제는 최대한 많이, 빠르게 출판하려고 실수를 의도적으로 지나치는 일이 잦다는 것임. 논문 검증이 쉬워지면 이 상황이 개선될 가능성은 있지만, 크게 기대하지는 않겠음. 사람들은 지름길을 찾는 데 매우 능함
  • Rust의 proptest상태 있는 속성 테스트를 꽤 자주 작성하고, 보통은 직접 코딩하는데 상당히 간단함
    6개의 버그를 찾은 비자명한 예시는 https://github.com/sunshowers-code/buf-list/blob/main/src/cu...에 있음
    병렬 테스트는 때때로 유용할 수 있겠지만, 그냥 많은 테스트를 병렬로 돌리는 편이 더 쉬운 경우가 많음

    • Rust에서 수동 속성 테스트를 많이 작성하는데 대체로 이런 형태임
      최상위에서는 진짜 무작위성을 쓰고, 그 아래 중첩 루프를 여러 개 둬서 낮은 복잡도 사례에서 높은 복잡도 사례로 올라감. 그런 다음 결정적 의사난수 생성기에 넣을 시드를 만들고 출력해 둠. 테스트가 실패하면 오류 시드를 복사해 붙여 넣어 실패 사례를 재현하면 됨
      이런 수동 속성 테스트가 어떤 프레임워크나 라이브러리보다 더 빠르고, 더 유연하며, 전반적으로 덜 번거롭다고 느꼈음
      다만 정말 견고한 동시성 테스트에는 AWS Shuttle 라이브러리(https://github.com/awslabs/shuttle)를 강력히 추천함. 믿기 어려울 만큼 복잡한 경쟁 상태를 찾아낼 수 있음. 작은 튜토리얼도 작성했음: https://grantslatton.com/shuttle
      AWS에서는 AWS S3를 구동하기 위해 작성한 맞춤 파일 시스템을 검증하는 데 이 라이브러리를 사용했음
  • 링크된 “Testing Telecoms Software with Quviq QuickCheck” 논문을 빠르게 훑어봤지만, “왜 이 상태 있는 작업을 직접 만드는 편이 낫지 않은가?”라는 질문에 대한 답은 바로 보이지 않았음
    원문은 키-값 저장소의 키-값 쌍 모델로 이 부분을 가리키지만, 왜 그냥 상태 기계를 작성하면 안 되는지, 왜 프레임워크가 필요한지 모르겠음. 지난주 직장에서 파일 시스템 상호작용 테스트를 위해 말 그대로 이렇게 했고, 결국 type Instruction = | Read of stuff | Write of stuff | Seek of stuff | … 정도로 귀결됐음
    그러면 속성은 “이 명령 목록이 주어졌을 때, …”가 됨. StateModel 형식도 기본적으로 같은 일을 요구함. StateModel이 제 몫을 한다고 보기 어렵고, 이해해야 할 프레임워크 코드를 훨씬 더 추가하는 대신 실제 경험상 아주 적은 양의 테스트 코드를 없애는 이득만 주는 듯함

    • 그런 판단이 맞는 테스트도 있지만, 실패 사례를 축소하는 부분이 까다로울 때가 많음
      “유효한” 상태 전이 시퀀스만 생성하고 싶다면, 특정 상태에서 어떤 테스트 단계가 유효한지 정하는 모델 상태가 보통 필요함. 또한 축소 중 테스트 단계를 제거하다가 원래 각 단계를 생성할 때 지킨 사전 조건을 깨뜨려 가짜 실패가 생기지 않도록 해야 함
      어떤 상태에서도 어떤 연산이든 유효하고 완전히 무작위인 임의 연산 시퀀스만 원한다면 상태 있는 proptest 프레임워크는 과할 수 있음. 하지만 모델 상태를 유지하고 여러 연산의 사전 조건을 지정해야 한다면 전용 프레임워크가 많은 일을 덜어줌
      작년에 이 주제로 블로그 글을 썼고, 더 깊이 있는 예시가 궁금하면 참고할 만함: https://readyset.io/blog/stateful-property-testing-in-rust
      다른 사람들이 말했듯 병렬 상태 기계 테스트도 전용 프레임워크에서 얻을 수 있는 멋진 이점이지만, 유일한 이점은 아님
    • 상태 있는 부분은 모델 기반 테스트가 더 잘 다룬다고 봄
      테스트 스타일은 섞어 써도 됨. 자기 코드니까
    • 병렬 QuickCheck는 다중 스레드 프로그램에서 가능한 모든 인터리빙이 최종적으로 명령을 순차적으로 호출했을 때도 도달 가능한 상태를 만드는지 확인하는 것으로 이해하고 있음
      그게 이점임
  • 글쓴이는 속성 기반 테스트의 상태 기계와 병렬 측면에 집중하지만, 더 큰 효과가 있을 수 있는 다른 측면도 있음
    하나는 커버리지 유도 속성 기반 테스트이고, Dan Luu의 글을 보면 됨: https://danluu.com/testing/
    또 하나는 내가 편향되어 있는 쪽인데, 값을 생성할 때 만든 모든 불변 조건을 유지하면서 축소를 자동화하는 것임
    요약하면, 값에 작동하는 QuickCheck식 파생 축소 함수(shrink : a -> [a])는 제약 조건과 문제가 있어 사람들이 문제를 다루기보다 축소를 꺼버리게 됨
    로즈 트리 “통합 축소”(예: Hedgehog)는 생성기의 제약 조건을 따르지만, 모나드 바인드, 즉 생성기 결과를 사용해 다른 생성기로 분기하는 경우 문제가 있음
    마법처럼 “그냥 되는” 것처럼 보이는 유일한 접근은 Hypothesis의 내부 축소임. 값 자체가 아니라 무작위 선택 목록을 줄이는 간접 계층을 사용함. 단점은 생성기가 이제 실패할 수 있는 바이트 목록 파서가 되어 약간의 비효율이 생기고, 사용자가 내부 축소기가 완벽히 줄이지 못하는 이상한 생성기를 만들 수 있다는 것임. 그래도 세 접근법 중 개발자 경험이 가장 좋고, 사람들이 테스트를 한다는 것 자체가 작은 기적임을 생각하면 테스트 라이브러리 저자로서 가장 구축할 가치가 있는 접근처럼 느껴짐

    • 로즈 트리 “통합 축소”(예: Hedgehog)가 생성기의 제약 조건을 따르지만 모나드 바인드에 문제가 있다는 부분은, 내 아마추어 지식의 한계 안에서는 모나드 바인드/생성기의 근본적 한계라고 봄
      대신 최적의 축소를 위해 applicative 생성기를 선호해야 함: https://github.com/hedgehogqa/haskell-hedgehog/issues/473#is...
      다시 말해 applicative 생성기는 “생성기 결과를 사용해 다른 생성기로 분기”하지 않으며, applicative의 “병렬” 성격 때문에 축소가 최적화됨. 여기서 병렬은 글의 스레딩 의미가 아니라 모나드적 의미임. applicative는 “병렬”이므로 생성기를 독립적으로 줄일 수 있음. 반면 모나드 생성기는 “직렬”이라 하나를 줄이면 뒤따르는 생성기의 동작이 반드시 바뀜
      공개된 발표라면 링크를 보고 싶음
    • 개인적으로 Hypothesis는 “그냥 되는” 것과는 거리가 멀었음
      실제 프로덕션 준비가 되어 있다고 생각하지 않고, 그것도 설계상 그런 것처럼 보임.[0]
      clojure.spec.alphatest.check와 함께 또는 없이 꽤 써본 경험이 있어서, 차이가 있더라도 일반적인 아이디어에 완전히 낯선 상태는 아니었음
      [0] https://github.com/HypothesisWorks/hypothesis/issues/3493
    • 내가 선호하는 프레임워크는 falsify이고, “내부 통합 축소”를 제공함
      Hypothesis와 비슷하지만 선형 시퀀스가 아니라 생성기 트리를 사용함. 선택적 펑터(selective functor)에 기반하며, 이는 검증기 같은 데도 유용한 좋은 인터페이스임
      https://hackage.haskell.org/package/falsify에 따르면, 이 라이브러리는 내부 통합 축소를 지원하는 속성 기반 테스트를 제공함. Hedgehog 의미의 통합, 즉 별도 축소기와 생성기를 작성할 필요가 없다는 뜻이고, Hypothesis 의미의 내부, 즉 모나드 바인드 전반에서도 잘 동작한다는 뜻임
  • 속성 기반 테스트를 써보려 했지만 항상 두 의자 사이에 낀 느낌이었음
    어떤 속성을 엄격하게 테스트할 만큼 잘 이해한다면 보통 타입 시스템으로 밀어 넣어 구성상 참이 되게 만들 수 있음. 그냥 간단한 연기 테스트를 원한다면 임의 입력 하나가 더 쉬움

    • 어떤 종류의 속성을 염두에 둔 건지 궁금함
      예를 들어 느리지만 단순한 순진한 구현과 최적화된 구현 두 개가 있을 때가 많고, 임의 입력에 대해 둘의 출력을 비교할 수 있음. 이해하기 쉬운 단순한 속성이지만 일반적으로 타입 시스템에 넣기는 어려움
      마찬가지로 입력이 제시되는 순서가 중요하지 않아야 한다거나, 데이터를 나눌 수 있는 방식이 있어 max(A의 최대값, B의 최대값) = maximum(A union B) 같은 성질이 있을 수 있음. 이런 걸 타입 시스템에 어떻게 인코딩할 수 있을까?
      또는 “임의의 A와 B에 대해 A에서 찾은 어떤 최적해는 A union B에서 찾은 어떤 최적해보다 나쁘다” 같은 것이나, f(f(A)) = f(A) 같은 멱등성도 있음
      이런 것들은 모두 이해하기 쉬운 속성이지만, 대부분의 타입 시스템에서 설명하기는 쉽지 않음
    • 가능하다면 컴파일 시간에 제약을 강제하는 것이 분명 더 좋음
      하지만 주류 타입 검사기가 다루지 못하는 제약도 많음. 의존 타입이 많이 도움이 되겠지만, 아직 정리 증명기 같은 틈새에 제한된 것처럼 보임
  • 목록에 원래의 QuviQ Erlang QuickCheck가 빠진 건 아닌지 궁금함
    전체 제품은 독점이지만, 무료 버전인 QuickCheck Mini도 제공됨: http://www.quviq.com/downloads/

  • Clojure에도 이제 상태 있는 quickcheck 라이브러리가 있음: https://github.com/griffinbank/test.contract
    병렬 테스트는 흥미롭지만, 아직 큰 고통의 원천은 아니었음

  • C#/.NET 테스트에는 CsCheck[0]를 써왔고 꽤 만족스러웠음
    Hedgehog나 FsCheck보다 훨씬 접근하기 쉬웠고 속도도 꽤 빠름
    [0] https://github.com/AnthonyLloyd/CsCheck