| name | kelvin-sdk-testing |
| description | Use whenever a Kelvin SmartApp (`type: app`) is created, modified, or reviewed to design and write unit tests with the `kelvin.testing` framework (`KelvinAppTest`, `ManifestBuilder`, data sources). Every SmartApp produced or changed by an agent MUST be accompanied by tests written with this framework; load this skill alongside `kelvin-sdk-app`. Do NOT use for importer applications (`type: importer`). |
Kelvin SDK — Testing Framework
Write deterministic, fast, programmatic tests for Kelvin SmartApps using the kelvin.testing framework. The framework replaces real I/O with in-memory streams and wall-clock time with a VirtualClock, so timers, windows, and schedules fire instantly under pytest.
When to Apply
Apply this skill every time a SmartApp is generated, scaffolded, or changed:
- New app generated → create
tests/test_main.py and pytest.ini alongside main.py.
- New handler added (
@app.stream, @app.timer, @app.schedule, @app.task, @app.window, callbacks) → add a test class covering that handler.
- Output type added (data, recommendation, control change, custom action, data tag, data quality) → add an assertion that the right
*Msg is published.
- Logic changed (threshold, configuration, parameter) → add or update a test that exercises the new branch.
Never leave an app without tests. If the user does not explicitly request tests, still produce them — say so briefly in the final summary.
Execution Workflow
- Read
main.py and app.yaml to identify handlers, inputs, outputs, assets, parameters, and configuration.
- Pick one test pattern per handler from the decision table below.
- Build a manifest with
ManifestBuilder that mirrors app.yaml (or load it via ManifestBuilder.from_app_yaml(...)).
- Inside
async with KelvinAppTest(app, manifest=...) as harness: publish inputs, advance virtual time with run_until_idle, then assert on harness.outputs.
- Run
pytest from the app directory to confirm tests pass before finishing.
First-File Decision Rules
Pick exactly one first reference, then expand only if needed:
- Writing the test file, test class layout, pytest config, async fixtures, asserting on outputs by message type, time control, capturing logs: references/test-patterns.md
- Building a
RuntimeManifest (inputs, outputs, control changes, custom actions, assets, parameters, configuration, loading from app.yaml): references/manifest-builder.md
- Driving inputs from CSV files, synthetic waveforms, random generators, or a DataFrame: references/data-sources.md
For SmartApp semantics (decorators, message types, KRN, app.yaml), load the matching reference from the kelvin-sdk-app skill.
Required File Layout
For every SmartApp the agent produces, also create:
<app-root>/
├── main.py
├── app.yaml
├── pytest.ini # see pytest.ini block below
└── tests/
├── __init__.py # empty file
└── test_main.py # one TestClass per handler / behavior
pytest.ini (verbatim):
[pytest]
pythonpath = .
This makes from main import app resolvable when pytest runs from the app directory.
Canonical Test Skeleton
Use this as the starting point for every new tests/test_main.py. Adjust manifest, handler names, and assertions to the app under test.
"""Tests for the <app name> SmartApp."""
from __future__ import annotations
import pytest
from main import app
from kelvin.application import KelvinApp
from kelvin.krn import KRNAssetDataStream
from kelvin.message import Number
from kelvin.testing import KelvinAppTest, ManifestBuilder
def _build_manifest() -> ManifestBuilder:
return (
ManifestBuilder()
.add_input("temperature", "number")
.add_output("alert", "boolean")
.add_asset("pump-001", parameters={"limit": 50})
)
def _task_names(a: KelvinApp) -> set[str]:
"""Return short handler names (last segment of dotted keys)."""
return {k.rsplit(".", 1)[-1] for k in a.tasks}
class TestRegistration:
"""Sanity-check that main.py registers the expected handlers."""
def test_handlers_registered(self) -> None:
assert _task_names(app) == {"process_temperature"}
class TestProcessTemperature:
@pytest.mark.asyncio
async def test_emits_alert_above_threshold(self) -> None:
harness = KelvinAppTest(app, manifest=_build_manifest().build())
async with harness:
await harness.publish(
Number(resource=KRNAssetDataStream("pump-001", "temperature"), payload=75.0)
)
await harness.run_until_idle()
alerts = [o for o in harness.outputs if o.resource.data_stream == "alert"]
assert len(alerts) == 1
assert alerts[0].payload is True
Handler → Test Pattern Decision Table
Handler in main.py | Manifest must include | Drive the test with | Assert on |
|---|
@app.stream(...) / on_asset_input | input datastream(s) + asset(s) | harness.publish(Number(...)) then run_until_idle() | filtered harness.outputs by data_stream or isinstance |
@app.timer(interval=N) | output datastream(s) only | harness.run_until_idle(timeout=N+1) | output count / payload values |
@app.schedule(...) (cron) | output datastream(s) only | harness.advance_time(seconds=...) or run_until_idle(...) | output count at expected boundaries |
@app.task (one-shot or virtual-clock periodic) | whatever the task publishes | run_until_idle() or advance_time(...) | outputs / logs via capsys |
@app.task with while True + asyncio.sleep(rate) | whatever the task publishes; expose rate as an asset parameter or app configuration | set rate=0 in the manifest, then run_until_idle(timeout=...) to bound real time | outputs / logs via capsys |
@app.window(...) (tumbling/hopping/rolling) | input(s) + asset(s); set add_input(..., "number") etc. | publish N messages with timestamps, then run_until_idle(timeout > window_size) | capsys or output messages from the window callback |
on_control_change | add_control_change_input(...) | harness.publish(ControlChange(...)) | ControlChangeMsg ack or downstream outputs |
on_custom_action | add_custom_action_input(...) (+ outputs if app replies) | harness.publish(CustomAction(...)) | CustomActionResultMsg / outputs |
| Publishes recommendations | output datastream(s); add parameters={"kelvin-closed-loop": False} if applicable | trigger handler | RecommendationMsg (and embedded actions) |
| Publishes data tags | input(s) + asset(s) (data tags reference inputs as context) | trigger handler | DataTagMsg fields |
| Publishes data quality | output datastream(s) + asset(s) | trigger handler | msg.resource is KRNAssetDataQuality / KRNAssetDataStreamDataQuality |
Implementation Defaults
- Mark every async test with
@pytest.mark.asyncio. Add asyncio_mode = auto to pytest.ini only when the user opts in; otherwise mark explicitly.
- Always use
async with KelvinAppTest(app, manifest=...) as harness: so connect/disconnect is clean and sources stop.
- After publishing, ALWAYS
await harness.run_until_idle(...) before reading harness.outputs.
- Choose
timeout= to slightly exceed the slowest virtual delay involved (timer interval, window size, schedule gap). Default 5.0 is enough only when no timers/windows are involved.
- Build manifests with the same datastream names and asset names declared in
app.yaml. Mismatches cause silent drops.
- Use
parameters={...} on add_asset(...) to exercise per-asset behavior; use set_configuration({...}) for app-level configuration.
- Filter outputs with
isinstance(o, Number | Boolean | String | RecommendationMsg | ControlChangeMsg | CustomActionMsg | DataTagMsg) (primitives have no separate *Msg class) rather than positional indexing. See the message-class cheat sheet in references/test-patterns.md.
- Capture stdout/log decisions with the pytest
capsys fixture when the handler only logs.
- Reset module-level counters or singletons that the app uses between tests. Define a small helper in the test file and call it at the top of each test, e.g.
def _reset_counter() -> None: import main; main.counter = 0.
- Keep test code deterministic: never call
asyncio.sleep, time.sleep, datetime.now(), or datetime.utcnow() from inside your test — use await harness.run_until_idle(timeout=...) / await harness.advance_time(...) and harness.clock.now(). If the app under test uses asyncio.sleep(N) internally, that sleep is real time (see "What is NOT virtualised" below) — drive N through an asset parameter or app configuration and set it to 0 in tests.
Validation Checklist
Before finishing, confirm all of the following:
Framework Guarantees
When asserting, rely on these guarantees and don't re-test them:
harness.outputs returns every message the app published since connect, in publish order.
harness.inputs returns every message injected (via publish or sources), in injection order.
harness.clock is a VirtualClock; harness.clock.now() is the current virtual datetime.
- Sources added with
harness.add_source(source) start on connect and stop on disconnect.
run_until_idle(timeout=T) drains queues, advances virtual time up to T seconds, and waits for handler tasks to finish (subject to a small real-time guard for thread-based sync handlers).
harness.outputs accumulates across multiple run_until_idle calls within the same async with block; reading it is safe to repeat.
- Background
@app.task coroutines are cancelled on harness __aexit__ — an infinite while True task does not hang the test.
on_asset_change and on_app_configuration never fire on the initial manifest — the harness's initial-manifest delivery establishes the baseline silently. To fire them you must publish a second manifest via harness.publish(builder.build()). The two callbacks differ in what counts as a trigger after the initial manifest: on_asset_change fires on any subsequent manifest (no diff check in the SDK), but on_app_configuration fires only when the configuration value actually changed (the SDK does if configuration != self.app_configuration: before invoking it). For on_app_configuration, start the initial manifest with an empty or different configuration, then re-publish with the desired configuration. See references/test-patterns.md → Triggering callbacks.
ControlChange arriving at the app is a KMessageTypeData primitive on an input_cc / input_cc_output datastream — not a ControlChangeMsg. Publish a Number (or other primitive) on the CC-declared datastream, not the ControlChange builder.
ControlAck(...) produces ControlChangeAck (NOT ControlChangeMsg). Filter outputs with the correct class.
RecommendationMsg.payload.actions.control_changes / .custom_actions hold what the builder takes as top-level control_changes= / actions=. auto_accepted=True becomes payload.state == "auto_accepted". See the builder→payload table in references/test-patterns.md.
CustomActionMsg: the action's user-defined type lives on msg.type.type, not msg.payload.
@app.schedule requires an initial run_until_idle() before advance_time(...) so the scheduler task can start.
@app.stream() populates app._filters lazily on the first message. For registration checks, use app.tasks (the stream is registered there).
capfd (fd-level capture), not capsys, captures print(..., flush=True) from background tasks (@app.task, @app.schedule, @app.stream()).
What is NOT virtualised
The harness replaces stream I/O and (for timer/window/schedule primitives) wall-clock time. Everything else runs for real. Be explicit about these in tests:
-
asyncio.sleep(N) is real time. Only timers, windows, and schedules advance under VirtualClock. If main.py does await asyncio.sleep(rate) inside a task, the test waits real seconds. Drive rate through a parameter and set it to 0 in tests.
-
File I/O is real. If main.py opens dataset.csv, config.json, etc. via Path(__file__).parent / ..., the test runs against the actual file on disk. To inject fixtures without touching the shipped file, monkeypatch the path:
def test_with_fixture(monkeypatch, tmp_path):
import main
fixture = tmp_path / "dataset.csv"
fixture.write_text("number,1,2,3\n")
monkeypatch.setattr(main, "DATASET_PATH", fixture)
...
-
Network/HTTP/DB calls are real. Mock them with unittest.mock or pytest-mock as you would in any other Python test.