Skip to main content

Testing

Unchained Engine uses Node.js built-in test runner for both unit and integration tests. This guide covers how to test custom plugins, modules, and integrations.

Running Tests​

All Tests​

npm run test

Unit Tests Only​

npm run test:run:unit

Integration Tests​

npm run test:run:integration

Single Test File​

# Unit test
node --test path/to/test.ts

# Integration test (from monorepo root)
node --no-warnings \
--env-file .env.tests \
--env-file-if-exists=.env \
--test-isolation=none \
--test-force-exit \
--test-global-setup=tests/helpers.js \
--test \
--test-concurrency=1 \
path/to/test.ts

Unit Testing​

Unit tests validate the behavior callbacks passed to the registration factories. Keep domain logic in named functions, register those functions in boot code, and test them without initializing the global plugin registry.

Testing a Custom Plugin​

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

const gateway = { charge: async (order) => ({ id: '' }) }; // your gateway SDK client

export const charge = async (configuration, context) => {
const result = await gateway.charge(context.order);
return { transactionId: result.id };
};

// Boot code:
// registerPaymentProvider({ adapterId: 'custom', type: 'GENERIC', charge });

describe('custom payment charge', () => {
it('returns the gateway transaction id', async () => {
gateway.charge = async () => ({ id: 'tx-1' });
const result = await charge([], { order: { _id: 'order-1' } });
assert.deepEqual(result, { transactionId: 'tx-1' });
});
});

Testing a Pricing Plugin​

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

export const calculateMemberPrice = async (sheet, context) => {
if (context.product.tags?.includes('member-price')) {
sheet.addItem({ amount: -100, isTaxable: true, isNetPrice: true });
}
};

// Boot code:
// registerProductPricing({ adapterId: 'member-price', calculate: calculateMemberPrice });

describe('member pricing', () => {
it('adds the member discount row', async () => {
const rows = [];
const sheet = { addItem: (row) => rows.push(row) };
await calculateMemberPrice(sheet, { product: { tags: ['member-price'] } });
assert.equal(rows[0].amount, -100);
});
});

Integration Testing​

Integration tests run against a live Unchained instance with MongoDB.

Test Setup​

Integration tests run against a dedicated test harness, not an example app: the global setup in tests/helpers.js bootstraps a Fastify instance and startPlatform() (via tests/setup.js) with all plugins registered, mirroring the kitchensink configuration. It shuts the platform down with platform.shutdown() in a root after() hook: --test-force-exit does not wait for globalTeardown.

Environment​

Integration tests load .env.tests, then the optional .env, whose values override it; variables already set in the shell take precedence over both (see the command above). The monorepo's .env.tests intentionally sets no MONGO_URL: the harness starts one in-memory mongodb-memory-server instance for the whole run and sets MONGO_URL to it, replacing any MONGO_URL you set. The platform and the unit tests that need a database (when they run in the same process with npm run test) share that server, each in its own database; run on their own, those unit tests use a MONGO_URL from the shell, else start a server of their own. A minimal .env.tests:

NODE_ENV=test
EMAIL_WEBSITE_NAME=Unchained
EMAIL_WEBSITE_URL=http://localhost:4010
EMAIL_FROM=noreply@unchained.local
UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all # must be at least 32 characters
# tests/setup.js assigns ROOT_URL from the dynamically allocated server port
# No MONGO_URL: mongodb-memory-server is started automatically

Writing an Integration Test​

import assert from 'node:assert/strict';
import { describe, it, before } from 'node:test';
import { setupDatabase, createLoggedInGraphqlFetch } from './helpers.js';

describe('Add a Product to the Cart', () => {
let graphqlFetch;

before(async () => {
await setupDatabase();
graphqlFetch = createLoggedInGraphqlFetch(); // admin client
});

it('creates a product and adds it to the cart', async () => {
// Create a product (title/slug live on the texts argument, not the product input)
const { data: { createProduct } } = await graphqlFetch({
query: `
mutation {
createProduct(
product: { type: SIMPLE_PRODUCT }
texts: [{ locale: "en", title: "Test Product" }]
) {
_id
}
}
`,
});

// Cart validation requires an active product
await graphqlFetch({
query: `
mutation PublishProduct($productId: ID!) {
publishProduct(productId: $productId) { _id }
}
`,
variables: { productId: createProduct._id },
});

// Add to cart
const { data: { addCartProduct } } = await graphqlFetch({
query: `
mutation AddToCart($productId: ID!) {
addCartProduct(productId: $productId, quantity: 1) {
_id
}
}
`,
variables: { productId: createProduct._id },
});

assert.ok(addCartProduct._id);
});
});

GraphQL Test Client​

The test helpers provide authenticated GraphQL clients. createLoggedInGraphqlFetch(token) takes a bearer token string and defaults to the seeded admin token:

import {
setupDatabase,
createLoggedInGraphqlFetch,
createAnonymousGraphqlFetch,
} from './helpers.js';

await setupDatabase(); // wipes and reseeds all collections

const adminGraphqlFetch = createLoggedInGraphqlFetch(); // defaults to ADMIN_TOKEN
const userGraphqlFetch = createLoggedInGraphqlFetch('Bearer user-secret'); // seeded regular user
const anonymousGraphqlFetch = createAnonymousGraphqlFetch();

// Use for admin operations
const result = await adminGraphqlFetch({
query: '{ users { _id } }',
});

Testing Custom Modules​

import assert from 'node:assert/strict';
import { after, describe, it } from 'node:test';
import { startPlatform } from '@unchainedshop/platform';

describe('Custom Module', () => {
let platform;
let unchainedAPI;

after(() => platform?.shutdown());

it('should initialize', async () => {
platform = await startPlatform({
// No background work that could still use the database after shutdown()
workQueueOptions: { disableWorker: true },
modules: {
customModule: {
configure: async ({ db }) => ({
findItems: async () => [],
createItem: async (data) => ({ _id: 'new', ...data }),
}),
},
},
});
unchainedAPI = platform.unchainedAPI;
assert.ok(unchainedAPI.modules.customModule);
});

it('should create items', async () => {
const item = await unchainedAPI.modules.customModule.createItem({
name: 'Test',
});
assert.equal(item.name, 'Test');
});
});

Best Practices​

  1. Use .env.tests for test-specific configuration to avoid affecting development data
  2. Run with --test-concurrency=1 for integration tests to avoid race conditions
  3. Clean up test data after each test suite to keep tests independent
  4. Test adapters in isolation before integration testing with the full platform
  5. Use --test-isolation=none for integration tests that share platform state