218 lines
5.3 KiB
Markdown
218 lines
5.3 KiB
Markdown
---
|
|
id: testing-property-based
|
|
title: Property-based Testing — fast-check / 자동 input
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [testing, property-based, fast-check, vibe-coding]
|
|
tech_stack: { language: "TS / fast-check", applicable_to: ["Backend", "Frontend"] }
|
|
applied_in: []
|
|
aliases: [property-based testing, fast-check, QuickCheck, Hypothesis, generator, shrinking]
|
|
---
|
|
|
|
# Property-based Testing
|
|
|
|
> 예시 input 만 테스트 = edge case 놓침. **속성 (property) + 자동 input 생성** = 100+ random case 시도. fast-check (TS), Hypothesis (Python), QuickCheck (Haskell).
|
|
|
|
## 📖 핵심 개념
|
|
- Property: "모든 input X 에 대해 결과 Y" 같은 universal 진리.
|
|
- Generator: random input 자동 생성.
|
|
- Shrinking: 실패한 input → 최소 reproduce.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### fast-check 기본
|
|
```ts
|
|
import fc from 'fast-check';
|
|
import { describe, test, expect } from 'vitest';
|
|
|
|
function reverse<T>(arr: T[]): T[] {
|
|
return [...arr].reverse();
|
|
}
|
|
|
|
describe('reverse', () => {
|
|
test('reverse twice = original', () => {
|
|
fc.assert(
|
|
fc.property(fc.array(fc.integer()), (arr) => {
|
|
expect(reverse(reverse(arr))).toEqual(arr);
|
|
})
|
|
);
|
|
});
|
|
|
|
test('length preserved', () => {
|
|
fc.assert(
|
|
fc.property(fc.array(fc.anything()), (arr) => {
|
|
expect(reverse(arr).length).toBe(arr.length);
|
|
})
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
`fc.assert` 가 100번 (default) random run.
|
|
|
|
### Generators
|
|
```ts
|
|
fc.integer({ min: 0, max: 100 })
|
|
fc.float({ min: 0, max: 1, noNaN: true })
|
|
fc.string({ minLength: 1, maxLength: 100 })
|
|
fc.array(fc.integer(), { minLength: 1 })
|
|
fc.uuid()
|
|
fc.emailAddress()
|
|
fc.date({ min: new Date('2020'), max: new Date('2030') })
|
|
fc.option(fc.integer()) // T | undefined
|
|
|
|
// Object
|
|
fc.record({
|
|
id: fc.uuid(),
|
|
email: fc.emailAddress(),
|
|
age: fc.integer({ min: 0, max: 120 }),
|
|
})
|
|
|
|
// Tuple
|
|
fc.tuple(fc.string(), fc.integer())
|
|
|
|
// One of
|
|
fc.oneof(fc.integer(), fc.string())
|
|
```
|
|
|
|
### Stateful test (model-based)
|
|
```ts
|
|
import fc, { Command } from 'fast-check';
|
|
|
|
class Stack<T> {
|
|
private arr: T[] = [];
|
|
push(x: T) { this.arr.push(x); }
|
|
pop(): T | undefined { return this.arr.pop(); }
|
|
size() { return this.arr.length; }
|
|
}
|
|
|
|
class PushCommand implements Command<{ size: number }, Stack<number>> {
|
|
constructor(readonly value: number) {}
|
|
check = (m: { size: number }) => true;
|
|
run(m: { size: number }, r: Stack<number>) {
|
|
r.push(this.value);
|
|
m.size++;
|
|
expect(r.size()).toBe(m.size);
|
|
}
|
|
}
|
|
|
|
class PopCommand implements Command<{ size: number }, Stack<number>> {
|
|
check = (m: { size: number }) => m.size > 0;
|
|
run(m, r) {
|
|
r.pop();
|
|
m.size--;
|
|
expect(r.size()).toBe(m.size);
|
|
}
|
|
}
|
|
|
|
test('stack', () => {
|
|
fc.assert(fc.property(
|
|
fc.commands([
|
|
fc.integer().map(v => new PushCommand(v)),
|
|
fc.constant(new PopCommand()),
|
|
], { maxCommands: 100 }),
|
|
(cmds) => fc.modelRun(() => ({ model: { size: 0 }, real: new Stack() }), cmds),
|
|
));
|
|
});
|
|
```
|
|
|
|
### Real-world: JSON serialize
|
|
```ts
|
|
test('JSON parse(stringify(x)) = x', () => {
|
|
fc.assert(
|
|
fc.property(fc.jsonValue(), (x) => {
|
|
expect(JSON.parse(JSON.stringify(x))).toEqual(x);
|
|
})
|
|
);
|
|
});
|
|
```
|
|
|
|
### 검증
|
|
```ts
|
|
import { z } from 'zod';
|
|
|
|
const User = z.object({ email: z.string().email(), age: z.number().int().positive() });
|
|
|
|
test('valid input always parses', () => {
|
|
fc.assert(fc.property(
|
|
fc.record({
|
|
email: fc.emailAddress(),
|
|
age: fc.integer({ min: 1, max: 200 }),
|
|
}),
|
|
(input) => {
|
|
expect(() => User.parse(input)).not.toThrow();
|
|
},
|
|
));
|
|
});
|
|
```
|
|
|
|
### Sort idempotent
|
|
```ts
|
|
test('sorted is idempotent', () => {
|
|
fc.assert(fc.property(
|
|
fc.array(fc.integer()),
|
|
(arr) => {
|
|
const s1 = [...arr].sort();
|
|
const s2 = [...s1].sort();
|
|
expect(s2).toEqual(s1);
|
|
},
|
|
));
|
|
});
|
|
```
|
|
|
|
### Shrinking 효과
|
|
```ts
|
|
// 실패 시 input 자동 최소화
|
|
// 처음: [42, -123, 0, 9999, ...] (10 items, 실패)
|
|
// shrink 후: [0, -1] (2 items, 같은 실패)
|
|
// → 디버깅 쉬움
|
|
```
|
|
|
|
### Reproduce 실패
|
|
```ts
|
|
fc.assert(fc.property(...), { seed: 1234, path: '5:0:1' });
|
|
// 같은 seed = 같은 input
|
|
```
|
|
|
|
### 통계 출력
|
|
```ts
|
|
fc.statistics(
|
|
fc.integer(),
|
|
(n) => n < 0 ? 'negative' : n === 0 ? 'zero' : 'positive',
|
|
100,
|
|
);
|
|
// 분포 확인 — 충분히 다양?
|
|
```
|
|
|
|
## 🤔 의사결정 기준
|
|
| 적합 | 부적합 |
|
|
|---|---|
|
|
| 순수 함수 | 외부 부수효과 |
|
|
| Encode/decode round-trip | UI 상호작용 |
|
|
| Sort / search algorithm | API integration |
|
|
| Parser / formatter | Auth / DB |
|
|
| 수학 / 변환 | E2E flow |
|
|
|
|
## ❌ 안티패턴
|
|
- **Property = 1 case**: 일반 unit test 와 같음. universal 속성 찾기.
|
|
- **Generator 너무 좁음**: bug 못 찾음.
|
|
- **너무 넓음 (모든 string)**: shrinking 비싸짐. 적절히.
|
|
- **외부 부수효과 (DB)**: random 에 안 맞음. mock.
|
|
- **Random 결과를 assertion 으로**: 결정론적 property.
|
|
- **Run 횟수 너무 적음 (10)**: edge case 놓침. 100+.
|
|
- **`fc.assert` 없이 single random**: 의미 없음.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Round-trip / 단조성 / idempotency / 길이 보존 같은 일반 속성 찾기.
|
|
- Encoder / parser / sort / search 가 강 candidate.
|
|
- shrinking 이 디버깅 무기.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Testing_Test_Pyramid]]
|
|
- [[Testing_Faker_and_Builders]]
|
|
- [[TS_Schema_Validation_Comparison]]
|