Skip to content

Testing Strategy

This guideline documents how automated testing actually works in mr-mentor-backend today: the Bun test runner, the (parallel) ts-jest config, where tests live, the unit-vs-integration split, fixtures and test seeding, and — honestly — the large parts of the codebase that have no test coverage. It is meant to orient a new engineer before they add tests for a feature, and to set realistic expectations about the current safety net.

Status: documented from source on this branch.


Overview

mr-mentor-backend is a large Express + TypeScript monolith (124 service files in src/services/, 94 controllers in src/controllers/, 77 route files in src/routes/, 51+ TypeORM entities) that hosts many products (Mr. Mentor, Mr. Hire, MAS LMS, Sales CRM, Finance, AI Platform). Against that surface area, the automated test suite is small and service-focused: 11 unit test files under src/services/__tests__/ and 2 integration test files under src/integration/__tests__/.

Who uses this:

  • Feature engineers — expected to add a unit test alongside a new service (see adding-a-feature). In practice most new code ships without tests.
  • Reviewers — run npm test locally before merging. There is no CI gate that runs the test suite (the GitHub Actions workflows only build/deploy the Docker image — confirmed in .github/workflows/build.yml, deploy-development.yml, deploy-production.yml; none invoke bun test / npm test).
  • On-call / infra — the two integration tests double as live connectivity smoke checks for PostgreSQL and Redis.

The test stack is deliberately lightweight: tests run on the Bun test runner (bun test), which provides Jest-compatible globals (describe, it/test, expect, jest.mock, jest.fn). A jest.config.js with the ts-jest preset also exists, so the same *.test.ts files can be run under classic Jest if Bun is unavailable. Tests are pure-TypeScript and require no compile step (Bun transpiles on the fly; ts-jest does the same for Jest).


Key concepts & entities

This is a guideline, not a domain — it owns no entities. The "entities" of testing here are the runner, configs, and conventions.

Concept What it is Source
Bun test runner The default runner. npm testbun test. Auto-discovers *.test.ts, provides Jest-compatible globals. package.json scripts
ts-jest / Jest config A parallel config (preset: ts-jest, testEnvironment: node, testMatch: **/__tests__/**/*.test.ts, testTimeout: 30000). Maps @/src/. Not wired to an npm script, but usable via npx jest. jest.config.js
Unit test Service class tested in isolation with all collaborators mocked (TypeORM repositories, AWS SDK, Razorpay, nodemailer, etc.). No DB, no network. src/services/__tests__/*.test.ts
Integration test Exercises a real external dependency (PostgreSQL or Redis) using env vars; self-skips (warns and returns) when the connection env vars are absent. src/integration/__tests__/*.test.ts
Test seeding npm run seed:test-meeting (src/seeding/seed-test-meeting.ts) creates a mentor user, student user, mentor profile, tokens, and a confirmed meeting slot starting in 5 minutes — a fixture for manually exercising the WebRTC meeting/recording flow. src/seeding/seed-test-meeting.ts
Repository mock The dominant unit-test pattern: build a jest.Mocked<Repository<T>> with jest.fn() stubs, and a mockDataSource whose getRepository(Entity) returns the right mock. All src/services/__tests__/*
@/ path alias @/src/. Resolved by tsconfig-paths at runtime, by moduleNameMapper in jest.config.js, and natively by Bun via tsconfig.json paths. tsconfig.json, jest.config.js

Architecture

How the test commands, runners, and configs fit together.

flowchart TD
    subgraph CMDS["npm scripts (package.json)"]
        T["npm test -> bun test"]
        TU["npm run test:unit -> bun test (find src -name *.test.ts)"]
        TI["npm run test:integration -> bun test (find src/integration -name *.test.ts)"]
        TC["npm run test:coverage -> bun test --coverage"]
        SEED["npm run seed:test-meeting"]
    end

    subgraph RUNNERS["Runners"]
        BUN["Bun test runner (default)"]
        JEST["ts-jest + Jest (jest.config.js, via npx jest)"]
    end

    subgraph TESTS["Test files"]
        UNIT["src/services/__tests__/*.test.ts (11 files)"]
        INT["src/integration/__tests__/*.test.ts (2 files)"]
    end

    subgraph DEPS["What tests touch"]
        MOCKS["Mocked collaborators: TypeORM repos, AWS SDK, Razorpay, nodemailer, google-auth"]
        REALPG["Real PostgreSQL (integration only)"]
        REALREDIS["Real Redis (integration only)"]
    end

    SEEDTGT["Seeded fixture rows in mas DB"]

    T --> BUN
    TU --> BUN
    TI --> BUN
    TC --> BUN
    BUN -.alt runner.-> JEST
    JEST --> UNIT

    BUN --> UNIT
    BUN --> INT
    UNIT --> MOCKS
    INT --> REALPG
    INT --> REALREDIS
    SEED --> SEEDTGT

Key points:

  • tsconfig.json has strict: false and strictPropertyInitialization: false — tests freely cast mocks with as unknown as jest.Mocked<...>.
  • jest.config.js collectCoverageFrom is src/**/*.ts excluding *.d.ts and __tests__ — so coverage is measured against the entire source tree, which makes the low coverage numbers very visible.
  • There is no global setup/teardown file, no bunfig.toml, and no test-only .env loader. Unit tests set the process.env values they need inline (e.g. EmailService.test.ts sets EMAIL_USER/EMAIL_PASS; TokenService.test.ts sets RAZORPAY_*).

The test pyramid (current reality)

The intended pyramid vs. what exists:

flowchart TD
    subgraph IDEAL["Conventional pyramid (target)"]
        E2E1["E2E / browser - few"]
        INT1["Integration - some"]
        UNIT1["Unit - many"]
    end
    subgraph ACTUAL["mr-mentor-backend today"]
        E2E2["E2E: NONE in this repo (manual via seed:test-meeting + /browse)"]
        CTRL2["Controller / route / HTTP: NONE"]
        WORK2["Worker / socket: NONE"]
        INT2["Integration: 2 files (DB, Redis) - self-skip without env"]
        UNIT2["Unit (service-level): 11 files, ~11 of 124 services"]
    end

The shape is inverted and incomplete: a thin band of service unit tests, two connectivity integration tests, and nothing above the service layer. See Edge cases & gotchas for the explicit gap list.


Data model

Testing owns no database tables. The only persistent artifact testing produces is the seeded test-meeting fixture written by src/seeding/seed-test-meeting.ts into the live mas database. Those rows touch real entities:

erDiagram
    USER ||--o| MENTOR : "has profile"
    USER ||--o| TOKEN : "owns balance"
    USER ||--o{ SLOTS : "books or hosts"
    MENTOR ||--o{ SLOTS : "is booked in"

    USER {
        uuid id PK
        string email
        string role
        string stage
        bool isActive
    }
    MENTOR {
        uuid id PK
        string company
        string designation
    }
    TOKEN {
        uuid id PK
        uuid userId FK
        int token
    }
    SLOTS {
        uuid id PK
        uuid mentorId FK
        uuid studentId FK
        datetime startDateTime
        datetime endDateTime
        string status
        bool isAvailable
    }

The seed script (idempotent — it checks findOne before creating) produces:

  • Mentor user testmentor@mrmentor.com (role EXPERT) + Mentor profile.
  • Student user teststudent@mrmentor.com with 10 tokens.
  • A Slots row with status = CONFIRMED, startDateTime = now + 5 minutes, 30-minute duration.
  • Shared password Test@1234 (constant TEST_PASSWORD in the script).

API surface

Testing exposes no HTTP API. Its "surface" is the npm script set in package.json:

Command Underlying invocation Purpose
npm test bun test Run all *.test.ts (unit + integration) via Bun auto-discovery.
npm run test:unit bun test $(find src -name '*.test.ts' -not -path '*/node_modules/*') Run every *.test.ts found anywhere in src (note: this glob actually includes the integration tests too, since they end in .test.ts).
npm run test:integration bun test $(find src/integration -name '*.test.ts') Run only the DB + Redis integration tests.
npm run test:coverage bun test --coverage Run all tests with Bun coverage report.
npm run seed:test-meeting ts-node -r tsconfig-paths/register src/seeding/seed-test-meeting.ts Seed the meeting/recording fixture into the mas DB.
npx jest ts-jest via jest.config.js Alternate runner (not wired to an npm script).

Caveat (inferred from the shell globs): test:unit uses find src -name '*.test.ts', which matches src/integration/__tests__/*.test.ts as well, so "unit" is not strictly unit-only. To run a single file: bun test src/services/__tests__/TokenService.test.ts.


User journeys

The relevant "journeys" here are engineer workflows: running the suite, writing a unit test against a mocked repository, and running the integration tests against live services.

Journey 1 — Run the suite locally (the common path)

An engineer runs the tests before opening a PR.

sequenceDiagram
    participant Dev as Engineer
    participant NPM as npm
    participant Bun as Bun runner
    participant FS as src tree
    participant Env as process.env

    Dev->>NPM: npm test
    NPM->>Bun: bun test
    Bun->>FS: discover all *.test.ts files
    Bun->>Bun: load each file and run describe blocks
    Note over Bun: unit tests mock all collaborators so they pass with no DB
    Bun->>Env: integration tests read DB_HOST and REDIS_HOST
    alt env vars present
        Bun->>Bun: run integration assertions against live services
    else env vars absent
        Bun-->>Dev: warn and skip integration tests
    end
    Bun-->>Dev: pass or fail summary

Because the integration tests self-skip when DB_HOST / REDIS_HOST are unset, a bare npm test on a machine with no .env runs the unit tests and harmlessly skips the integration ones.

Journey 2 — Write a unit test for a service (the canonical pattern)

Every service test follows the same recipe: mock the repositories, wire a fake DataSource, instantiate the service, stub return values, assert behaviour. This mirrors AuthService.test.ts and TokenService.test.ts.

sequenceDiagram
    participant Dev as Engineer
    participant Test as Test file
    participant Mocks as jest.fn repositories
    participant DS as mock DataSource
    participant Svc as Service under test

    Dev->>Test: write describe and beforeEach
    Test->>Mocks: build jest.Mocked Repository with stubbed findOne save create
    Test->>DS: mock getRepository to return the matching repo per entity
    Test->>Svc: new Service with mock DataSource
    Note over Test: jest.mock for bcrypt jsonwebtoken razorpay nodemailer aws-sdk
    Dev->>Test: arrange stub resolved values for this case
    Test->>Svc: call the method under test
    Svc->>Mocks: service calls repo methods
    Mocks-->>Svc: return stubbed data
    Svc-->>Test: result
    Test->>Test: expect result and expect mock called with args

Journey 3 — Run the integration tests against live infra

The integration tests in src/integration/__tests__/ connect to a real PostgreSQL / Redis, validate connection lifecycle, then close cleanly.

sequenceDiagram
    participant Dev as Engineer
    participant Bun as Bun runner
    participant DBSvc as DatabaseService singleton
    participant PG as PostgreSQL
    participant RedisSvc as RedisService singleton
    participant RD as Redis

    Dev->>Bun: npm run test:integration
    Bun->>DBSvc: beforeAll checks DB_HOST and DB_NAME
    alt DB env present
        Bun->>DBSvc: initialize
        DBSvc->>PG: open TypeORM DataSource connection
        PG-->>DBSvc: connected
        Bun->>DBSvc: assert isConnected true
        Bun->>DBSvc: afterEach close connection
    else DB env absent
        Bun-->>Dev: warn skipping database integration tests
    end
    Bun->>RedisSvc: beforeEach ensure client status ready
    alt Redis env present
        RedisSvc->>RD: initialize then set get del ping
        RD-->>RedisSvc: ok
        Bun->>RedisSvc: afterAll close
    else Redis env absent
        Bun-->>Dev: warn skipping Redis integration tests
    end

Journey 4 — Manually verify the meeting/recording flow with seeded data

There is no automated WebRTC/Socket.IO test. Instead, the team seeds a near-future confirmed meeting and exercises it by hand (often with the /browse QA skill).

sequenceDiagram
    participant Dev as Engineer
    participant Seed as seed-test-meeting script
    participant DB as mas database
    participant FE as Frontend meeting room
    participant Sock as Socket.IO server

    Dev->>Seed: npm run seed:test-meeting
    Seed->>DB: upsert mentor user student user mentor profile tokens
    Seed->>DB: create Slots row status CONFIRMED starting in 5 minutes
    Seed-->>Dev: print mentor email student email password and meeting URL
    Dev->>FE: log in as both users and open the meeting URL
    FE->>Sock: initialize-meeting join-room offer answer ice-candidate
    Sock-->>FE: presence and signaling events
    Note over Dev: validate join recording presence manually then teardown

Background jobs & async

There are no automated tests for the BullMQ workers (email, database, cleanup, kpi, resumeAnalysis) or for the Socket.IO layer (src/socket.ts, 1300+ lines). Async behaviour is covered only indirectly:

  • Email queueing — services that enqueue email jobs are tested by mocking QueueService. For example CourseEnrollmentService.test.ts mocks ../QueueService so QueueService.getInstance().addEmailJob is a no-op jest.fn, and the test asserts the service path runs without actually touching Redis/BullMQ.
  • Direct email sendingEmailService.test.ts mocks nodemailer and uses jest.useFakeTimers() + jest.runAllTimers() to drive the internal send delay deterministically, then asserts sendMail was called with the expected { from, to, subject, html }.
  • WhatsApp / Sales side-effectsSalesDashboardService.test.ts mocks WhatsAppService and QueueService to keep the test offline.

Webhooks (e.g. Razorpay signature verification) are tested at the service level by mocking crypto (see TokenService.test.ts and CourseEnrollmentService.test.ts which jest.mock('crypto') and jest.mock('razorpay')), not via the HTTP route.


External integrations

Unit tests never call real third parties; every external SDK is mocked with jest.mock(...). Observed mocks across the suite:

Integration How it is faked in tests Where
bcrypt jest.mock('bcrypt'); bcrypt.hash / bcrypt.compare stubbed AuthService.test.ts
jsonwebtoken jest.mock('jsonwebtoken') AuthService.test.ts
nodemailer jest.mock('nodemailer'); createTransport returns { sendMail } EmailService.test.ts, AuthService.test.ts
Google OAuth jest.mock('google-auth-library'); OAuth2Client.verifyIdToken stubbed AuthService.test.ts
Razorpay jest.mock('razorpay'); payments.fetch / order create stubbed TokenService.test.ts, CourseEnrollmentService.test.ts
crypto jest.mock('crypto') for signature/HMAC verification TokenService.test.ts, CourseEnrollmentService.test.ts
AWS S3 jest.mock('@aws-sdk/client-s3'), @aws-sdk/lib-storage, s3-request-presigner, and fs S3Service.test.ts
uuid jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })) for deterministic IDs S3Service.test.ts
PostgreSQL Real connection (integration only); self-skips without DB_HOST/DB_NAME database.integration.test.ts
Redis Real connection (integration only); self-skips without REDIS_HOST redis.integration.test.ts

Env-var contract for the integration tests (read directly, no .env loader in the test): - DB: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME. - Redis: REDIS_HOST, REDIS_PORT.

Failure/fallback: if those vars are missing the integration beforeAll/beforeEach log a console.warn("Skipping ...") and the test body short-circuits rather than failing — so a developer without infra still gets a green run.


Status lifecycles

A test file's run status under the runner:

stateDiagram-v2
    [*] --> Discovered
    Discovered --> Loaded : runner imports the file
    Loaded --> Running : describe and beforeEach execute
    Running --> Passed : all expect assertions hold
    Running --> Failed : an assertion throws or fail is called
    Loaded --> Skipped : integration guard sees missing env and returns
    Passed --> [*]
    Failed --> [*]
    Skipped --> [*]

Note the fail('...') escape used in AuthService.test.ts — when a discriminated-union result is the wrong variant the test explicitly calls fail(...) to force the Failed state.


Sample test template

Grounded in the existing TokenService.test.ts / AuthService.test.ts pattern. Copy this when adding a unit test for a new service. It works under both Bun and ts-jest because it relies only on Jest-compatible globals.

// src/services/__tests__/MyFeatureService.test.ts
import { MyFeatureService } from '../MyFeatureService';
import { DataSource, Repository } from 'typeorm';
import { MyEntity } from '../../entities/MyEntity';

// Mock any external SDKs the service imports at module scope.
jest.mock('razorpay');
// Mock collaborator services so no queue / network is touched.
jest.mock('../QueueService', () => ({
  QueueService: {
    getInstance: jest.fn(() => ({
      addEmailJob: jest.fn().mockResolvedValue(undefined),
    })),
  },
}));

describe('MyFeatureService', () => {
  let service: MyFeatureService;
  let mockDataSource: jest.Mocked<DataSource>;
  let mockRepo: jest.Mocked<Repository<MyEntity>>;

  beforeEach(() => {
    // 1. Build the repository mock with only the methods you use.
    mockRepo = {
      findOne: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
    } as unknown as jest.Mocked<Repository<MyEntity>>;

    // 2. Route getRepository(Entity) to the right mock.
    mockDataSource = {
      getRepository: jest.fn().mockImplementation((entity) => {
        if (entity === MyEntity) return mockRepo;
      }),
    } as unknown as jest.Mocked<DataSource>;

    // 3. Instantiate with the fake DataSource (services take DataSource in their ctor).
    service = new MyFeatureService(mockDataSource);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('doThing', () => {
    it('creates and saves the entity', async () => {
      // Arrange — stub returns for THIS case.
      mockRepo.findOne.mockResolvedValue(undefined);
      mockRepo.create.mockReturnValue({ id: '1' } as MyEntity);
      mockRepo.save.mockResolvedValue({ id: '1' } as MyEntity);

      // Act
      const result = await service.doThing({ name: 'x' });

      // Assert — both the result and the interaction.
      expect(result.id).toBe('1');
      expect(mockRepo.save).toHaveBeenCalledTimes(1);
    });
  });
});

For services that build queries with createQueryBuilder, mock a chainable builder (select/where/andWhere return this, getRawOne/getCount return values) exactly as AdminDashboardService.test.ts does.

For pure functions (no DB), test them directly with no mocks — see askMasHelpers.test.ts, which imports matchesRefusal, extractLearnTopic, etc. from src/services/askMas.helpers.ts and asserts boolean outputs. Prefer extracting pure helpers so logic is testable without the repository-mock ceremony.


How to test each layer

Layer Source location Testable today? How to test / recommendation
Entity src/entities/*.ts Indirectly TypeORM decorators need no unit test. Validate column/relation correctness via the integration DB test or by running the app with auto-sync. Test entity-attached business methods as pure functions.
Service src/services/*.ts Yes (established pattern) Constructor-inject a mock DataSource; mock repositories with jest.fn(); mock external SDKs. This is the only well-trodden path — 11 services done, 113 untested.
Pure helper e.g. src/services/askMas.helpers.ts Yes (easiest) Import and assert directly. No mocks. Highest value-per-line; extract logic here.
Controller src/controllers/*.ts Not done (inferred) Construct the controller, pass mock req/res with jest.fn() for res.status/res.json, assert status codes and payloads. No examples exist yet.
Route src/routes/*.ts Not done (inferred) Would need supertest against the Express app — not currently a dependency. No HTTP-level tests exist.
Middleware src/middleware/*.ts Not done (inferred) auth.middleware, admin.middleware, expert.middleware are pure-ish (req.userId from JWT) and good unit candidates: mock req.headers, stub jwt.verify, assert next() vs 401/403. None exist.
Worker src/workers/*.ts Not done (inferred) Test the processor function in isolation by passing a fake Job; mock the service it delegates to. No BullMQ harness exists.
Socket.IO src/socket.ts Not done (inferred) Hard — 1300+ line handler with WebRTC/recording state. Exercised manually via seed:test-meeting. Would need socket.io-client + a test server.
Integration (infra) src/integration/__tests__/* Yes Real PG/Redis with env-guarded self-skip. Extend for new infra (e.g. S3) following the same warn-and-skip pattern.

Edge cases, limits & gotchas

  • No CI test gate. The GitHub Actions workflows (build.yml, deploy-development.yml, deploy-production.yml) build and deploy the Docker image but never run bun test. A broken test will not block a merge or deploy. Run tests locally; don't assume the suite is green just because main deployed.
  • Coverage is genuinely thin. ~11 of 124 services have tests; controllers (94), routes (77), workers (5), and the Socket.IO server have zero automated coverage. collectCoverageFrom: src/**/*.ts means npm run test:coverage will report low overall percentages — that is accurate, not a misconfiguration.
  • Dual-runner ambiguity. npm test uses Bun, but jest.config.js exists for ts-jest. Most files use bare globals (work under both); two files (HealthService.test.ts, redis.integration.test.ts) explicitly import { ... } from 'bun:test', which means those two will not run under plain npx jest — the bun:test import resolves only inside Bun. Keep new tests on bare globals for portability, or import from bun:test only if you have committed to Bun.
  • test:unit is not unit-only. Its find src -name '*.test.ts' glob also matches the integration tests (they end in .test.ts). The integration tests self-skip without DB/Redis env, so this is usually harmless, but it is not a clean unit/integration separation.
  • Integration tests self-skip silently. Missing DB_HOST/REDIS_HOST produces a console.warn and a passing (skipped) run — easy to mistake a skip for a pass. Confirm the warnings are absent if you intend to actually test connectivity.
  • No test database isolation. There is no test-only DB or transactional rollback. seed:test-meeting writes to the live mas database the app uses. It is idempotent (checks before insert) but it does mutate real data and creates a real near-future slot.
  • Auto-sync is ON. With TypeORM synchronize: true, running the integration DB test (or the app) against a real DB can alter the schema. Point integration tests at a disposable/local DB, not production.
  • strict: false. tsconfig.json disables strict mode, so tests lean heavily on as unknown as jest.Mocked<...> casts. This compiles but gives you no type safety that your mock actually matches the real repository shape — a renamed method on the real class won't fail the mock.
  • Multi-platform (x-platform) untested. The platform-routing behaviour (Mr. Mentor vs My Analytics School via the x-platform header) has no automated coverage; verify manually.
  • Fake timers. EmailService has an internal delay; its tests must jest.useFakeTimers() and jest.runAllTimers() then await the promise. Forgetting this hangs the test. Restore with jest.useRealTimers() in afterEach.
  • Module-scope mocks must precede imports. SalesDashboardService.test.ts places its jest.mock(...) calls above the import statements so the mocks are hoisted/applied before the service module loads its collaborators — follow that ordering when a service instantiates collaborators at import time.

  • Adding a feature — the entity → service → controller → route flow; add a service unit test at the service step using the template above.
  • Architecture overview (if present) — where services sit relative to controllers, workers, and Socket.IO.
  • Background jobs & queues (if present) — the BullMQ workers that currently lack tests.
  • DevOps / deployment (if present) — confirms the CI pipeline does not run the test suite.