Skip to main content

plugin-testing

Tests NeMo Platform plugin surfaces without a running platform. Use when writing tests for entity CRUD routes, mocking the entity client, testing NemoJob run() methods, setting up config overrides, or verifying FastAPI route error handling. Trigger keywords: test, pytest, mock entity client, TestClient, dependency_overrides, AsyncMock, test job, test config, test service, test controller.

Source facts

Repository
NVIDIA-NeMo/nemo-platform
Last source activity
August 14, 2026 at 17:32
Detected SKILL.md language
English
Stars
79
Forks
24

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
plugin-testing
description
Tests NeMo Platform plugin surfaces without a running platform. Use when writing tests for entity CRUD routes, mocking the entity client, testing NemoJob run() methods, setting up config overrides, or verifying FastAPI route error handling. Trigger keywords: test, pytest, mock entity client, TestClient, dependency_overrides, AsyncMock, test job, test config, test service, test controller.
# Testing NeMo Platform Plugins All plugin surfaces can be tested without a running platform. The platform injects real implementations at runtime; tests replace them with mocks via `dependency_overrides`. ## Service Route Tests ### Setup: _make_app helper ```python from fastapi import FastAPI from fastapi.testclient import TestClient from unittest.mock import AsyncMock from nemo_platform_plugin.entity_client import get_entity_client from nemo_my_plugin.service import MyService def _make_app(mock_client: AsyncMock) -> FastAPI: service = MyService() app = FastAPI() for spec in service.get_routers(): app.include_router(spec.router, prefix=spec.prefix) app.dependency_overrides[get_entity_client] = lambda: mock_client return app ``` > **Critical:** Override `get_entity_client` from `nemo_platform_plugin.entity_client`, not a local stub. The routes use `Depends(get_entity_client)` — overriding a different function object has no effect and tests will raise `NotImplementedError`. ### Entity helper ```python from datetime import datetime, timezone from unittest.mock import MagicMock from nemo_platform_plugin.entity_client import NemoPaginationInfo NOW = datetime.now(timezone.utc) def _make_widget(name: str = "w1", workspace: str = "default") -> Widget: """Build a fake persisted entity with store-populated private attrs.""" w = Widget(name=name, workspace=workspace, colour="red") w._id = f"id-{name}" w._created_at = NOW return w def _make_list_response(items: list) -> MagicMock: resp = MagicMock() resp.data = items resp.pagination = NemoPaginationInfo( page=1, page_size=20, current_page_size=len(items), total_pages=1, total_results=len(items), ) return resp ``` ### Route tests (all 5 CRUD operations) ```python from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError def test_create_201(): mock = AsyncMock() mock.create.return_value = _make_widget("w1") client = TestClient(_make_app(mock)) resp = client.post("/v2/workspaces/default/widgets", json={"name": "w1", "colour": "red"}) assert resp.status_code == 201 assert resp.json()["name"] == "w1" def test_create_409_conflict(): mock = AsyncMock() mock.create.side_effect = NemoEntityConflictError("already exists") client = TestClient(_make_app(mock)) resp = client.post("/v2/workspaces/default/widgets", json={"name": "dup", "colour": "red"}) assert resp.status_code == 409 def test_list_200(): mock = AsyncMock() mock.list.return_value = _make_list_response([_make_widget("w1"), _make_widget("w2")]) client = TestClient(_make_app(mock)) resp = client.get("/v2/workspaces/default/widgets") assert resp.status_code == 200 assert len(resp.json()["data"]) == 2 def test_get_200(): mock = AsyncMock() mock.get.return_value = _make_widget("w1") client = TestClient(_make_app(mock)) resp = client.get("/v2/workspaces/default/widgets/w1") assert resp.status_code == 200 def test_get_404(): mock = AsyncMock() mock.get.side_effect = NemoEntityNotFoundError("not found") client = TestClient(_make_app(mock)) resp = client.get("/v2/workspaces/default/widgets/missing") assert resp.status_code == 404 def test_patch_409_version_conflict(): mock = AsyncMock() mock.get.return_value = _make_widget("w1") mock.update.side_effect = NemoEntityConflictError("version mismatch") client = TestClient(_make_app(mock)) resp = client.patch("/v2/workspaces/default/widgets/w1", json={"colour": "blue"}) assert resp.status_code == 409 def test_delete_204(): mock = AsyncMock() client = TestClient(_make_app(mock)) resp = client.delete("/v2/workspaces/default/widgets/w1") assert resp.status_code == 204 def test_delete_404(): mock = AsyncMock() mock.delete.side_effect = NemoEntityNotFoundError("not found") client = TestClient(_make_app(mock)) resp = client.delete("/v2/workspaces/default/widgets/missing") assert resp.status_code == 404 ``` ## Job Tests Jobs have no platform dependency — instantiate and call `run()` directly: ```python from nemo_my_plugin.jobs.say_hello import SayHelloJob def test_say_hello(): result = SayHelloJob().run({"name": "Alice"}) assert result == {"result": "Hello, Alice!"} def test_say_hello_default(): result = SayHelloJob().run({}) assert result == {"result": "Hello, world!"} ``` > **Note:** `run()` is synchronous. Do NOT use `pytest-asyncio` for job tests — just call `job.run(config)` directly. ## Config Tests ```python import pytest from nemo_platform_plugin.config import set_nemo_config_override, clear_nemo_config_overrides from nemo_my_plugin.config import MyPluginConfig # Recommended: autouse fixture clears all overrides after every test @pytest.fixture(autouse=True) def reset_config(): yield clear_nemo_config_overrides() def test_debug_mode(): set_nemo_config_override(MyPluginConfig(debug=True)) config = MyPluginConfig.get() assert config.debug is True def test_default_config(): config = MyPluginConfig.get() assert config.debug is False ``` > **Always use the autouse fixture.** Config is cached per process — a test override left in place will bleed into subsequent tests. ## Discovery Cache (entry-point tests) Tests that mock entry-points must clear the discovery cache. This is mandatory — stale cache causes test interference: ```python import pytest from nemo_platform_plugin.discovery import discover, discover_entry_points, discover_manifests @pytest.fixture(autouse=True) def clear_discovery_cache(): yield discover.cache_clear() discover_entry_points.cache_clear() discover_manifests.cache_clear() ``` ## Controller Tests Controllers require no platform for unit tests — mock the entity client: ```python import pytest from unittest.mock import AsyncMock, patch from nemo_my_plugin.controller import MyController @pytest.mark.asyncio async def test_controller_list_objects_returns_empty_on_error(): controller = MyController() controller._entities = AsyncMock() controller._entities.list.side_effect = Exception("connection refused") result = await controller.list_objects() assert result == [] ``` ## Gotchas - **Override `get_entity_client` from `nemo_platform_plugin.entity_client`**: Not a local stub. The route uses `Depends(get_entity_client)` — overriding the wrong object silently does nothing. - **`entity.id` is `""` before persistence**: Use `_make_entity()` helpers that set `_id` directly; otherwise response validation fails. - **`asyncio.run()` in jobs**: `run()` is sync. Tests that call `job.run()` don't need `@pytest.mark.asyncio`. - **Config cache bleeds between tests**: Always use the `clear_nemo_config_overrides()` autouse fixture. - **Discovery cache bleeds between tests**: Always clear `discover`, `discover_entry_points`, and `discover_manifests` caches in tests that mock entry-points. - **`MagicMock` for list response**: Use `MagicMock()` with `.data` and `.pagination` set manually; do not import `ListResponse` from internal `nmp.common` paths.
View on GitHub