Skip to main content

Testing

Telo tests are themselves Telo manifests: a Run.Sequence (or top-level Assert.Events / Assert.Manifest) drives the test, and the Telo.Application's targets: list invokes it. A top-level test-suite.yaml orchestrates discovery via Test.Suite, globbing **/tests/*.yaml and excluding **/__fixtures__/**.

Where tests live

  • Cross-cutting kernel/analyzer teststests/. Exercise the module system (include, imports:), Telo.Definition semantics (extends, capability), and topology rules.
  • Module-specific testsmodules/<name>/tests/. Per CLAUDE.md, every test should live next to the module it exercises.
  • Fixtures — any __fixtures__/ subdirectory. Excluded from discovery; reference them from a test via source: ./__fixtures__/foo.yaml or include: [./__fixtures__/foo.yaml].

Running tests

pnpm run test                      # run everything
pnpm run test if # filter by substring (matches "run-sequence-if.yaml")
pnpm run test --filter=if # same, explicit
pnpm run telo ./modules/run/tests/run-sequence-if.yaml # run one manifest directly

Anatomy of a test manifest

Every test starts with a Telo.Application, declares imports: aliases for each stdlib it uses, and defines the resource named in targets::

kind: Telo.Application
metadata:
name: AddTwoNumbers
version: 1.0.0
imports:
Run: ../../run
JavaScript: ../../javascript
Assert: ../../assert
targets:
- TestAdd
---
kind: Run.Sequence
metadata:
name: TestAdd
steps:
- name: AddNumbers
inputs:
a: 5
b: 3
invoke:
kind: JavaScript.Script
code: |
function main({ a, b }) {
return { sum: a + b }
}
- name: VerifySum
inputs:
sum: "${{ steps.AddNumbers.result.sum }}"
invoke:
kind: Assert.Schema
schema:
type: object
properties:
sum:
type: number
const: 8

Adjust the source: paths to the relative location of each module from your test file. From modules/<name>/tests/ use ../../<other-module>; from the root tests/ use ../modules/<other-module>.

Step shapes

Every step has a name. Beyond that, a step is one of several shapes — an invoke, or a control-flow block (conditional, loop, switch, try, throw). The when: guard composes with any of them.

Invoke

{ name, inputs?, invoke }. inputs is a CEL-templatable map; invoke declares the resource to call. The result is available to later steps as ${{ steps.<name>.result.<field> }}.

Conditional — if/then/else

- name: BranchOnValue
if: "${{ steps.Setup.result.value == 42 }}"
then:
- name: Matched
inputs: { value: "${{ steps.Setup.result.value }}" }
invoke:
kind: Assert.Schema
schema:
type: object
properties:
value: { const: 42 }
else:
- name: NotMatched
...

Loop — while/do

A do-while pattern emerges naturally from sharing a step name between a pre-loop initializer and the loop body:

- name: Counter
inputs: { n: 0 }
invoke:
kind: JavaScript.Script
code: |
function main({ n }) { return { n } }

- name: Increment
while: "${{ steps.Counter.result.n < 3 }}"
do:
- name: Counter # shared name overwrites prior result each iteration
inputs: { n: "${{ steps.Counter.result.n }}" }
invoke:
kind: JavaScript.Script
code: |
function main({ n }) { return { n: n + 1 } }

Switch — switch/cases/default

- name: RouteByRole
switch: "${{ steps.ComputeRole.result.role }}"
cases:
admin:
- name: AdminAction
...
viewer:
- name: ViewerAction
...
default:
- name: Fallback
...

Try/catch/finally

error is bound inside catch: with code, message, step, and data.

- name: Outer
try:
- name: Boom
invoke:
kind: JavaScript.Script
code: |
function main() { throw new Error("caught me") }
catch:
- name: Inspect
inputs:
msg: "${{ error.message }}"
step: "${{ error.step }}"
invoke:
kind: Assert.Schema
schema:
type: object
properties:
msg: { type: string }
step: { type: string }
finally:
- name: Cleanup
...

Throw

throw: raises a structured InvokeError that the nearest enclosing catch: binds as error:

- name: Boom
throw:
code: "UNAUTHORIZED"
message: "bad token"
data: { reason: "expired" }

Guard — when

when: skips a step if the expression is false. Works on plain steps, if:, try:, switch:, and while: blocks alike.

- name: ShouldSkip
when: "${{ false }}"
inputs: { x: 999 }
invoke:
kind: Assert.Schema
schema:
type: object
properties:
x: { const: 0 }

Assertion kinds

All exported by the assert stdlib (an imports: entry Assert: ../../assert from a module test, Assert: ../modules/assert from the root tests/).

KindUse forWhere it goes
Assert.SchemaJSON Schema validation on inputsStep (invoke.kind)
Assert.EqualsDeep equalityStep
Assert.MatchesRegex match on a stringStep
Assert.ContainsSubstring / element / property containmentStep
Assert.EventsAsserts an ordered subsequence of kernel eventsTop-level resource
Assert.ManifestAsserts the analyzer emits specific diagnostic codes for a fixtureTop-level resource

Assert.Events

Watches the kernel event stream and asserts an ordered subsequence:

kind: Assert.Events
metadata: { name: ExpectEvents }
filter:
- type: "*"
expect:
- event: JavaScript.Script.*.Invoked
payload:
outputs: { sum: 8 }
- event: Assert.Schema.*.Invoked
payload:
outputs: true

Assert.Manifest

Asserts the analyzer's diagnostics on a fixture without running it:

kind: Assert.Manifest
metadata: { name: TestExtendsMalformed }
source: ./__fixtures__/extends-malformed.yaml
expect:
errors:
- code: EXTENDS_MALFORMED

Negative-path patterns

Two shapes:

  1. Static-analysis errors — use Assert.Manifest with a fixture under __fixtures__/ and an expected errors[].code.
  2. Runtime errors — wrap the failing step in try/catch and assert against ${{ error.code }}, ${{ error.message }}, ${{ error.step }}, ${{ error.data.* }}. See modules/run/tests/invoke-error.yaml for the canonical example.

See also