Testing
Oidis supports three types of tests, each serving a different purpose. Tests are run via the Oidis CLI and integrated into the CI pipeline.
Test Directory Structure
test/
├── unit/typescript/Io/Oidis/[LibraryName]/
│ ├── UnitTestRunner.ts # Project-specific test runner
│ └── [ModulePath]/[ComponentTest].ts # Unit test files
├── selenium/Io/Oidis/[LibraryName]/RuntimeTests/
│ ├── reference.d.ts # Namespace-style type reference
│ └── ExecuteRuntimeTest.ts # Selenium WebDriver tests
└── resource/
└── [test configs, data, graphics]
Three Test Types
1. Unit Tests (test/unit/typescript/)
Standard unit tests using the Oidis test framework. Each project extends a BaseUnitTestRunner and uses the @Test() decorator.
import { Test } from "@io-oidis-hub/.../Testing/Decorators.js";
import { BaseUnitTestRunner } from "@io-oidis-hub/.../Testing/BaseUnitTestRunner.js";
export class OrderValidatorTest extends BaseUnitTestRunner {
@Test()
public async ValidPercentage() : Promise<void> {
const validator = new RangeValidator(0, 100);
const result = await validator.validate(50);
this.assert(result === true, "50% should be valid");
}
@Test()
public async InvalidPercentage() : Promise<void> {
const validator = new RangeValidator(0, 100);
const result = await validator.validate(150);
this.assert(result === false, "150% should be invalid");
}
@Test()
public async NullHandling() : Promise<void> {
const result = ObjectValidator.IsEmptyOrNull(null);
this.assert(result === true, "null should be empty");
}
}
Key features:
@Test()decorator marks test methods- Each project has a
UnitTestRunner.tsthat extends the parent's runner UnitTestLoaderskips database initialization for fast execution- Test files mirror the source structure (
test/unit/typescript/→source/typescript/)
FE-Only Unit Tests (Separate GUI Repositories)
GUI repositories that follow the Separate GUI Repository pattern (io-oidis-hub-gui, io-oidis-home-gui, io-oidis-studio-gui) also contain unit tests in test/unit/typescript/. These tests are specifically for pure frontend logic — no server, no database, no LCP calls.
export class PasswordValidatorTest extends BaseUnitTestRunner {
@Test()
public async MinLength() : Promise<void> {
const result = PasswordValidator.validate("abc");
this.assert(result.valid === false, "Short password should be invalid");
}
@Test()
public async StrongPassword() : Promise<void> {
const result = PasswordValidator.validate("Str0ng!Pass");
this.assert(result.valid === true, "Strong password should be valid");
}
}
Key characteristics:
- Tests run in the DevOps CI pipeline of the standalone GUI repository — not in
dependencies/context - No database initialization, no HTTP server — pure in-memory logic
- Typical subjects: input validators, formatters, state transformations, utility functions
- The
UnitTestRunner.tsin the GUI repo extends the parent hub-gui runner
When developing inside the backend's dependencies/io-oidis-hub-gui/ folder, pre-commit hooks and test runners do not execute. Unit tests are validated only when committing from the standalone GUI clone. See Separate GUI Repository for the full workflow.
2. Selenium Tests (test/selenium/)
Browser automation tests using Selenium WebDriver. These test the full application in a browser environment.
export class ExecuteRuntimeTest extends SeleniumTestRunner {
public async TestLoginFlow() : Promise<void> {
await this.navigateTo("/login");
await this.fillInput("#email", "test@example.com");
await this.fillInput("#password", "testpass");
await this.clickButton("#loginBtn");
await this.waitForUrl("/main");
this.assert(await this.isVisible(".DashboardPanel"), "Dashboard should be visible");
}
}
Key features:
- Namespace-style with
reference.d.tsfor type declarations - Full browser interaction (click, type, navigate, wait)
- Tests run against a live application instance
3. Runtime Tests (source/typescript/.../RuntimeTests/)
Runtime tests live inside the source directory (not in test/) and compile into the build output. They're runnable in development mode through the browser.
/* dev:start */
import { Test } from "@io-oidis-hub/.../Testing/Decorators.js";
export class ConnectorTest extends BaseTestRunner {
@Test()
public async ListProducts() : Promise<void> {
const client = ProductConnector.getClient();
const result = await client.ListProducts({ offset: 0, limit: 10 });
this.assert(result.items.length > 0, "Should return products");
}
}
/* dev:end */
Key features:
- Wrapped in
/* dev:start *///* dev:end */markers — stripped from production - Test panel + viewer pairs:
*TestPanel.ts+*TestPanelViewer.ts - Accessible via
@ResolverwithResolverScope.DEV - Test against the live application (including database and LCP)
Specialized Test Runners
| Runner | Purpose |
|---|---|
BaseUnitTestRunner | Standard unit tests |
MongoTestRunner | Tests with in-memory MongoDB and GraphQL assertions |
RESTUnitTestRunner | REST API endpoint tests |
SeleniumTestRunner | Browser automation tests |
Database Tests with MongoTestRunner
The hub MongoTestRunner (io-oidis-hub test/unit/typescript/Io/Oidis/Hub/MongoTestRunner.ts) runs model and
GraphQL tests against a throw-away in-memory MongoDB — a unit test never connects to a real database, so no
connection configuration or local MongoDB instance is involved:
| Hook | What it does |
|---|---|
before() (once per suite) | Starts MongoMemoryServer from mongodb-memory-server (binaries cached under dependencies/nodejs/build/), opens the hub MongoDriver against its URI and registers the GraphQL models |
tearDown() (after every test) | Drops the whole database, so each test starts empty |
after() (once per suite) | Closes the driver and stops the memory server |
Models saved in a test go through the regular BaseModel / MongoContext code path. this.context is a
MongoDirectContext for raw fixtures and assertions that bypass the models (Insert, Update, getAll, getById,
DropCollection, DropDatabase); assertGraphQL() and assertGraphQLErrorMessage() check GraphQL results.
import { MongoTestRunner } from "@io-oidis-hub/test/unit/Io/Oidis/Hub/MongoTestRunner.js";
import { Test } from "@io-oidis-commons/test/unit/Io/Oidis/Commons/BaseUnitTestRunner.js";
import { assert } from "@io-oidis-commons/test/unit/Io/Oidis/Commons/UnitTestEnvironment.js";
import { Product } from "../../../../../../source/typescript/Io/Oidis/YourProject/Models/Product.js";
export class ProductTest extends MongoTestRunner {
@Test()
public async SaveStoresName() : Promise<void> {
const product : Product = new Product();
product.Name = "Energy Monitor";
await product.Save();
const stored : any = await this.context.getById(Product.getCollection(), product.Id);
assert.equal(stored.name, "Energy Monitor");
}
}
A subclass that overrides before(), after() or tearDown() must call the super implementation, otherwise the memory
server is not started, stopped or reset.
Test Coverage by Project Type
| Project Type | Test Scope |
|---|---|
| Framework libraries (commons, gui, services) | Extensive unit tests |
| Hub (io-oidis-hub, hub-gui) | Specialized runners, moderate coverage |
| Solution projects | Minimal — UnitTestRunner + DummyTest to satisfy the Builder |
Solution projects focus on runtime tests and manual QA rather than unit test coverage. The framework libraries are the primary target for automated testing.
Running Tests
# Run only unit tests (fast, no DB or browser required)
oidis test unit
# Run all tests
oidis test
# Run with coverage
oidis test coverage
# Run linting only (also validates copyright headers)
oidis.cmd project-cleanup test:lint
# CI pipeline runs all
oidis test coverage --solution=shared
Test File Conventions
- Test files end with
Test.ts:OrderValidatorTest.ts - Test classes end with
Test:OrderValidatorTest - Test methods are marked with
@Test()decorator — the method name describes the scenario in PascalCase without aTestprefix:ValidPercentage(),NullHandling() - Relaxed linting rules in test files: unused vars off, empty functions allowed, prefer-const off
- Framework imports come first, separated from the rest by an empty line. Put the runner the test class extends —
UnitTestRunner, or a project runner such as aMongoTestRunnersubclass — (and the commonsTestimport) at the very top of the file, then a single blank line, then all other imports. The blank line stops the unit build's automatic import sorter from hoisting asource/or model import above the framework; when it does, loading the test module evaluates the source graph — and any CORE circular initialization such asConfigEntry↔FileEntry— before the runner, so the whole module fails to load withCannot access '<Symbol>' before initializationin the sandbox instead of running its tests
test prefix conventionOlder code may use a convention-based discovery where methods containing test in their name (case-insensitive) are auto-discovered without the @Test() decorator. The test runner strips the lowercase test prefix from the output name. New tests should always use the @Test() decorator — the method name then appears in the output as-is, so do not add a Test prefix.
Unit Test Runtime Gotchas
The unit runner executes test modules inside a vm realm that emulates a browser window (window,
document, localStorage, a Firefox user agent) on top of Node. Four consequences keep surprising people:
processis a shim.process.on("unhandledRejection", …)works, butprocess.off/removeListenerdo not exist. When a test needs the real Node process (listening for unhandled rejections, readingprocess.version), take it fromrequire("process"), not from the global.- Arrays cross a realm boundary. An array literal created in a test is an instance of the test realm's
Array; a library loaded in the main realm that checksvalue instanceof Arrayseesfalse(Array.isArrayis fine). exceljs'sworksheet.addRow([...])is the known victim — it silently drops every row passed as an array and writes a workbook with only the cells you set one by one. Build fixtures cell by cell (sheet.getRow(n).getCell(i).value = …) or through APIs that take objects. clearTimeoutdoes not cancelsetTimeout. The globalsetTimeoutis the runner's Node override and returns a NodeTimeout, but the globalclearTimeoutis the emulated window's, which expects a numeric handle and silently ignores the object. A cleared timer therefore still fires inside tests (production is unaffected). Make timer callbacks check that their state is still current, e.g.if (this.pending === pending) { … }, instead of relying on cancellation.before()/after()run once per suite,setUp()/tearDown()once per test, and the tests of a class run one after another. Fixtures that live in the filesystem are therefore shared across the suite unlesssetUpcreates them — a per-test temp file needs a unique name per test, not per process.
DummyTest Pattern
Solution projects that don't have meaningful unit tests still need a test runner that passes:
export class UnitTestRunner extends HubUnitTestRunner {
// Inherits parent test runner
}
export class DummyTest extends BaseUnitTestRunner {
@Test()
public async FrameworkLoads() : Promise<void> {
this.assert(true, "Framework loaded successfully");
}
}
This satisfies the Builder's requirement that oidis test completes successfully, even when there are no real unit tests to run.
E2E Testing with Playwright
For solution projects that need richer browser-level E2E coverage beyond Selenium, Oidis supports Playwright-based E2E testing — isolated from the Builder toolchain with its own package.json and tsconfig.json.
See also: E2E Testing with Playwright | Development Markers | Docker & CI | ForceCompile