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 testlocally 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 invokebun 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 test → bun 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.jsonhasstrict: falseandstrictPropertyInitialization: false— tests freely cast mocks withas unknown as jest.Mocked<...>.jest.config.jscollectCoverageFromissrc/**/*.tsexcluding*.d.tsand__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.envloader. Unit tests set theprocess.envvalues they need inline (e.g.EmailService.test.tssetsEMAIL_USER/EMAIL_PASS;TokenService.test.tssetsRAZORPAY_*).
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(roleEXPERT) +Mentorprofile. - Student user
teststudent@mrmentor.comwith 10 tokens. - A
Slotsrow withstatus = CONFIRMED,startDateTime= now + 5 minutes, 30-minute duration. - Shared password
Test@1234(constantTEST_PASSWORDin 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:unitusesfind src -name '*.test.ts', which matchessrc/integration/__tests__/*.test.tsas 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 exampleCourseEnrollmentService.test.tsmocks../QueueServicesoQueueService.getInstance().addEmailJobis a no-opjest.fn, and the test asserts the service path runs without actually touching Redis/BullMQ. - Direct email sending —
EmailService.test.tsmocksnodemailerand usesjest.useFakeTimers()+jest.runAllTimers()to drive the internal send delay deterministically, then assertssendMailwas called with the expected{ from, to, subject, html }. - WhatsApp / Sales side-effects —
SalesDashboardService.test.tsmocksWhatsAppServiceandQueueServiceto 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 runbun 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/**/*.tsmeansnpm run test:coveragewill report low overall percentages — that is accurate, not a misconfiguration. - Dual-runner ambiguity.
npm testuses Bun, butjest.config.jsexists forts-jest. Most files use bare globals (work under both); two files (HealthService.test.ts,redis.integration.test.ts) explicitlyimport { ... } from 'bun:test', which means those two will not run under plainnpx jest— thebun:testimport resolves only inside Bun. Keep new tests on bare globals for portability, or import frombun:testonly if you have committed to Bun. test:unitis not unit-only. Itsfind 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_HOSTproduces aconsole.warnand 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-meetingwrites to the livemasdatabase 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.jsondisables strict mode, so tests lean heavily onas 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 thex-platformheader) has no automated coverage; verify manually. - Fake timers.
EmailServicehas an internal delay; its tests mustjest.useFakeTimers()andjest.runAllTimers()thenawaitthe promise. Forgetting this hangs the test. Restore withjest.useRealTimers()inafterEach. - Module-scope mocks must precede imports.
SalesDashboardService.test.tsplaces itsjest.mock(...)calls above theimportstatements so the mocks are hoisted/applied before the service module loads its collaborators — follow that ordering when a service instantiates collaborators at import time.
Related docs¶
- 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.