Unit Testing
This guide explains the most important Vona testing workflows in the Cabloy monorepo.
Why testing is emphasized
Test-driven development remains a strong default in the Cabloy monorepo.
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:
npm run vona :create:test student -- --module=training-studentExecute tests
From the root repository:
npm run testA typical Vona test flow includes:
- create one shared global
appobject for the test run - clean Redis data
- recreate the database
- execute migration code
- run the test files
- close the shared application after the test run
The runner owns this application lifecycle. Test files import the shared app from vona-mock and must not create or close it themselves:
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 database without running tests
Representative command:
cd vona && npm run db:resetThis is useful when you want to reapply migration logic without running the entire test suite.
Coverage
Representative command:
cd vona && npm run covRunner concurrency
The Vona runner uses Node's built-in test runner with one shared application and process isolation disabled. TEST_CONCURRENCY controls runner scheduling:
trueuses the available CPU count;falseforces serial execution;- a positive integer sets an explicit concurrency limit.
TEST_CONCURRENCY=false npm run test
TEST_CONCURRENCY=4 npm run testCabloy 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:
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:
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.tsseed()hook only for stable baseline data shared across tests or intentionally used by the local-development test-data workflow.
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.
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.
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:
- create a dedicated test-local fixture in an appropriate
mockCtx(...); - give every contender its own
mockCtx(...)and authentication lifecycle; - launch the contenders explicitly and wait for all of them to settle;
- use a fresh
mockCtx(...)to assert both individual outcomes and the combined durable invariant; - 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:
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:
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:
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 catchErrorfrom@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:
- create request data
- sign in if auth is required
- call create action
- call list/query action and verify inclusion
- call update action
- call find-one action and verify the new state
- call delete action
- verify the final deleted/not-found state
- 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:
- should a module test be created or updated?
- does the change need request-context simulation?
- does it affect migration/setup behavior that should be covered through the test flow?
- should controller behavior be verified through
performActionrather than only direct method calls? - 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.