zod-contract-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseZod Contract Testing
Zod 契约测试
Test schemas at boundaries — not just happy-path inputs.
Schemas define contracts between systems. A schema that accepts invalid data is a security hole. A schema that rejects valid data is a broken integration. This skill teaches you to systematically test schemas at system boundaries.
When to use: Testing API request/response schemas, WebSocket message validation, database record parsing, external data ingestion, any Zod schema at a system boundary.
When not to use: Internal type assertions, UI component props, type definitions without runtime validation.
在边界处测试schema —— 不要只测试正向路径输入。
Schema定义了系统之间的契约。接受无效数据的schema是安全漏洞,拒绝有效数据的schema则意味着集成故障。本技能将教你如何系统地测试系统边界处的schema。
适用场景:测试API请求/响应schema、WebSocket消息校验、数据库记录解析、外部数据摄入,以及所有位于系统边界的Zod schema。
不适用场景:内部类型断言、UI组件props、无运行时校验的类型定义。
Rationalizations (Do Not Skip)
常见误区(请勿跳过)
| Rationalization | Why It's Wrong | Required Action |
|---|---|---|
| "The type system ensures correctness" | TypeScript doesn't exist at runtime | Test Zod parsing with real data |
| "I tested valid inputs" | Invalid inputs cause production errors | Test rejection of invalid inputs |
| "Refinements are simple" | | Test BOTH passing and failing cases |
| "Optional fields are optional" | 2^N combinations have hidden bugs | Use compound state matrix |
| 误区 | 错误原因 | 必要操作 |
|---|---|---|
| "类型系统能保证正确性" | TypeScript在运行时不存在 | 使用真实数据测试Zod解析逻辑 |
| "我已经测试过有效输入了" | 无效输入会导致生产环境故障 | 测试schema对无效输入的拦截逻辑 |
| "自定义校验规则很简单" | | 同时测试校验规则的通过和失败两种场景 |
| "可选字段本来就是可选的" | 2^N种组合中隐藏着未被发现的bug | 使用复合状态矩阵覆盖所有组合 |
What To Protect (Start Here)
防护场景确认(从此处开始)
Before generating schema tests, identify which data integrity decisions apply to your code:
| Decision | Question to Answer | If Yes → Use |
|---|---|---|
| Invalid data must be rejected with specific errors | What malformed inputs could reach this boundary? | |
| Schema changes must not break old data | Is there serialized/stored data in the old format? | |
| Version upgrades must be backward compatible | Do multiple schema versions coexist in production? | |
| Optional field combinations have hidden bugs | Does this schema have 3+ optional fields with interacting refinements? | |
| Refinements must reject specific threats | What invalid-but-plausible input does each | |
Do not generate tests for decisions the human hasn't confirmed. A schema test that checks "valid input passes" without naming the threat it guards against is slop — it'll pass even if the schema accepts everything.
在生成schema测试之前,先确认你的代码需要覆盖哪些数据完整性决策:
| 决策 | 需要回答的问题 | 回答为是 → 使用工具 |
|---|---|---|
| 必须以特定错误拒绝无效数据 | 哪些格式错误的输入可能抵达该边界? | 传入 |
| schema变更不能破坏旧数据兼容性 | 是否存在旧格式的序列化/存储数据? | |
| 版本升级必须向后兼容 | 生产环境中是否同时存在多个schema版本? | |
| 可选字段组合存在隐藏bug | 该schema是否有3个及以上存在交互校验逻辑的可选字段? | |
| 自定义校验规则必须拦截特定风险 | 每个 | |
不要为开发人员未确认的决策生成测试。 一个只检查「有效输入能通过」却不明确防护目标的schema测试是无效的——哪怕schema接受所有输入,这种测试也能通过。
Included Utilities
内置工具函数
typescript
import {
testValidInput,
testInvalidInput,
testSchemaEvolution,
generateVersionCompatibilityMatrix,
assertVersionCompatibility,
testRefinement,
generateCompoundStateMatrix,
formatStateMatrix,
applyCompoundState,
} from './schema-boundary.ts';typescript
import {
testValidInput,
testInvalidInput,
testSchemaEvolution,
generateVersionCompatibilityMatrix,
assertVersionCompatibility,
testRefinement,
generateCompoundStateMatrix,
formatStateMatrix,
applyCompoundState,
} from './schema-boundary.ts';Core Workflow
核心工作流
Step 1: Test Valid Inputs
步骤1:测试有效输入
Verify the schema accepts all valid input shapes:
typescript
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(),
});
it('accepts valid user', () => {
testValidInput(UserSchema, { name: 'Alice', age: 30 });
testValidInput(UserSchema, { name: 'Bob' }); // age optional
});验证schema接受所有合法的输入结构:
typescript
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(),
});
it('accepts valid user', () => {
testValidInput(UserSchema, { name: 'Alice', age: 30 });
testValidInput(UserSchema, { name: 'Bob' }); // age optional
});Step 2: Test Invalid Inputs
步骤2:测试无效输入
Verify the schema rejects invalid data and errors at the correct path:
typescript
it('rejects missing required field', () => {
testInvalidInput(UserSchema, {}, 'name'); // Error at 'name' path
});
it('rejects wrong type', () => {
testInvalidInput(UserSchema, { name: 123 }, 'name');
});
it('rejects unknown fields with strict schema', () => {
const StrictUserSchema = UserSchema.strict();
testInvalidInput(StrictUserSchema, { name: 'Alice', role: 'admin' });
});验证schema拦截无效数据,且错误提示出现在正确的路径上:
typescript
it('rejects missing required field', () => {
testInvalidInput(UserSchema, {}, 'name'); // Error at 'name' path
});
it('rejects wrong type', () => {
testInvalidInput(UserSchema, { name: 123 }, 'name');
});
it('rejects unknown fields with strict schema', () => {
const StrictUserSchema = UserSchema.strict();
testInvalidInput(StrictUserSchema, { name: 'Alice', role: 'admin' });
});Step 3a: Test Schema Evolution
步骤3a:测试schema演进
When schemas change, old serialized data must still parse:
typescript
// Old schema: { name: string }
// New schema: { name: string, email?: string }
const NewUserSchema = z.object({
name: z.string(),
email: z.string().email().optional(),
});
it('backward compatible with old data', () => {
const oldData = { name: 'Alice' }; // No email field
testSchemaEvolution(NewUserSchema, oldData);
});当schema发生变更时,旧的序列化数据必须仍能被正常解析:
typescript
// Old schema: { name: string }
// New schema: { name: string, email?: string }
const NewUserSchema = z.object({
name: z.string(),
email: z.string().email().optional(),
});
it('backward compatible with old data', () => {
const oldData = { name: 'Alice' }; // No email field
testSchemaEvolution(NewUserSchema, oldData);
});Step 3b: Build Version Compatibility Matrix
步骤3b:构建版本兼容性矩阵
For evolving contracts, check and explicitly:
vN -> vN+1vN -> vN+2typescript
const versions = [
{ version: 'v1', schema: V1Schema, fixtures: [v1Payload] },
{ version: 'v2', schema: V2Schema, fixtures: [v2Payload] },
{ version: 'v3', schema: V3Schema, fixtures: [v3Payload] },
];
const matrix = generateVersionCompatibilityMatrix(versions);
// Enforce only adjacent upgrades (vN -> vN+1)
assertVersionCompatibility(versions, 1);
// Optionally enforce two-hop upgrades (vN -> vN+2)
assertVersionCompatibility(versions, 2);对于持续演进的契约,显式检查和的兼容性:
vN -> vN+1vN -> vN+2typescript
const versions = [
{ version: 'v1', schema: V1Schema, fixtures: [v1Payload] },
{ version: 'v2', schema: V2Schema, fixtures: [v2Payload] },
{ version: 'v3', schema: V3Schema, fixtures: [v3Payload] },
];
const matrix = generateVersionCompatibilityMatrix(versions);
// Enforce only adjacent upgrades (vN -> vN+1)
assertVersionCompatibility(versions, 1);
// Optionally enforce two-hop upgrades (vN -> vN+2)
assertVersionCompatibility(versions, 2);Step 4: Test Refinements
步骤4:测试自定义校验规则
Every MUST have tests for both passing and failing cases:
.refine()typescript
const PositiveNumberSchema = z.object({
value: z.number().refine(n => n > 0, 'Value must be positive'),
});
it('refinement: positive vs non-positive', () => {
testRefinement(
PositiveNumberSchema,
{ value: 10 }, // Passing case
{ value: -5 }, // Failing case
'must be positive', // Expected error message
);
});每个必须同时覆盖通过和失败两种场景的测试:
.refine()typescript
const PositiveNumberSchema = z.object({
value: z.number().refine(n => n > 0, 'Value must be positive'),
});
it('refinement: positive vs non-positive', () => {
testRefinement(
PositiveNumberSchema,
{ value: 10 }, // Passing case
{ value: -5 }, // Failing case
'must be positive', // Expected error message
);
});Step 5: Generate Compound State Matrix
步骤5:生成复合状态矩阵
For schemas with N optional fields, there are 2^N possible combinations. For 3-4 fields, test exhaustively. For 5+, switch to pairwise coverage (see decision table below):
typescript
// Cell schema: value?, candidates?, isGiven?
// 2^3 = 8 combinations
const matrix = generateCompoundStateMatrix(['value', 'candidates', 'isGiven']);
console.log(formatStateMatrix(matrix));
/*
| # | value | candidates | isGiven | Label |
|---|-------|------------|---------|-------|
| 0 | - | - | - | (empty) |
| 1 | Y | - | - | value |
| 2 | - | Y | - | candidates |
| 3 | Y | Y | - | value + candidates |
| 4 | - | - | Y | isGiven |
| 5 | Y | - | Y | value + isGiven |
| 6 | - | Y | Y | candidates + isGiven |
| 7 | Y | Y | Y | value + candidates + isGiven |
*/对于有N个可选字段的schema,存在2^N种可能的组合。如果是3-4个字段,可以全量测试;如果是5个及以上,切换为成对覆盖(参考下方决策表):
typescript
// Cell schema: value?, candidates?, isGiven?
// 2^3 = 8 combinations
const matrix = generateCompoundStateMatrix(['value', 'candidates', 'isGiven']);
console.log(formatStateMatrix(matrix));
/*
| # | value | candidates | isGiven | Label |
|---|-------|------------|---------|-------|
| 0 | - | - | - | (empty) |
| 1 | Y | - | - | value |
| 2 | - | Y | - | candidates |
| 3 | Y | Y | - | value + candidates |
| 4 | - | - | Y | isGiven |
| 5 | Y | - | Y | value + isGiven |
| 6 | - | Y | Y | candidates + isGiven |
| 7 | Y | Y | Y | value + candidates + isGiven |
*/Step 6: Apply Matrix to Schema Tests
步骤6:将矩阵应用到schema测试
Generate test inputs from the matrix:
typescript
const CellSchema = z.object({
value: z.number().optional(),
candidates: z.array(z.number()).optional(),
isGiven: z.boolean().optional(),
}).refine(
cell => !(cell.isGiven && cell.value === undefined),
'Given cells must have a digit value',
);
describe('cell schema compound states', () => {
const matrix = generateCompoundStateMatrix(['value', 'candidates', 'isGiven']);
const template = { value: 5, candidates: [1, 3, 7], isGiven: true };
for (const entry of matrix) {
const input = applyCompoundState(entry, template);
const shouldFail = input.isGiven && input.value === undefined;
it(`${entry.label}: ${shouldFail ? 'rejects' : 'accepts'}`, () => {
if (shouldFail) {
testInvalidInput(CellSchema, input); // Object-level .refine() reports at root path
} else {
testValidInput(CellSchema, input);
}
});
}
});基于矩阵生成测试用例输入:
typescript
const CellSchema = z.object({
value: z.number().optional(),
candidates: z.array(z.number()).optional(),
isGiven: z.boolean().optional(),
}).refine(
cell => !(cell.isGiven && cell.value === undefined),
'Given cells must have a digit value',
);
describe('cell schema compound states', () => {
const matrix = generateCompoundStateMatrix(['value', 'candidates', 'isGiven']);
const template = { value: 5, candidates: [1, 3, 7], isGiven: true };
for (const entry of matrix) {
const input = applyCompoundState(entry, template);
const shouldFail = input.isGiven && input.value === undefined;
it(`${entry.label}: ${shouldFail ? 'rejects' : 'accepts'}`, () => {
if (shouldFail) {
testInvalidInput(CellSchema, input); // Object-level .refine() reports at root path
} else {
testValidInput(CellSchema, input);
}
});
}
});Compound State Matrix Decision
复合状态矩阵决策表
| Optional Fields | Combinations | Testing Approach |
|---|---|---|
| 0-2 | 1-4 | Enumerate manually |
| 3 | 8 | Use matrix, manageable |
| 4 | 16 | Use matrix, essential |
| 5+ | 32+ | Switch to pairwise-test-coverage — covers all field pairs in near-minimal test cases |
| 可选字段数量 | 组合数 | 测试方案 |
|---|---|---|
| 0-2 | 1-4 | 手动枚举 |
| 3 | 8 | 使用矩阵,工作量可控 |
| 4 | 16 | 使用矩阵,非常有必要 |
| 5+ | 32+ | 切换为pairwise-test-coverage——用接近最少的测试用例覆盖所有字段对组合 |
Violation Rules
违规规则
missing_invalid_input_test
missing_invalid_input_test
Every Zod schema MUST have tests for invalid inputs, not just valid ones.
Severity: must-fail
每个Zod schema必须包含无效输入测试,不能只测试有效输入。
严重级别:必须拦截
missing_refinement_coverage
missing_refinement_coverage
Every or MUST have tests for both the passing AND failing case.
Severity: must-fail
.refine().superRefine()每个或必须同时包含通过和失败场景的测试。
严重级别:必须拦截
.refine().superRefine()missing_compound_state_test
missing_compound_state_test
Schemas with 3+ optional fields SHOULD use compound state matrix. For 5+ fields, switch to pairwise-test-coverage rather than testing all 2^N individually.
Severity: should-fail
包含3个及以上可选字段的schema建议使用复合状态矩阵。对于5个及以上字段的schema,切换为pairwise-test-coverage,不要逐个测试全部2^N种组合。
严重级别:建议拦截
schema_not_at_boundary
schema_not_at_boundary
Zod parsing MUST happen at system boundaries (API handlers, WebSocket messages, database reads), not inside business logic.
Severity: should-fail
Zod解析必须在系统边界处执行(API handler、WebSocket消息处理、数据库读取),不能放在业务逻辑内部。
严重级别:建议拦截
type_assertion_instead_of_parse
type_assertion_instead_of_parse
Use or , NEVER casts for external data.
Severity: must-fail
Schema.parse()Schema.safeParse()as Type对于外部数据,使用或,绝对不要使用类型断言。
严重级别:必须拦截
Schema.parse()Schema.safeParse()as TypeCompanion Skills
关联技能
This skill teaches testing methodology, not Zod API usage. For broader methodology:
- Search on skills.sh for schema authoring, transforms, error handling, and framework integrations
zod - Our utilities work with any Zod version (v3, v4) via the interface — no version lock-in
ZodLikeSchema - Schemas with 5+ optional fields produce 32+ combinations — use pairwise-test-coverage for near-minimal coverage of all field pairs
- Schema parsing at boundaries often logs errors — use observability-testing to assert structured log output on validation failures
- Schema evolution testing pairs with resilience — use fault-injection-testing for retry and circuit breaker testing around schema-validated endpoints
本技能教授的是测试方法论,而非Zod API使用方法。如需了解更广泛的方法论:
- 在skills.sh搜索可查看schema编写、转换、错误处理以及框架集成相关内容
zod - 我们的工具通过接口兼容所有Zod版本(v3、v4)——无版本绑定
ZodLikeSchema - 包含5个及以上可选字段的schema会产生32种以上组合——使用pairwise-test-coverage可以用接近最少的用例覆盖所有字段对
- 边界处的schema解析通常会记录错误日志——使用observability-testing可以验证校验失败时的结构化日志输出
- schema演进测试和弹性能力适配——使用fault-injection-testing可以测试schema校验接口的重试和熔断逻辑
Quick Reference
快速参考
| Utility | When | Example |
|---|---|---|
| Verify acceptance | |
| Verify rejection | |
| Backward compat | |
| Version matrix | |
| Enforce vN->vN+K | |
| Pass + fail | |
| Optional fields | |
| Generate input | |
See patterns.md for Zod-specific patterns, boundary testing methodology, and integration with TypeScript strict mode.
| 工具函数 | 使用场景 | 示例 |
|---|---|---|
| 验证输入被接受 | |
| 验证输入被拦截 | |
| 向后兼容性测试 | |
| 生成版本兼容性矩阵 | |
| 强制vN->vN+K兼容 | |
| 校验规则通/断场景测试 | |
| 可选字段组合覆盖 | |
| 生成测试输入 | |
查看patterns.md了解Zod专属模式、边界测试方法论以及与TypeScript严格模式的集成方法。