Skip to content

Test Architecture Overview

Principles

Four rules drive every decision in this test suite:

  1. Every action has a test. No exceptions — an action without a test is not done.
  2. Every ERROR_CODE has a test. The JSDoc header of every action is your test checklist. Go through every error listed and confirm there is a test that triggers it and asserts the correct response.
  3. Test who cannot do something. For every role that does NOT have access to an action, there must be a test asserting the correct rejection. At minimum, if an action requires authentication, there is a Role: Logged Out test asserting a 401. Authorization gaps are how security bugs happen.
  4. Fixtures are baselines, not scenarios. A fixture set represents a clean, minimal starting state. Mutate it inside the test to create the scenario you need — do not create a fixture file per scenario.

Directory Structure

Tests live in two places: inside each feature folder and inside the global test/ directory.

app/<Feature>/tests/
├── integration/        # full HTTP tests via supertest, one file per action
│   ├── V1Action1.test.js
│   └── V1Action2.test.js
├── tasks/              # background task tests, one file per task
│   └── V1Task1.test.js
└── helper.test.js      # unit tests for this feature's helper.js

test/
├── fixtures/
│   ├── fix1/           # baseline fixture set (JS source)
│   │   ├── admin.js
│   │   └── user.js
│   ├── fix1.sql        # compiled SQL (generated by yarn sql fix1 — do not edit)
│   └── assets/         # images, files, or other binary assets for tests
├── helpers/            # unit tests for global helpers (helpers/*.js)
└── services/           # unit tests for global services (services/*.js)

Rule: Test file location mirrors source location. Never drop a test in the test/ root — feature action tests go in app/<Feature>/tests/integration/, task tests in app/<Feature>/tests/tasks/.


Fixtures

What They Are

Fixture files are plain JavaScript modules that export an array of records representing a table in the test database. Together, the files in a fixture set (e.g. test/fixtures/fix1/) define the complete baseline state of the database before each test.

javascript
// test/fixtures/fix1/admin.js
module.exports = [
  {
    id: 'uuid-1',
    email: 'admin1@example.com',
    // admin1 is the primary test admin — active, used for most happy-path tests
  },
  {
    id: 'uuid-2',
    email: 'admin2@example.com',
    // admin2 is a secondary admin — used to test multi-user scenarios
  },
];

Every record in a fixture file must have a comment explaining its role in the baseline and any intentional relationships. A new engineer reading the fixture should immediately understand why each record exists.

Why SQL, Not JavaScript

populate() runs in beforeEach — potentially hundreds of times per suite. Loading JS fixtures through Sequelize on every call would be very slow: model hooks, validations, and per-row round-trips add up. Instead:

  1. yarn sql fix1 runs once before Jest starts (the yarn test script does this automatically). It reads the JS fixture files and compiles them into a single flat SQL file (test/fixtures/fix1.sql) full of raw INSERT statements.
  2. In each beforeEach, populate('fix1') executes that pre-built SQL directly against the test database — no ORM, no parsing, no validations. One bulk insert.

Never edit fix1.sql directly. It is generated output. Edit the fix1/*.js source files and yarn sql fix1 regenerates it. Run yarn test to regenerate and run everything.

Referencing Fixtures in Tests

Wrap every fixture require in a function and reassign in beforeEach. Never share a fixture object directly between tests — one test mutating it will silently break the next.

javascript
const adminFixFn = () => _.cloneDeep(require('../../../../test/fixtures/fix1/admin'));
let adminFix = null;

beforeEach(async () => {
  adminFix = adminFixFn(); // fresh deep copy before every test
  await reset();           // wipe the test database
});

When to Create a New Fixture Set

A new fixture set (fix2, fix3) is only justified when the baseline structure is genuinely different — the starting data has a fundamentally different shape or relationship graph. A user with isActive: false, an account with a specific role, an order in a specific status — those are scenarios. Mutate them in the test:

javascript
// WRONG — creating a fixture file for an inactive admin scenario
// test/fixtures/fix1_admin_inactive/admin.js

// RIGHT — load the baseline, mutate to the scenario in the test
it('[logged-out] should fail to login if account is inactive', async () => {
  const admin1 = adminFix[0];
  await models.admin.update({ isActive: false }, { where: { id: admin1.id } });

  const res = await request(app).post(routeUrl).send({ email: admin1.email, password: admin1.password });

  expect(res.statusCode).toBe(400);
  expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.ADMIN_BAD_REQUEST_ACCOUNT_INACTIVE));
});

The reset() + populate('fix1') in beforeEach restores the baseline before every test, so mutations from one test never affect the next.


Running Tests

bash
yarn test

This runs three steps in order:

  1. yarn lang — compiles and validates i18n keys. Fails fast if a translation key is missing.
  2. yarn sql fix1 — compiles fixture JS into SQL. Regenerates fix1.sql from the current fixture source.
  3. jest --runInBand — runs the full suite serially.

Postgres and Redis must be running. The app and test suite both require them.

--runInBand is required. All test suites share one test database. Running suites in parallel causes race conditions on reset() and populate() calls. The yarn test script already passes this flag — do not remove it.


Test Anatomy

A complete integration test file for Admin.V1Login:

javascript
/**
 * TEST ADMIN V1Login METHOD
 */

'use strict';

// 1. require path first, then load the test env before reading process.env
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../../../config/.env.test') });

// 2. standard imports (same JS file structure order as source files)
const _ = require('lodash');
const request = require('supertest');
const i18n = require('i18n');

// helpers
const { adminLogin } = require('../../../../helpers/tests');
const { ERROR_CODES, errorResponse } = require('../../../../services/error');

// models
const models = require('../../../../models');

// queues + services
const queue = require('../../../../services/queue');
const socket = require('../../../../services/socket');

// test utilities
const { reset, populate } = require('../../../../test/fixtures');

// 3. server initialized in beforeAll because it is async
let app = null;
let AdminQueue = null;

describe('Admin.V1Login', () => {
  // wrap fixtures in a function for a fresh deep copy before each test
  const adminFixFn = () => _.cloneDeep(require('../../../../test/fixtures/fix1/admin'));
  let adminFix = null;

  // define the route being tested
  const routeVersion = '/v1';
  const routePrefix = '/admins';
  const routeMethod = '/login';
  const routeUrl = `${routeVersion}${routePrefix}${routeMethod}`;

  // boot the server once before all tests in this file
  beforeAll(async () => {
    app = await require('../../../../server');
  });

  // before each test: fresh fixtures, empty queues, reset DB
  beforeEach(async () => {
    adminFix = adminFixFn();
    AdminQueue = queue.get('AdminQueue');
    await AdminQueue.obliterate({ force: true }); // always start with an empty queue
    await socket.get();
    await reset(); // wipe the test database
  });

  // after all tests: close all connections (required — failure causes hangs)
  afterAll(async () => {
    await queue.closeAll();
    await socket.close();
    await models.db.close();
    app.close();
  });

  // ─── Role: Logged Out ───────────────────────────────────────────────────────

  describe('Role: Logged Out', () => {
    beforeEach(async () => {
      await populate('fix1'); // load fixture data for this role's tests
    });

    // happy path
    it('[logged-out] should login successfully', async () => {
      const admin1 = adminFix[0];

      const res = await request(app)
        .post(routeUrl)
        .send({ email: admin1.email, password: admin1.password });

      // assert response
      expect(res.statusCode).toBe(200);
      expect(res.body.success).toBe(true);
      expect(typeof res.body.token).toBe('string');
    });

    // validation error
    it('[logged-out] should fail if email is missing', async () => {
      const res = await request(app)
        .post(routeUrl)
        .send({ password: 'somepassword' });

      expect(res.statusCode).toBe(400);
      expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.BAD_REQUEST_INVALID_ARGUMENTS));
    });

    // business rule rejection
    it('[logged-out] should fail if credentials are incorrect', async () => {
      const res = await request(app)
        .post(routeUrl)
        .send({ email: 'wrong@example.com', password: 'wrongpassword' });

      expect(res.statusCode).toBe(400);
      expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS));
    });
  }); // END Role: Logged Out

  // ─── Role: Admin (must be logged out to login) ──────────────────────────────

  describe('Role: Admin', () => {
    beforeEach(async () => {
      await populate('fix1');
    });

    it('[admin] should fail if already logged in', async () => {
      const admin1 = adminFix[0];
      const { token } = await adminLogin(app, routeVersion, request, admin1);

      const res = await request(app)
        .post(routeUrl)
        .set('authorization', `jwt-admin ${token}`)
        .send({ email: admin1.email, password: admin1.password });

      expect(res.statusCode).toBe(401);
    });
  }); // END Role: Admin
}); // END Admin.V1Login

Key Rules in This File

Load the env before reading process.env. This is unique to test files. require('path') first, then require('dotenv').config(...), then read process.env. If you read process.env before dotenv runs, every variable comes back blank.

beforeAll boots the server once. The server is async — initializing it per-test would be too slow.

beforeEach (outer) resets the database and obliterates queues. This runs before every test in the file regardless of role group.

beforeEach (inner, per role group) calls populate('fix1') at the narrowest scope that needs data. Different role groups may need different mutations or different fixture subsets.

afterAll closes every connection. Missing a close causes the test suite to hang after completion. Always close: queues, socket, database, and the app server.

Group tests by role using describe. Role: Logged Out, Role: Admin, Role: User — makes it immediately clear what access level is being tested and makes coverage gaps visible.


Supertest Setup

supertest wraps the Express app and makes real HTTP requests against it without binding to a port. The pattern is:

javascript
const request = require('supertest');

// GET request
const res = await request(app).get(routeUrl);

// POST request with body
const res = await request(app).post(routeUrl).send({ key: 'value' });

// Authenticated request
const { token } = await adminLogin(app, routeVersion, request, admin1);
const res = await request(app)
  .post(routeUrl)
  .set('authorization', `jwt-admin ${token}`)
  .send({ key: 'value' });

The authorization header prefix must match the user type: jwt-admin for admins, jwt-user for users. Using the wrong prefix results in a 401 even with a valid token.

adminLogin and userLogin are thin helpers in helpers/tests.js that hit the real login endpoint and return the JWT. They make a real HTTP request — the token is authentic, not fabricated.


What to Assert

Every test asserts two things:

1. The response — always check the HTTP status code, the success flag, and the shape of the returned data. For error cases, compare the full response body against errorResponse(i18n, ERROR_CODES.YOUR_ERROR_CODE):

javascript
// success case
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.admin).toHaveProperty('id', admin1.id);

// error case — compare full body against the error service output
expect(res.statusCode).toBe(400);
expect(res.body).toEqual(errorResponse(i18n, ERROR_CODES.ADMIN_BAD_REQUEST_INVALID_TIMEZONE));

2. The database state — after any write operation (create, update, delete), query the database directly via models to confirm the change landed. Never trust the response alone:

javascript
// assert the database after a write
const updated = await models.admin.findByPk(admin1.id);
expect(updated.timezone).toBe('America/New_York');

If the action enqueues a background job, add a third assertion — check Redis for the job. This belongs in the integration test that triggered the job, not in the task test:

javascript
const jobs = await AdminQueue.getJobs();
expect(jobs).toHaveLength(1);
expect(jobs[0].name).toBe('V1ExportTask');

Test names describe behavior, not code. Format: [role] should <outcome> when <condition>.

javascript
// good
it('[admin] should fail to update timezone if value is not a valid IANA timezone', ...)
it('[logged-out] should fail with 401 when no token is provided', ...)

// bad
it('test update timezone', ...)
it('adminUpdateTest', ...)