|
| 1 | +# Contract Testing |
| 2 | + |
| 3 | +PACT-style contract tests for AppKit apps. Consumer defines expectations, provider verifies. Use when multiple modules produce or consume the same data shapes. |
| 4 | + |
| 5 | +**When to use:** Multi-module apps, apps with jobs that produce/consume data, or any app where two or more modules share a data boundary. Skip for single-module prototypes. |
| 6 | + |
| 7 | +**Not mandatory.** Add contract tests when the app has multiple producers/consumers of the same data, or when a boundary change has caused a runtime bug. |
| 8 | + |
| 9 | +## What Contract Tests Are |
| 10 | + |
| 11 | +Contract tests verify that a producer's output matches what the consumer expects. They are NOT integration tests -- they run without network calls, databases, or live services. |
| 12 | + |
| 13 | +The pattern (PACT-style): |
| 14 | +1. **Consumer** defines a contract: "I expect this shape, with these constraints." |
| 15 | +2. **Provider** is verified against that contract: "Does my output satisfy every consumer?" |
| 16 | +3. If either side changes, the contract test fails at build time -- not in production. |
| 17 | + |
| 18 | +``` |
| 19 | +Consumer (Dashboard) Provider (Eval API) |
| 20 | + | | |
| 21 | + +-- expects scores 0..1 | |
| 22 | + +-- expects run_id as string | |
| 23 | + +-- expects status enum | |
| 24 | + | | |
| 25 | + +---- contract.test.ts -----------+ |
| 26 | + | |
| 27 | + vitest runs |
| 28 | + at build time |
| 29 | +``` |
| 30 | + |
| 31 | +## Contract Boundaries in AppKit Apps |
| 32 | + |
| 33 | +Each module boundary is a potential contract surface: |
| 34 | + |
| 35 | +| Boundary | Producer | Consumer | What to test | |
| 36 | +|----------|----------|----------|-------------| |
| 37 | +| frontend <-> server | tRPC router | React components | Response shapes, error codes, field presence | |
| 38 | +| server <-> lakebase | Lakebase migrations/queries | tRPC procedures | Row shapes, column types, NULL handling | |
| 39 | +| server <-> files | Files plugin | tRPC procedures | Volume paths, content types, metadata keys | |
| 40 | +| job <-> job | Upstream job task | Downstream job task | Task output shapes, status codes, payload encoding | |
| 41 | + |
| 42 | +## How to Write Contract Tests with Vitest |
| 43 | + |
| 44 | +Contract tests live alongside unit tests and run with `vitest`. |
| 45 | + |
| 46 | +### Basic Example |
| 47 | + |
| 48 | +```ts |
| 49 | +import { describe, it, expect } from "vitest"; |
| 50 | + |
| 51 | +// Simulated provider response -- in practice, import the type |
| 52 | +// and construct a minimal valid instance. |
| 53 | +const result = { |
| 54 | + run_id: "run-abc-123", |
| 55 | + appeval100: 0.87, |
| 56 | + status: "COMPLETED", |
| 57 | + metrics: { accuracy: 0.92, latency_ms: 340 }, |
| 58 | +}; |
| 59 | + |
| 60 | +describe("Dashboard expects Eval API", () => { |
| 61 | + it("returns a valid run_id", () => { |
| 62 | + expect(typeof result.run_id).toBe("string"); |
| 63 | + expect(result.run_id.length).toBeGreaterThan(0); |
| 64 | + }); |
| 65 | + |
| 66 | + it("returns results with scores between 0 and 1", () => { |
| 67 | + expect(result.appeval100).toBeGreaterThanOrEqual(0); |
| 68 | + expect(result.appeval100).toBeLessThanOrEqual(1); |
| 69 | + }); |
| 70 | + |
| 71 | + it("returns a known status enum value", () => { |
| 72 | + expect(["PENDING", "RUNNING", "COMPLETED", "FAILED"]).toContain( |
| 73 | + result.status |
| 74 | + ); |
| 75 | + }); |
| 76 | + |
| 77 | + it("includes metrics as a record of numbers", () => { |
| 78 | + for (const [key, value] of Object.entries(result.metrics)) { |
| 79 | + expect(typeof key).toBe("string"); |
| 80 | + expect(typeof value).toBe("number"); |
| 81 | + } |
| 82 | + }); |
| 83 | +}); |
| 84 | +``` |
| 85 | + |
| 86 | +### Testing Lakebase Row Shapes |
| 87 | + |
| 88 | +```ts |
| 89 | +import { describe, it, expect } from "vitest"; |
| 90 | +import type { RunRecord } from "../proto/gen/app/v1/database"; |
| 91 | + |
| 92 | +// Minimal valid row -- mirrors what Lakebase would return. |
| 93 | +const row: RunRecord = { |
| 94 | + run_id: "run-001", |
| 95 | + app_name: "my-app", |
| 96 | + status: "RUN_STATUS_PENDING", |
| 97 | + started_at: new Date().toISOString(), |
| 98 | + completed_at: "", |
| 99 | + error_message: "", |
| 100 | + config_json: "{}", |
| 101 | +}; |
| 102 | + |
| 103 | +describe("API module expects RunRecord from Lakebase", () => { |
| 104 | + it("has required fields", () => { |
| 105 | + expect(row.run_id).toBeTruthy(); |
| 106 | + expect(row.app_name).toBeTruthy(); |
| 107 | + }); |
| 108 | + |
| 109 | + it("status is a valid enum string", () => { |
| 110 | + expect(row.status).toMatch(/^RUN_STATUS_/); |
| 111 | + }); |
| 112 | + |
| 113 | + it("config_json is valid JSON", () => { |
| 114 | + expect(() => JSON.parse(row.config_json)).not.toThrow(); |
| 115 | + }); |
| 116 | +}); |
| 117 | +``` |
| 118 | + |
| 119 | +### Testing Job Task Outputs |
| 120 | + |
| 121 | +```ts |
| 122 | +import { describe, it, expect } from "vitest"; |
| 123 | +import type { JobTaskOutput } from "../proto/gen/app/v1/compute"; |
| 124 | + |
| 125 | +const output: JobTaskOutput = { |
| 126 | + task_id: "task-001", |
| 127 | + run_id: "run-001", |
| 128 | + success: true, |
| 129 | + error: "", |
| 130 | + output_payload: new Uint8Array([]), |
| 131 | + duration_ms: 1200, |
| 132 | + metrics: { rows_processed: "5000" }, |
| 133 | +}; |
| 134 | + |
| 135 | +describe("API module expects JobTaskOutput", () => { |
| 136 | + it("has matching run_id and task_id", () => { |
| 137 | + expect(output.run_id).toBeTruthy(); |
| 138 | + expect(output.task_id).toBeTruthy(); |
| 139 | + }); |
| 140 | + |
| 141 | + it("duration_ms is non-negative", () => { |
| 142 | + expect(output.duration_ms).toBeGreaterThanOrEqual(0); |
| 143 | + }); |
| 144 | + |
| 145 | + it("on success, error is empty", () => { |
| 146 | + if (output.success) { |
| 147 | + expect(output.error).toBe(""); |
| 148 | + } |
| 149 | + }); |
| 150 | +}); |
| 151 | +``` |
| 152 | + |
| 153 | +## Proto-First Contract Derivation |
| 154 | + |
| 155 | +The recommended workflow ties contract tests directly to proto definitions: |
| 156 | + |
| 157 | +``` |
| 158 | +1. Write the contract test -> "Dashboard expects scores 0..1" |
| 159 | +2. Derive the proto message -> message EvalResult { double score = 1; } |
| 160 | +3. Generate TypeScript types -> buf generate proto/ |
| 161 | +4. Implement provider -> tRPC route returns EvalResult |
| 162 | +5. Contract test passes -> Consumer expectation met |
| 163 | +``` |
| 164 | + |
| 165 | +This inverts the usual flow. Instead of writing the proto first and hoping consumers are satisfied, you start from what the consumer needs and work backward to the schema. The proto becomes the verified bridge. |
| 166 | + |
| 167 | +## Running Contract Tests |
| 168 | + |
| 169 | +Contract tests run with the rest of the vitest suite: |
| 170 | + |
| 171 | +```bash |
| 172 | +# Run all tests including contracts |
| 173 | +npx vitest run |
| 174 | + |
| 175 | +# Run only contract tests (by convention, name files *.contract.test.ts) |
| 176 | +npx vitest run --reporter=verbose "contract" |
| 177 | +``` |
| 178 | + |
| 179 | +## File Naming Convention |
| 180 | + |
| 181 | +``` |
| 182 | +tests/ |
| 183 | + contracts/ |
| 184 | + dashboard-eval-api.contract.test.ts |
| 185 | + api-lakebase-runs.contract.test.ts |
| 186 | + job-upstream-downstream.contract.test.ts |
| 187 | +``` |
| 188 | + |
| 189 | +Name each file `<consumer>-<provider>.contract.test.ts` so the boundary is obvious at a glance. |
| 190 | + |
| 191 | +## Common Traps |
| 192 | + |
| 193 | +| Trap | Why it fails | Fix | |
| 194 | +|------|-------------|-----| |
| 195 | +| Testing implementation, not shape | Test breaks on refactor even though contract holds | Assert on shape and constraints, not internal logic | |
| 196 | +| No contract for job boundaries | Job output changes silently, downstream breaks | Add contract test for every job->job and job->api boundary | |
| 197 | +| Duplicating validation logic | Contract and runtime validation diverge | Derive both from the proto; contract test checks the shape, runtime uses generated validators | |
| 198 | +| Testing only happy path | Missing fields or null values slip through | Add cases for empty strings, zero values, missing optional fields | |
0 commit comments