@reldens/storage Test Architecture

How the @reldens/storage test suite connects, creates tables and generates entities once per driver, how the optional drivers are enabled, and the pitfalls this pattern avoids.

Critical Patterns

Dynamic Models Instead of Generated Models

Generated models in .test-entities/ use const { ObjectionJsRawModel } = require('@reldens/storage'), which loads the package index.js. The Prisma client used by the tests does not exist until the tests generate it, so nothing may depend on it at module load time. The package index does not require any @prisma/* package: Prisma is injected through the prismaModules object.

Solution: create the models dynamically in memory, without requiring the package:

// WRONG - requires @reldens/storage and files generated at runtime
const { TestCategoriesModel } = require('.test-entities/...');

// CORRECT - create models dynamically without package dependency
const { Model } = require('objection');
class DynamicModel extends Model {
    static get tableName(){ return tableName; }
}

Prisma Client Loading Pattern

The tests cannot use require('@prisma/client') at the top level, because the client does not exist until the tests generate it.

Solution: generate the client first, then build the prismaModules object from the generated path and the installed adapter (TestHelpers.loadPrismaModules()):

// 1. Generate Prisma client (subprocess)
await this.runPrismaSubprocess(process.cwd(), config);

// 2. Load the generated client module and the adapter (NOT '@prisma/client')
let prismaModule = require(FileHandler.joinPaths(projectRoot, 'prisma', 'client'));
let adapterModule = require('@prisma/adapter-mariadb');
let client = new prismaModule.PrismaClient({adapter: new adapterModule.PrismaMariaDb(adapterConfig)});

// 3. Pass the object to DataServer
serverConfig.prismaModules = {
    PrismaClient: prismaModule.PrismaClient,
    Prisma: prismaModule.Prisma,
    PrismaAdapter: adapterModule.PrismaMariaDb,
    client
};
let dataServer = new PrismaDataServer(serverConfig);

Key points:

  • Always load the Prisma client from an explicit path, never from the default @prisma/client.
  • Generate the client via subprocess before connecting PrismaDataServer.
  • Pass the prismaModules object to the data server: PrismaDataServer.connect() validates it with PrismaModulesValidator.

The full install, enable and run instructions for the Prisma driver are in @reldens/storage Prisma Setup.

Optional Drivers in the Test Suite

Only knex and mysql2 ship with the package, so the knex driver runs on every npm run test. Every other driver is opt in, each behind its own flag and install:

  • objection-js: RELDENS_TEST_OBJECTION_ENABLED=1 with npm install --no-save objection@3.1.5
  • mikro-orm: RELDENS_TEST_MIKRO_ORM_ENABLED=1 with npm install --no-save @mikro-orm/core@7.2.0 @mikro-orm/mysql@7.2.0
  • kysely: RELDENS_TEST_KYSELY_ENABLED=1 with npm install --no-save kysely
  • drizzle: RELDENS_TEST_DRIZZLE_ENABLED=1 with npm install --no-save drizzle-orm
  • prisma: RELDENS_TEST_PRISMA_ENABLED=1 with npm install --no-save prisma@7.9.1 @prisma/client@7.9.1 @prisma/adapter-mariadb@7.9.1

To install every optional driver and run all six drivers:

npm install --no-save objection@3.1.5 @mikro-orm/core@7.2.0 @mikro-orm/mysql@7.2.0 @mikro-orm/mongodb@7.2.0 kysely drizzle-orm prisma@7.10.0 @prisma/client@7.10.0 @prisma/adapter-mariadb@7.10.0
RELDENS_TEST_OBJECTION_ENABLED=1 RELDENS_TEST_MIKRO_ORM_ENABLED=1 RELDENS_TEST_PRISMA_ENABLED=1 RELDENS_TEST_KYSELY_ENABLED=1 RELDENS_TEST_DRIZZLE_ENABLED=1 npm run test

Always use that single install command: any later npm install, with or without --no-save, prunes the --no-save packages from a previous run.

How the flags are applied:

  • TestHelpers.activeDriverNames() always returns knex and adds each optional driver only when its flag is 1 and its packages resolve. DriverRegistry and run-tests.js use that list, so a disabled driver is skipped entirely.
  • TestHelpers.isPrismaEnabled() returns true only when RELDENS_TEST_PRISMA_ENABLED=1 and prisma, @prisma/client and @prisma/adapter-mariadb resolve.
  • The pre-flight check verifyAllPackages() lists the Objection, MikroORM and Prisma packages only when their flag is 1, and then as optional (a warning, not a failure).
  • tests/unit/test-drivers.js includes the Prisma driver only when TestHelpers.isPrismaEnabled() is true.
  • The prisma CLI package is resolved through prisma/package.json, because its exports["."] target build/types.js is not shipped in 7.9.1.
  • When a package does not resolve locally, TestHelpers.registerNpmGlobalPaths() adds the npm root -g folder, its nested @prisma/client/node_modules and the project node_modules to NODE_PATH and retries. Global installs (npm install -g ...) work for every driver except Prisma, because prisma generate only resolves @prisma/client from the project.
  • TestHelpers.getTestDbConfig() sets connectionLimit: 2 and every test data server passes poolConfig: {min: 0, max: 2}, so six drivers plus the sample data servers stay under the MySQL connection limit.

The Golden Rule: Connect Once, Test Many

Database connections, table creation and entity generation happen once per driver, never per test.

Per driver suite:

  1. Connect to the database - once.
  2. Create the tables via SQL - once.
  3. Generate the entities - once.
  4. Run all the tests - many times.
  5. Clean up and disconnect - once.

Per test:

  1. Delete the data from the tables - the only per test setup.
  2. Run the test.

Lifecycle Hooks Pattern

When a test file is written with lifecycle hooks, the setup goes in before(), the data cleanup in beforeEach() and the teardown in after():

describe('Driver: '+driverName, () => {
    let dataServer;
    let categoriesRepo;
    let productsRepo;
    let reviewsRepo;
    let schemaPath = FileHandler.joinPaths(__dirname, '..', 'fixtures', 'sql', 'test-schema.sql');

    // RUNS ONCE - Setup everything
    before(async function(){
        this.timeout(30000);
        let rawEntities = {};
        // Step 1: Connect to database
        dataServer = await TestHelpers.setupDriver(driverName, rawEntities);
        if(!dataServer){
            throw new Error('Failed to setup driver: '+driverName);
        }
        // Step 2: Create tables via raw SQL
        let schemaSql = FileHandler.readFile(schemaPath);
        await TestHelpers.executeRawSQL(dataServer, schemaSql);
        // Step 3: Generate entities (introspects database, creates models)
        let entitiesGenerated = await TestHelpers.generateTestEntities(dataServer, driverName);
        if(!entitiesGenerated){
            throw new Error('Failed to generate test entities for '+driverName);
        }
        // Step 4: Get repository references
        categoriesRepo = dataServer.getEntity('testCategories');
        productsRepo = dataServer.getEntity('testProducts');
        reviewsRepo = dataServer.getEntity('testReviews');
    });

    // RUNS BEFORE EACH TEST - Only delete data
    beforeEach(async () => {
        await TestHelpers.cleanDatabase(dataServer);
    });

    // RUNS ONCE - Final cleanup
    after(async () => {
        if(dataServer){
            await TestHelpers.dropTestTables(dataServer);
            await TestHelpers.teardownDriver(dataServer);
        }
    });

    describe('CREATE Operations', () => {
        it('should create single record', async () => {
            // Test code here
        });
    });
});

The wrong pattern puts the setup in beforeEach() and the teardown in afterEach():

// WRONG - reconnects, recreates the tables and regenerates the entities before EVERY test
beforeEach(async () => {
    dataServer = await TestHelpers.setupDriver(driverName, rawEntities);
    let schemaSql = FileHandler.readFile(schemaPath);
    await TestHelpers.executeRawSQL(dataServer, schemaSql);
    await TestHelpers.generateTestEntities(dataServer, driverName);
    categoriesRepo = dataServer.getEntity('testCategories');
});

// WRONG - disconnects after EVERY test
afterEach(async () => {
    await TestHelpers.teardownDriver(dataServer);
});

With that pattern every single test connects, drops and creates all the tables, introspects the schema, generates the models (for Prisma also the schema file and the client, then reconnects), runs and disconnects. Why it is wrong:

  • Creates and drops the tables hundreds of times.
  • Reconnects to the database hundreds of times.
  • For Prisma, generates the schema and the client and reconnects hundreds of times.
  • Makes the tests around 100x slower.
  • Breaks the test output indentation and pollutes the logs with connection messages.

How the Test Runner Applies It

tests/run-tests.js shares one data server per driver through DriverRegistry:

  1. Initialization, once per active driver (DriverRegistry.initialize()):
    • setupDriver(): connect to the database.
    • executeRawSQL(): create the tables from the SQL schema.
    • generateTestEntities(): introspect the database, run the EntitiesGenerator and load the entities.
    • dataServer.getEntity(): obtain the repository references.
  2. Test execution, per group method: each test class (DriversTest, NestedFiltersTest, RelationsTest, RawQueriesTest and the Reldens shape and sample data suites) has group methods. Each group method begins with await TestHelpers.cleanDatabase(this.dataServer) to delete all rows, then runs its tests through runner.test().
  3. Teardown, once per driver (DriverRegistry.cleanup()): dropTestTables() drops all the test tables and teardownDriver() disconnects.
async testCreateOperations() {
    this.runner.group('CREATE Operations');
    await TestHelpers.cleanDatabase(this.dataServer);
    // insert fixture data, then call runner.test() for each assertion
}

Each active driver gets one connection, one table creation and one entity generation. The Prisma driver initialization also generates the Prisma schema and client in a subprocess before connecting. The cross driver equivalence suite runs only when two or more drivers are active, and a benchmark table at the end of the run reports the test count, total and average duration per active driver.

DataServer Flow

Every active driver follows the same flow: connection, tables, entity generation, repositories.

  1. new DataServer({config, rawEntities}), plus the driver modules object for the optional drivers.
  2. await dataServer.connect(): establishes the database connection and sets this.initialized.
  3. await executeRawSQL(dataServer, schemaSql): creates the tables in the database. The tables must exist before the next step.
  4. await dataServer.generateEntities(): with the generated models loaded as rawEntities, creates one driver instance (repository) per entity and registers them in the EntityManager.
  5. dataServer.getEntity('entityName'): returns the repository with the full BaseDriver API.

Driver Notes

  • Knex (lib/knex/knex-data-server.js): default and only bundled driver. connect() creates the Knex instance, KnexDriver extends QueryBuilderDriver and loads the relations through RelationsLoader. No pre-generation required.
  • Kysely and Drizzle (lib/kysely/, lib/drizzle/): also built on QueryBuilderDriver, they receive kyselyModules or drizzleModules, or resolve them from the project node_modules. No pre-generation required.
  • ObjectionJS (lib/objection-js/objection-js-data-server.js): extends KnexDataServer, so the Knex connection code exists once, and binds the Objection Model to the Knex instance. Models are plain classes extending the Objection Model, with the relations in the static relationMappings getter. No pre-generation required.
  • MikroORM (lib/mikro-orm/mikro-orm-data-server.js): initializes MikroORM with the raw entities, supports MySQL and MongoDB, entity metadata through decorators and dynamic entity discovery. MongoDB collections are introspected with listCollections(). No pre-generation required; the tests reconnect it after loading the generated models, and cleanDatabase() also clears its entity manager.
  • Prisma (lib/prisma/prisma-data-server.js): requires pre-generation. The prisma/schema.prisma file must exist and the client must be generated (npx prisma generate) before connect(), which validates the prismaModules object and builds the client only when none was passed. generateEntities() needs a matching model in the generated client for every table and logs No matching Prisma model found otherwise. The tests run the generation in subprocesses.

The SQL drivers fetch the database schema with MySQLTablesProvider.fetchTables(), which reads information_schema.

Test Helpers Entity Generation

TestHelpers.generateTestEntities(dataServer, driverName) in tests/utils/test-helpers.js:

static async generateTestEntities(dataServer, driverName)
{
    // Prisma only: generate schema + client if not already present
    if('prisma' === driverName){
        let config = this.getTestDbConfig();
        config.client = 'mysql';
        let schemaPath = FileHandler.joinPaths(process.cwd(), 'prisma', 'schema.prisma');
        if(!FileHandler.exists(schemaPath)){
            if(!await this.generatePrismaSchema(config)){
                throw new Error('Failed to generate Prisma schema');
            }
            if(!await this.generatePrismaClient()){
                throw new Error('Failed to generate Prisma client');
            }
            await dataServer.disconnect();
            delete require.cache[require.resolve(FileHandler.joinPaths(process.cwd(), 'prisma', 'client'))];
            await dataServer.connect();
        }
    }
    // Run EntitiesGenerator: introspects DB, writes entity + model files to generated-entities/
    await this.runEntitiesGenerator(dataServer, driverName);
    this.fixGeneratedRequirePaths();
    this.compareGeneratedWithExpected(driverName, 'entities');
    this.compareGeneratedWithExpected(driverName, 'models/'+driverName);
    if('knex' === driverName){
        this.compareGeneratedWithExpected(driverName, 'entities-config.js');
        this.compareGeneratedWithExpected(driverName, 'entities-translations.js');
    }
    // Load generated registered-models file and populate entity manager
    await this.loadGeneratedEntities(dataServer, driverName);
    return true;
}

runEntitiesGenerator() creates an EntitiesGenerator pointed at the connected data server and calls generator.generate(), which calls dataServer.fetchEntitiesFromDatabase() to read the real table metadata. After the generation, loadGeneratedEntities() loads generated-entities/models/[driver]/registered-models-[driver].js, sets dataServer.rawEntities and calls dataServer.generateEntities() to register every driver instance in the entity manager.

Why the tables must exist first:

  • fetchEntitiesFromDatabase() queries the MySQL information_schema.
  • It returns the actual table structures with columns, types and foreign keys.
  • Without tables it returns empty or null, and the entity generation fails without table metadata.

Test Database Operations

All three helpers live in tests/utils/test-helpers.js and wrap their statements between SET FOREIGN_KEY_CHECKS=0; and SET FOREIGN_KEY_CHECKS=1;, re-enabling the checks when a statement fails.

cleanDatabase() - Delete Data Only

static async cleanDatabase(dataServer)
{
    try {
        await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=0;');
        await dataServer.rawQuery('DELETE FROM test_reviews;');
        await dataServer.rawQuery('DELETE FROM test_product_details;');
        await dataServer.rawQuery('DELETE FROM test_products;');
        await dataServer.rawQuery('DELETE FROM test_categories;');
        await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=1;');
        if(dataServer.orm && dataServer.orm.em){
            dataServer.orm.em.clear();
        }
        return true;
    } catch(error) {
        try {
            await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=1;');
        } catch(e) {}
        return false;
    }
}
  • Used at the start of every test group (or in beforeEach() with lifecycle hooks).
  • Purpose: a clean slate for each test without recreating the tables.
  • Speed: very fast (milliseconds).

dropTestTables() - Drop Tables

Runs DROP TABLE IF EXISTS for test_reviews, test_product_details, test_products and test_categories.

  • Used once per driver, in the teardown (or in after()).
  • Purpose: complete cleanup, removes the tables from the database.
  • Speed: fast, but slower than DELETE.

executeRawSQL() - Create Tables

static async executeRawSQL(dataServer, sql)
{
    try {
        await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=0;');
        let statements = sql.split(';').filter(stmt => stmt.trim().length > 0);
        for(let i = 0; i < statements.length; i++){
            let statement = statements[i].trim();
            if(!statement){
                continue;
            }
            await dataServer.rawQuery(statement+';');
        }
        await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=1;');
        return true;
    } catch(error) {
        try {
            await dataServer.rawQuery('SET FOREIGN_KEY_CHECKS=1;');
        } catch(e) {}
        throw error;
    }
}
  • Used once per driver, in the initialization (or in before()).
  • Purpose: create all the tables from the SQL schema file.
  • Speed: slow (can take seconds), so it only runs once.

Prisma Special Handling

Why Prisma Is Different

  • All the other drivers: models are code (classes or plain objects), can be defined at runtime, and the database can change independently.
  • Prisma: models are code generated from a schema file, the schema defines the data model, the PrismaClient is generated code (not dynamic), and every change requires a regeneration.

Prisma Test Flow

  1. setupDriver('prisma') forks tests/utils/prisma-subprocess-worker.js, which writes prisma/schema.prisma and prisma.config.js in the repository root, runs npx prisma db pull and npx prisma generate, and disconnects.
  2. TestHelpers.loadPrismaModules() requires the generated client and the adapter, instantiates the client and returns the prismaModules object.
  3. The data server connects with that object.
  4. The tables are created via SQL.
  5. generateTestEntities(): when prisma/schema.prisma is missing, it generates the schema (npx reldens-storage-prisma) and the client (npx prisma generate) in subprocesses, disconnects, clears the require cache and reconnects with the new client.
  6. The entities are generated: the PrismaClient now has a model per table.
  7. The repositories are obtained.

Prisma Generation Commands

Generate the schema from the database:

npx reldens-storage-prisma 
  --host=localhost 
  --port=3306 
  --user=test_user 
  --password=test_password 
  --database=reldens_storage_test 
  --client=mysql

Generate the Prisma client:

npx prisma generate

In the tests, TestHelpers.generatePrismaSchema(config) and TestHelpers.generatePrismaClient() run those commands as subprocesses, log a critical error and return false when a command fails.

Common Issues and Solutions

Tests Taking Forever

  • Symptoms: each test takes 1000+ milliseconds, connection logs appear between tests, the test output has bad indentation.
  • Cause: the setup runs in beforeEach() instead of before(), so every test reconnects, recreates the tables and regenerates the entities.
  • Solution: move the connection, tables and entities to before(), use beforeEach() only for the data cleanup (DELETE), and after() for the final teardown (drop tables, disconnect).

Tests "Cancelled" Instead of Failed

  • Symptoms: the output shows tests 147, suites 0, pass 0, fail 0, cancelled 147 and no error messages.
  • Cause: the before() hook fails silently because the helper methods return false instead of throwing, and the Node.js test runner marks every test as cancelled when before() fails.
  • Solution: make the helper methods throw instead of returning false, add explicit checks (if(!result){ throw new Error(...) }), and log the errors in before() before re-throwing.

Tables Not Found During Entity Generation

  • Symptoms: fetchEntitiesFromDatabase() returns empty or null, EntitiesGenerator.generate() fails or produces no entities.
  • Cause: generateTestEntities() was called before the tables were created.
  • Solution: keep the order connect, create tables, generate entities. Never call generateTestEntities() before executeRawSQL().

Prisma Client Not Found

  • Symptoms: connect() fails, or Cannot find module errors for the generated client.
  • Cause: the client was not generated, the schema file is missing, or the generated client was removed and not regenerated.
  • Solution: run npx reldens-storage-prisma to generate the schema and npx prisma generate to generate the client, let generateTestEntities() handle the full Prisma flow, and clear the cache and reconnect after a generation.

Foreign Key Constraint Violations

  • Symptoms: DELETE fails with Cannot delete or update a parent row, DROP TABLE fails with foreign key references.
  • Cause: MySQL enforces the foreign key constraints, a parent row can not be deleted while it has children and a referenced table can not be dropped.
  • Solution: always wrap the DELETE and DROP operations between SET FOREIGN_KEY_CHECKS=0; and SET FOREIGN_KEY_CHECKS=1;, and delete and drop the children before the parents.

Summary: The Complete Flow

For each active driver (knex always, the others behind their RELDENS_TEST_*_ENABLED flag):

  1. Initialization, once per driver:
    • Connect to the database (Knex instance, MikroORM initialization, or the Prisma client from prismaModules).
    • Create the tables from the SQL file, with the foreign key checks disabled.
    • Generate the entities: run the EntitiesGenerator (introspects the database and writes the files to generated-entities/), then load the registered models and call dataServer.generateEntities().
  2. Per test group: cleanDatabase() deletes the rows of test_reviews, test_product_details, test_products and test_categories.
  3. Per test: create the test data, perform the operations, assert the results.
  4. Teardown, once per driver: drop all the tables, then disconnect (knex.destroy(), orm.close() or prisma.$disconnect()).

Performance Comparison

With the wrong pattern, each test reconnected, recreated the tables and regenerated the entities:

  • Connect: ~500ms
  • Create tables: ~1000ms
  • Generate entities: ~500ms
  • Run test: ~10ms
  • Teardown: ~100ms
  • Total per test: ~2110ms, more than 10 minutes for 100 tests on 3 drivers.

With the correct pattern, the setup and teardown happen once per driver and only the data cleanup runs between tests:

  • Setup once: connect, create tables and generate entities, ~2 seconds.
  • Each test: delete the data (~5ms) and run the test (~10ms), ~15ms.
  • Teardown once: drop tables and disconnect, ~200ms.
  • Total for 100 tests on one driver: ~3.7 seconds.

That is roughly 60 to 90 times faster, with clean output and errors that throw instead of failing silently.

Test Files

  • Integration tests:
    • tests/integration/test-drivers.js: full CRUD cycle for every active driver.
    • tests/integration/test-cross-driver-equivalence.js: the same call on every active driver, fails on any divergence.
    • tests/integration/test-nested-filters.js: complex filter syntax (AND, OR, NOT, IN, LIKE).
    • tests/integration/test-relations.js: relation loading and nested relations.
    • tests/integration/test-raw-queries.js: rawQuery with single and multiple SQL statements.
  • Test utilities:
    • tests/utils/test-helpers.js: database setup, entity generation and cleanup utilities.
    • tests/utils/driver-registry.js: shared data server and repositories per active driver.
    • tests/utils/test-runner.js: test framework with suite, group and test methods, reports through Logger.

Related Documentation

Go Up