Skip to content

Unit Testing ​

This guide explains the most important Vona testing workflows in the Cabloy monorepo.

Why testing is emphasized ​

Keep testing proportional to the backend change: cover critical business behavior using the existing Vona testing infrastructure, and add focused regression coverage when actual problems are fixed. Choose the smallest meaningful service/model, controller/API, or integration boundary for the changed contract; browser E2E is not the default backend test layer.

Test-driven development and targeted unit or framework tests remain useful when they clarify and directly prove new or changed behavior. Exhaustive upfront mock/fake matrices and bespoke harnesses are not a delivery prerequisite.

Preserve applicable authentication/authorization, tenant-isolation, transaction, concurrency, idempotency, migration, and acceptance checks without turning them into an unrelated mandatory matrix. The context, fixture, and resource-isolation rules below still apply. Verify changes to migration, initialization, or seed behavior through managed isolated tests covering the affected behavior, preferably focused; a meta.version.ts edit alone does not require the full suite. Missing runtime access must be reported, not worked around with unauthorized resets or substitute test machinery.

Vona’s testing story is valuable because it is closely integrated with:

  • app initialization
  • Redis cleanup
  • database recreation
  • migration execution
  • request-context simulation
  • action-level API verification

That means tests can exercise framework behavior in a realistic way.

Unit testing in the backend contract loop ​

A useful backend-contract-loop testing mental model is:

  • migration prepares the structural state
  • controllers expose the route/action surface
  • services and models execute the business and persistence logic
  • tests verify the resulting contract through scoped access and action execution

This is why Vona testing should not be reduced to isolated helper-unit testing only.

Create a test file ​

Example:

bash
npm run vona :create:test student -- --module=training-student

Execute tests ​

From the repository root, prefer a focused run for a bounded module change:

bash
npm run test -- 'src/module/<module-name>/test/**/*.ts'

Resolve the actual test path for suite-owned modules. Positional patterns select test files, not a reduced database-preparation lifecycle. Focused managed tests still exercise fresh migration, initialization, and seeds; assert the affected resource behavior and cover existing-data upgrades when applicable. Expand to the full suite according to actual impact:

bash
npm run test

A typical Vona test flow includes:

  1. create one shared global app object for the test run
  2. clean the run's Redis namespace and run-specific public/runtime paths
  3. recreate the run's disposable test database
  4. execute migration and instance initialization code, then module seed() hooks
  5. run the test files
  6. close the shared application after the test run

In Cabloy Basic and Cabloy Start, root npm run test delegates to vona/'s npm run test; both allocate an isolated test identity, database and Redis namespace, Vona generated/runtime/public paths, and loopback listener. They can run in the same checkout as an ordinary development server without resetting or mutating that server's business data. Even a targeted run performs the test-mode setup. Passing tests do not update the development database; choose its migration or explicitly approved reset path separately using Migration and Changes. The Vona npm run cov script uses the same isolation; raw :bin:test commands do not. Shared build/dependency preparation may still write to shared artifacts, and custom external resources need separate isolation. See Repo Scripts.

The runner owns this application lifecycle. Test files import the shared app from vona-mock and must not create or close it themselves:

typescript
import { app } from 'vona-mock';

This is one of the most important distinctions from ordinary app flow: test execution rebuilds and verifies the framework lifecycle, not only the target function.

Reset development database without running tests ​

From the repository root:

bash
npm run db:reset

This is an explicit, destructive dev-mode command, not test isolation: it recreates matching disposable development databases, reapplies migration and instance initialization, and runs module seed() hooks. It requires database.testDatabase: true in dev configuration and a connection outside the disposable database. Ordinary dev startup does not seed; do not reset if you need to keep its existing business data. See Migration and Changes.

Coverage ​

Representative command:

bash
cd vona && npm run cov

This script runs under the same isolated test environment as Vona's npm run test. It writes vona/coverage/lcov.info and, unless --ci is supplied, vona/coverage/report. These reports are shared checkout-level artifacts rather than per-run test data; do not run coverage jobs simultaneously in one checkout. Use npm run cov -- --ci to skip the viewer in CI.

Runner concurrency ​

The Vona runner uses Node's built-in test runner with one shared application and process isolation disabled. TEST_CONCURRENCY controls runner scheduling:

  • true uses the available CPU count;
  • false forces serial execution;
  • a positive integer sets an explicit concurrency limit.
bash
TEST_CONCURRENCY=false npm run test
TEST_CONCURRENCY=4 npm run test

Cabloy Basic requests concurrency by default with TEST_CONCURRENCY=true. When the active database dialect does not advertise the required concurrency capability, the runner falls back to serial execution. The default SQLite path therefore runs serially.

Runner concurrency schedules ordinary tests; it does not prove a business-level race condition. Tests must not rely on scheduling, timing, or execution order for correctness.

A suite can also make sibling-test scheduling explicit:

typescript
describe('resource.test.ts', { concurrency: false }, () => {
  // sibling tests in this suite are serialized
});

This setting controls test scheduling only. It is not a substitute for explicitly creating competing business operations.

Mock request context ​

One of the most important Vona testing patterns is simulating a request context.

Representative shape:

typescript
await app.bean.executor.mockCtx(async () => {
  // test logic here
});

Treat one mockCtx(...) callback as one simulated request boundary. Keep app.ctx, authentication, current database selection, and context-dependent service, model, or action work inside that callback, and always await it. Do not retain request-context state after the callback returns.

mockCtx(...) isolates request-local context. It does not isolate committed persisted records, app-global state, external caches, or shared durable fixtures. For independent concurrent requests, create one mockCtx(...) for each branch; parallel work started inside a single mockCtx(...) shares that request context.

Locale-sensitive variants and additional request-context helpers are also available.

Persisted fixture lifecycle ​

Classify persisted test data before creating it:

  • use a test-local fixture for a single test or scenario;
  • use an owning module's meta.version.ts seed() hook only for stable baseline data shared across isolated test runs or intentionally prepared by an explicit dev-mode database reset.

A test owns every persisted resource it creates. Keep the returned entity or exact ID and delete owned records from finally, including when an assertion or action fails. Delete dependents before their owners and use the same active tenant/instance context that created them. Do not discover cleanup targets through broad table queries, time-based prefixes, or business conditions when the test already knows the exact identity.

typescript
let parentId: number | undefined;
let childId: number | undefined;
try {
  parentId = await createParent();
  childId = await createChild(parentId);

  // exercise and assert the behavior under test
} finally {
  if (childId) await deleteChild(childId);
  if (parentId) await deleteParent(parentId);
}

Treat shared seed() records as read-only. If a scenario needs to change a record, create a separate test-local fixture instead and clean it up. See Migration and Changes for the durable seed lifecycle.

Application shutdown is runner-owned; authentication and fixture cleanup are test-owned. Do not call app.close() from a test. Keep signout() and exact-identity deletion in finally inside an appropriate request context.

Testing concurrent behavior explicitly ​

A concurrency test creates competing business operations inside one test; it does not depend on the runner to happen to schedule tests at the same time.

typescript
const attempt = async () => {
  return await app.bean.executor.mockCtx(async () => {
    await app.bean.passport.signinMock();
    try {
      return await reserve(resourceId);
    } finally {
      await app.bean.passport.signout();
    }
  });
};

const results = await Promise.allSettled([attempt(), attempt()]);

Use this sequence:

  1. create a dedicated test-local fixture in an appropriate mockCtx(...);
  2. give every contender its own mockCtx(...) and authentication lifecycle;
  3. launch the contenders explicitly and wait for all of them to settle;
  4. use a fresh mockCtx(...) to assert both individual outcomes and the combined durable invariant;
  5. clean up exact owned fixtures only after every branch has settled, in the appropriate tenant/instance context.

For example, a one-winner reservation test should verify not only the fulfilled and rejected operations but also the final balance, surviving reservation, and audit records. If the invariant depends on row locks, transaction isolation, or another database-specific capability, skip the test on unsupported dialects rather than weakening its assertions. See stockReservation.test.ts for this pattern.

Working with module scope in tests ​

Representative pattern:

typescript
const scopeStudent = app.scope('training-student');

This lets tests exercise:

  • services
  • models
  • entities
  • controller actions

through the same scoped abstractions used in application code.

Testing controllers through actions ​

Representative pattern:

typescript
await app.bean.executor.performAction('get', '/training/student');

This is especially useful because it exercises the controller path more realistically than only unit-testing isolated helper functions.

A practical rule is:

  • use direct service/model assertions when the test target is truly internal behavior
  • use performAction(...) when the goal is to verify the backend API contract as a controller-facing workflow

A representative contract-verification pattern is:

typescript
const updateRes = await app.bean.executor.performAction('patch', '/test/rest/product/:id', {
  params: { id: productId },
  body: dataUpdate,
});
assert.equal(updateRes, null);

const deleteRes = await app.bean.executor.performAction('delete', '/test/rest/product/:id', {
  params: { id: productId },
});
assert.equal(deleteRes, null);

For standard resource command mutations, these assertions verify the controller-facing no-payload runtime contract. Follow them with a query/read-back assertion to verify the persisted update or deletion. When the API is generated for frontend consumers, add a focused OpenAPI structural assertion that its wrapped response schema declares data: null; this verifies SDK-contract fidelity separately from responder behavior. The same test then exercises params, body, route wiring, validation, response behavior, and persistence effects together.

Authentication simulation ​

Tests can also simulate signin and signout behavior.

Representative patterns include:

  • signinMock()
  • signinMock('admin')
  • signout()

This is important for testing permission-sensitive flows.

A practical CRUD-style pattern is:

  • sign in
  • call create via performAction(..., { body })
  • call list/query and verify inclusion
  • call update via params + body
  • call find-one and verify new state
  • call delete and verify final state
  • sign out

This keeps auth-sensitive CRUD verification close to the real controller contract path.

Assertion and error-handling helpers ​

Two practical testing helpers are:

  • Node’s built-in assert
  • catchError from @cabloy/utils

These help keep tests explicit while still fitting the framework’s async execution style.

End-to-end CRUD test story ​

A realistic CRUD test usually verifies a whole backend thread, not only one method call.

A practical sequence is:

  1. create request data
  2. sign in if auth is required
  3. call create action
  4. call list/query action and verify inclusion
  5. call update action
  6. call find-one action and verify the new state
  7. call delete action
  8. verify the final deleted/not-found state
  9. sign out

This is the most framework-native verification path because it tests route, validation, DTO, service, model, and migration assumptions together.

Relationship to migration and CRUD generation ​

Read this guide together with:

A practical split is:

  • CRUD generation creates the initial backend thread
  • migration keeps that thread structurally valid over time
  • tests verify the resulting contract through realistic execution

Implementation checks for backend testing changes ​

When adding or changing backend behavior, do not stop at code generation.

It should also ask:

  1. should a module test be created or updated?
  2. does the change need request-context simulation?
  3. does it affect migration/setup behavior that should be covered through the test flow?
  4. should controller behavior be verified through performAction rather than only direct method calls?
  5. does the change affect the end-to-end CRUD thread rather than only one isolated function?

That leads to much stronger and more framework-native verification.

Released under the MIT License.