Testing Your Lexicon
This guide documents what each test file in a lexicon should cover, with patterns and checklists. Follow these to ensure consistent test quality across lexicons.
Two test files are required by name. chant dev check-lexicon fails tier 1
without src/plugin.test.ts and src/serializer.test.ts.
Shared helpers: @intentius/chant-test-utils
Section titled “Shared helpers: @intentius/chant-test-utils”Before hand-rolling a fixture, check packages/test-utils/src/. 131 test files
across the shipped lexicons import from it, and a lexicon that reimplements
makeCtx gets a context that drifts from the one the real build path
constructs.
| Helper | Module | What it gives you |
|---|---|---|
createPostSynthContext(outputs) | fixtures.ts | A PostSynthContext from a record of lexicon name to template object, JSON-serialized. Wires ctx.docs exactly as runPostSynthChecks does (chant #975) |
makePostSynthCtx(lexicon, output, entities?) | post-synth-harness.ts | The same, from an already-serialized string (YAML, JSON, TOML) |
makePostSynthCtxFromFiles(lexicon, files, primary?, entities?) | post-synth-harness.ts | From a multi-file SerializerResult, as helm produces |
makePostSynthCtxFromJSON(lexicon, template, entities?) | post-synth-harness.ts | From a plain object, for JSON formats |
runCheck, expectNoDiagnostics, expectDiagnostic | post-synth-harness.ts | Run one check and assert on its diagnostics |
describeExample, describeAllExamples | example-harness.ts | Register lint and build tests for examples/*, which tier 1 requires to build |
createMockEntity, createMockSerializer, createMockLintRule, createMockLintContext | fixtures.ts | Core-level fixtures for lint rule tests |
createMockPlugin | mock-plugin.ts | A LexiconPlugin with only the members you pass, for exercising a degradation path |
describeObservationConformance | observation-conformance.ts | The observation contract suite. See Observation Contract |
describeApplyConformance | apply-conformance.ts | The apply contract suite. See Apply Conformance Suite |
createTestDir, cleanupTestDir, withTestDir | fs.ts | Temp-directory lifecycle |
expectToThrow, FIXTURE | assertions.ts, fixture-constants.ts | Assertion and constant helpers |
The package is @intentius/chant-test-utils, with ./example-harness as a
second entry point. It is private: true and unpublished, and resolves through
the root workspaces: ["packages/*", "lexicons/*"] entry, so no lexicon
declares it as a dependency. A lexicon developed outside this repo has to copy
the helpers it needs.
examples tests
Section titled “examples tests”Tier 1 requires every shipped example to build and to pass the lexicon’s own
post-synth checks. describeAllExamples registers both tests per example
directory:
import { describeAllExamples } from "@intentius/chant-test-utils/example-harness";import { mySerializer } from "@intentius/chant-lexicon-mylexicon";
describeAllExamples( { lexicon: "mylexicon", serializer: mySerializer, outputKey: "mylexicon", examplesDir: import.meta.dirname, }, { "getting-started": { skipLint: true }, },);The second argument overrides per example: checks(output) for custom
assertions, skipLint, skipBuild. See lexicons/k8s/examples/examples.test.ts.
serializer.test.ts
Section titled “serializer.test.ts”The serializer converts declarables to your output format. Cover these 12 cases, which are the same twelve listed in Create a Serializer.
Mock helpers
Section titled “Mock helpers”Create mockResource and mockProperty helpers using DECLARABLE_MARKER. Both
put the authored values under props, which is where a real generated class
puts them:
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
function mockResource(entityType: string, props: Record<string, unknown>): any { return { [DECLARABLE_MARKER]: true, lexicon: "my-lexicon", entityType, kind: "resource", props, };}
function mockProperty(entityType: string, props: Record<string, unknown>): any { return { [DECLARABLE_MARKER]: true, lexicon: "my-lexicon", entityType, kind: "property", props, };}Checklist
Section titled “Checklist”| # | Test case | What it verifies |
|---|---|---|
| 1 | Serializer name | serializer.name matches the lexicon |
| 2 | Rule prefix | serializer.rulePrefix matches the declared prefix |
| 3 | Empty entities | serialize(new Map()) returns an empty string |
| 4 | Single resource | Produces valid output in your format (YAML/JSON/text) |
| 5 | Auto-generated name | camelCase export name becomes your format’s name form |
| 6 | Explicit name preserved | An author-set name is not overwritten |
| 7 | Multi-resource | Multiple entities joined correctly (a --- separator for YAML, one file per entity for k3s and k3d) |
| 8 | Defaults merged | Whatever your format injects when the declaration is silent |
| 9 | Explicit overrides win | A resource-level value beats the default |
| 10 | Property entities skipped | kind: "property" entities are inlined, not emitted as separate documents |
| 11 | Key ordering | Output keys in canonical order, independent of prop declaration order |
| 12 | Format-specific | Whatever is peculiar to your format. Cedar parses its own output back through cedar-wasm; k3s asserts flag names survive as keys |
lexicons/cedar/src/serializer.test.ts is sectioned by these numbers.
Two more that every shipped serializer test covers:
- Ownership stamping when the build carries a marker, plus the case where an
author’s own key with the same name wins (
lexicons/k3d/src/serializer.test.ts,lexicons/k8s/src/serializer-ownership.test.ts). - Round-trip: the emitted text parses back to the declared values.
Additional lexicon-specific cases:
- Specless types (K8s: ConfigMap, Secret)
- Fallback type resolution (GCP: derive GVK from entity type string)
default-labels.test.ts
Section titled “default-labels.test.ts”Only for a lexicon that ships label/annotation declaration utilities, as k8s
does (lexicons/k8s/src/default-labels.ts). chant dev check-lexicon does not
look for this file.
Test the label/annotation declaration utilities:
| Test case | What it verifies |
|---|---|
| Correct markers | DEFAULT_LABELS_MARKER and DECLARABLE_MARKER are true |
| Lexicon property | .lexicon matches your lexicon name |
| Entity type | .entityType matches convention |
| Accessible values | Labels/annotations are readable |
| Empty allowed | defaultLabels({}) doesn’t throw |
| Type guards | isDefaultLabels() true for labels, false for annotations/null/undefined |
| Cross-checks | isDefaultAnnotations() false for labels, and vice versa |
LSP tests
Section titled “LSP tests”Pattern: conditional skip
Section titled “Pattern: conditional skip”Use test.skipIf(!hasGenerated) for tests that depend on the generated lexicon registry:
import { existsSync, readFileSync } from "fs";import { join, dirname } from "path";import { fileURLToPath } from "url";
const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));const lexiconPath = join(pkgDir, "src", "generated", "lexicon-{name}.json");const hasGenerated = existsSync(lexiconPath) && (() => { try { const content = JSON.parse(readFileSync(lexiconPath, "utf-8")); return Object.keys(content).length > 0; } catch { return false; }})();completions.test.ts
Section titled “completions.test.ts”| Test case | hasGenerated | What it verifies |
|---|---|---|
| Non-constructor context | No | Returns empty array for const x = 42 |
| Constructor prefix | Yes | new D returns results including Deployment |
| Prefix filtering | Yes | new StatefulS returns StatefulSet |
hover.test.ts
Section titled “hover.test.ts”| Test case | hasGenerated | What it verifies |
|---|---|---|
| Unknown word | No | Returns undefined for nonexistent resource |
| Known resource | Yes | Returns defined hover info |
| Empty string | Yes | Returns undefined |
| Content check | Yes | Hover content is non-empty |
Import tests
Section titled “Import tests”parser.test.ts
Section titled “parser.test.ts”| Test case | What it verifies |
|---|---|
| Empty YAML | Returns empty resources and parameters |
| Single resource | Correct type mapping (apiVersion+kind → type name) |
| Multi-doc | Multiple resources from ----separated YAML |
| Type mapping | Each apiVersion/kind combo maps correctly |
| Non-lexicon filtered | Resources from other lexicons are ignored |
| Logical name | metadata.name extracted correctly |
| Properties | Include metadata+spec, exclude apiVersion/kind |
| Parameters empty | Lexicons without parameters return [] |
generator.test.ts
Section titled “generator.test.ts”| Test case | What it verifies |
|---|---|
| Valid TypeScript | Output contains import + constructor |
| Correct import source | Uses @intentius/chant-lexicon-{name} |
| Multiple resources | Multiple export const declarations |
| Variable naming | kebab-case → camelCase conversion |
| Empty IR | Doesn’t crash on empty input |
| Nested objects | Proper formatting of nested props |
roundtrip.test.ts
Section titled “roundtrip.test.ts”Create testdata manifests as instance YAML (not CRD schemas):
src/testdata/manifests/├── resource-a.yaml # Single resource├── resource-b.yaml # Single resource└── full-app.yaml # Multi-doc with 3+ resources| Test case | What it verifies |
|---|---|
| Single resource roundtrip | Parse → generate → contains constructor |
| Multi-doc roundtrip | All resources present in output |
| Inline YAML | Parse+generate works without fixture file |
coverage.test.ts
Section titled “coverage.test.ts”const hasGenerated = existsSync(join(generatedDir, "lexicon-{name}.json"));
test.skipIf(!hasGenerated)("analyze function exists", async () => { const { analyze } = await import("./coverage"); expect(typeof analyze).toBe("function");});
test("handles missing files gracefully", async () => { if (!hasGenerated) { try { await analyze(); } catch { /* Expected */ } }});post-synth tests
Section titled “post-synth tests”Pattern: build the context with the shared harness
Section titled “Pattern: build the context with the shared harness”A hand-rolled { outputs: new Map(...) } is not a PostSynthContext: it has no
entities, no buildResult, and no lazy ctx.docs, so a check that reads any
of them passes in the test and throws in the build. Use the harness.
import { makePostSynthCtx } from "@intentius/chant-test-utils";
const ctx = makePostSynthCtx("mylexicon", yaml);For a JSON format, makePostSynthCtxFromJSON("aws", template) serializes the
object for you; createPostSynthContext({ azure: template }) does the same
keyed by lexicon, which is the form lexicons/azure/src/lint/post-synth/*.test.ts
uses. For a multi-file serializer, makePostSynthCtxFromFiles.
Structure
Section titled “Structure”Every post-synth check needs both positive and negative tests:
import { makePostSynthCtx, expectDiagnostic, expectNoDiagnostics } from "@intentius/chant-test-utils";
describe("WGCXXX: description", () => { test("flags bad pattern", () => { const ctx = makePostSynthCtx("mylexicon", `...bad yaml...`); expectDiagnostic(wgcXXX, ctx, { checkId: "WGCXXX" }); });
test("no diagnostic when correct", () => { const ctx = makePostSynthCtx("mylexicon", `...good yaml...`); expectNoDiagnostics(wgcXXX, ctx); });});YAML parsing note
Section titled “YAML parsing note”The chant YAML parser treats unquoted https://... as key-value pairs. Always quote URLs in test YAML:
# Bad — parsed as { https: "//..." }- https://www.googleapis.com/auth/cloud-platform
# Good- "https://www.googleapis.com/auth/cloud-platform"Next Steps
Section titled “Next Steps”Run the lexicon’s suite with npx vitest run lexicons/{name}, then
chant dev check-lexicon lexicons/{name} for the tier list. See the
Completeness Checklist for
what each tier row means.