# Zod Contract Testing

> Validates Zod schema parsing at boundaries. Tests valid/invalid inputs, schema evolution, refinement coverage, and compound state matrices (2^N optional field combinations).

- Skill: `jsgforever/zod-contract-testing` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add jsgforever/zod-contract-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jsgforever/zod-contract-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jsgforever (https://skillmd.com/u/jsgforever)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jsgforever/zod-contract-testing

---


# Zod Contract Testing

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.

## 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" | `.refine()` failures are easy to miss | Test BOTH passing and failing cases |
| "Optional fields are optional" | 2^N combinations have hidden bugs | Use compound state matrix |

---

## 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? | `testInvalidInput` with `expectedPath` |
| Schema changes must not break old data | Is there serialized/stored data in the old format? | `testSchemaEvolution` |
| Version upgrades must be backward compatible | Do multiple schema versions coexist in production? | `generateVersionCompatibilityMatrix` |
| Optional field combinations have hidden bugs | Does this schema have 3+ optional fields with interacting refinements? | `generateCompoundStateMatrix` |
| Refinements must reject specific threats | What invalid-but-plausible input does each `.refine()` guard against? | `testRefinement` |

**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.

---

## Included Utilities

```typescript
import {
  testValidInput,
  testInvalidInput,
  testSchemaEvolution,
  generateVersionCompatibilityMatrix,
  assertVersionCompatibility,
  testRefinement,
  generateCompoundStateMatrix,
  formatStateMatrix,
  applyCompoundState,
} from './schema-boundary.ts';
```

## Core Workflow

### Step 1: Test Valid Inputs

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
});
```

### Step 2: Test Invalid Inputs

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' });
});
```

### Step 3a: Test Schema Evolution

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);
});
```

### Step 3b: Build Version Compatibility Matrix

For evolving contracts, check `vN -> vN+1` and `vN -> vN+2` explicitly:

```typescript
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

Every `.refine()` MUST have tests for both passing and failing cases:

```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

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 |
*/
```

### Step 6: Apply Matrix to Schema Tests

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);
      }
    });
  }
});
```

---

## 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](https://github.com/apankov1/quality-engineering/tree/main/skills/pairwise-test-coverage) — covers all field pairs in near-minimal test cases |

---

## Violation Rules

### missing_invalid_input_test
Every Zod schema MUST have tests for invalid inputs, not just valid ones.
**Severity**: must-fail

### missing_refinement_coverage
Every `.refine()` or `.superRefine()` MUST have tests for both the passing AND failing case.
**Severity**: must-fail

### missing_compound_state_test
Schemas with 3+ optional fields SHOULD use compound state matrix. For 5+ fields, switch to [pairwise-test-coverage](https://github.com/apankov1/quality-engineering/tree/main/skills/pairwise-test-coverage) rather than testing all 2^N individually.
**Severity**: should-fail

### schema_not_at_boundary
Zod parsing MUST happen at system boundaries (API handlers, WebSocket messages, database reads), not inside business logic.
**Severity**: should-fail

### type_assertion_instead_of_parse
Use `Schema.parse()` or `Schema.safeParse()`, NEVER `as Type` casts for external data.
**Severity**: must-fail

---

## Companion Skills

This skill teaches **testing methodology**, not Zod API usage. For broader methodology:

- Search `zod` on [skills.sh](https://skills.sh) for schema authoring, transforms, error handling, and framework integrations
- Our utilities work with any Zod version (v3, v4) via the `ZodLikeSchema` interface — no version lock-in
- Schemas with 5+ optional fields produce 32+ combinations — use [pairwise-test-coverage](https://github.com/apankov1/quality-engineering/tree/main/skills/pairwise-test-coverage) for near-minimal coverage of all field pairs
- Schema parsing at boundaries often logs errors — use [observability-testing](https://github.com/apankov1/quality-engineering/tree/main/skills/observability-testing) to assert structured log output on validation failures
- Schema evolution testing pairs with resilience — use [fault-injection-testing](https://github.com/apankov1/quality-engineering/tree/main/skills/fault-injection-testing) for retry and circuit breaker testing around schema-validated endpoints

---

## Quick Reference

| Utility | When | Example |
|---------|------|---------|
| `testValidInput` | Verify acceptance | `testValidInput(schema, { name: 'Alice' })` |
| `testInvalidInput` | Verify rejection | `testInvalidInput(schema, {}, 'name')` |
| `testSchemaEvolution` | Backward compat | `testSchemaEvolution(newSchema, oldData)` |
| `generateVersionCompatibilityMatrix` | Version matrix | `generateVersionCompatibilityMatrix(versions)` |
| `assertVersionCompatibility` | Enforce vN->vN+K | `assertVersionCompatibility(versions, 2)` |
| `testRefinement` | Pass + fail | `testRefinement(schema, passing, failing, message)` |
| `generateCompoundStateMatrix` | Optional fields | `generateCompoundStateMatrix(['a', 'b', 'c'])` |
| `applyCompoundState` | Generate input | `applyCompoundState(entry, template)` |

See [patterns.md](./references/patterns.md) for Zod-specific patterns, boundary testing methodology, and integration with TypeScript strict mode.

