Skip to content
KheAi
Go back

Testing - Unit + E2E

Edit page

What This Part Covers


Meteor Equivalent

Meteor’s testing story was fragmented — Velocity, practicalmeteor:mocha, chimp. Tests were slow (required a running Meteor server), hard to isolate (global Meteor object everywhere), and rarely comprehensive.

In this stack:

Test typeToolRuns inSpeed
UnitJestNode only — no NestJS bootstrap~50ms per file
E2EJest + real NestJS appFull NestJS server + PostgreSQL~2-5s per suite

1. The Testing Philosophy

What to test at each layer

Resolver ─── NOT unit-tested as a unit
              The resolver is thin: extract args, call bus, return DTO.
              No business logic → nothing to verify in isolation.
              Covered by E2E tests.

Handler  ─── Thin handlers deserve thin tests
              Verify: does execute() call the right service method with query.args?
              Nothing more.

Service  ─── Most important unit to test
              All business logic lives here.
              Test: happy path, error cases, edge cases.
              Mock the repository — never hit a real DB in unit tests.

E2E      ─── Full stack, real database
              Test the complete HTTP request → DB → response cycle.
              Catches: TypeORM query bugs, migration mismatches,
              FK violations, auth guard integration.

The Golden Rule for Unit Tests

Two kinds of truth: Unit tests verify one specialist doctor’s diagnostic decisions in a mock hospital — no real patients, no real equipment, just the decision logic under controlled conditions. E2E tests run the full hospital with real patients: real building, real front desk, real lab equipment (PostgreSQL), real pharmacy (Redis). The unit test catches the wrong diagnosis. The E2E test catches the broken door that prevents the patient from reaching the specialist at all.

From Meteor? Meteor’s testing story required a running Meteor server even for basic logic tests — no isolation was possible. In NestJS, unit tests run in plain Node with zero infrastructure: no HTTP server, no database, no real dependencies. E2E tests use a real NestJS app with a real PostgreSQL database, mirroring Meteor’s full-server tests but with full control over setup and teardown.

Memory hook: Unit test = mock hospital (milliseconds, no infra). E2E test = real hospital with real patients (seconds, needs Docker). Test your code, not the framework.

Test your code, not the framework.

Don’t test:

Do test:


2. Unit Test Setup

NestJS’s Test.createTestingModule() creates a minimal DI container with only what you register — no HTTP server, no database, no real dependencies unless you add them.

const module: TestingModule = await Test.createTestingModule({
  providers: [
    TagService,                                              // ← the class under test
    { provide: getRepositoryToken(TagEntity), useValue: mockRepo },  // ← mock repository
    { provide: getQueryServiceToken(TagEntity), useValue: {} },      // ← mock query service
  ],
}).compile();

const service = module.get<TagService>(TagService);

The staffing office in test mode: Test.createTestingModule() is the staffing office in test mode. Instead of the real UserRepository (which needs a database), the office sends a stand-in — a jest.fn() mock that returns whatever you tell it to. The class under test (TagService) never knows the difference. It receives what looks like a repository, calls its methods, and your assertions verify the decisions made with those responses.

Repository = archivist: When TagService calls this.repo.findOne(...), it’s asking the archivist to fetch a record. In unit tests, you replace the real archivist with a mock who always returns the record you specify — no archive stacks, no SQL, no database. getRepositoryToken(TagEntity) is the token NestJS uses to register that archivist so your mock replaces exactly the right one.

From Meteor? In Meteor, TasksCollection was a global — mocking it required overwriting global state and carefully restoring it after each test. In NestJS, the repository is injected via DI, so you swap it cleanly per test module with zero global side effects.

Memory hook: getRepositoryToken(Entity) = the archivist’s name tag. Use it as the provide key so your mock replaces the right dependency.

Why getRepositoryToken(TagEntity) instead of Repository<TagEntity>?

NestJS registers the TypeORM repository under a generated token (not the class itself). getRepositoryToken(TagEntity) returns that token. Using it ensures your provide key matches what @InjectRepository(TagEntity) expects.


3. Complete Service Unit Test — TagService

// apps/api/src/modules/tag/test/tag.service.spec.ts
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueryServiceToken } from '@ptc-org/nestjs-query-core';
import { TagEntity } from '../tag.entity';
import { TagService } from '../tag.service';

// ── Mock factory helpers ─────────────────────────────────────────

const makeTag = (overrides: Partial<TagEntity> = {}): TagEntity =>
  ({
    id: 1,
    name: 'Work',
    slug: 'work',
    color: '#3b82f6',
    createdAt: new Date('2024-01-01'),
    updatedAt: new Date('2024-01-01'),
    ...overrides,
  } as TagEntity);

const mockRepo = {
  findOne: jest.fn(),
  create: jest.fn(),
  save: jest.fn(),
  remove: jest.fn(),
  count: jest.fn(),
};

// ── Test suite ───────────────────────────────────────────────────

describe('TagService', () => {
  let service: TagService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TagService,
        { provide: getRepositoryToken(TagEntity), useValue: mockRepo },
        { provide: getQueryServiceToken(TagEntity), useValue: {} },
      ],
    }).compile();

    service = module.get<TagService>(TagService);
    jest.clearAllMocks();
  });

  // ── createOne ──────────────────────────────────────────────────

  describe('createOne', () => {
    const input = { name: 'Work', slug: 'work', color: '#3b82f6' };

    it('creates and returns a new tag when slug is unique', async () => {
      const savedTag = makeTag();
      mockRepo.findOne.mockResolvedValue(null);   // slug not taken
      mockRepo.create.mockReturnValue(input);
      mockRepo.save.mockResolvedValue(savedTag);

      const { success, data } = await service.createOne({ input });

      expect(success).toBe(true);
      expect(data).toEqual(savedTag);
      expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { slug: 'work' } });
      expect(mockRepo.create).toHaveBeenCalledWith(input);
      expect(mockRepo.save).toHaveBeenCalledWith(input);
    });

    it('throws BadRequestException when slug already exists', async () => {
      mockRepo.findOne.mockResolvedValue(makeTag());  // slug is taken

      await expect(service.createOne({ input })).rejects.toThrow(BadRequestException);
      await expect(service.createOne({ input })).rejects.toThrow(
        'A tag with slug "work" already exists',
      );
      expect(mockRepo.save).not.toHaveBeenCalled();
    });
  });

  // ── updateOne ──────────────────────────────────────────────────

  describe('updateOne', () => {
    it('updates name and returns before/updated pair', async () => {
      const before = makeTag({ name: 'Work' });
      const updated = makeTag({ name: 'Work Tasks' });

      // filterQueryBuilder.select(query).getOne() — mock the chain
      const mockBuilder = { getOne: jest.fn().mockResolvedValue(before) };
      jest.spyOn(service['filterQueryBuilder'], 'select').mockReturnValue(mockBuilder as any);
      mockRepo.save.mockResolvedValue(updated);

      const { success, data } = await service.updateOne({
        query: { filter: { id: { eq: 1 } } },
        input: { name: 'Work Tasks' },
      });

      expect(success).toBe(true);
      expect(data.before).toEqual(before);
      expect(data.updated).toEqual(updated);
      expect(mockRepo.save).toHaveBeenCalledWith({ ...before, name: 'Work Tasks' });
    });

    it('throws when tag not found', async () => {
      const mockBuilder = { getOne: jest.fn().mockResolvedValue(null) };
      jest.spyOn(service['filterQueryBuilder'], 'select').mockReturnValue(mockBuilder as any);

      await expect(
        service.updateOne({ query: { filter: { id: { eq: 999 } } }, input: { name: 'X' } }),
      ).rejects.toThrow(BadRequestException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('throws when new slug is already taken by another tag', async () => {
      const current = makeTag({ id: 1, slug: 'work' });
      const conflicting = makeTag({ id: 2, slug: 'personal' });

      const mockBuilder = { getOne: jest.fn().mockResolvedValue(current) };
      jest.spyOn(service['filterQueryBuilder'], 'select').mockReturnValue(mockBuilder as any);
      mockRepo.findOne.mockResolvedValue(conflicting);  // slug 'personal' is taken

      await expect(
        service.updateOne({
          query: { filter: { id: { eq: 1 } } },
          input: { slug: 'personal' },
        }),
      ).rejects.toThrow(BadRequestException);
    });
  });

  // ── deleteOne ──────────────────────────────────────────────────

  describe('deleteOne', () => {
    it('removes the tag and returns its id', async () => {
      const tag = makeTag();
      mockRepo.findOne.mockResolvedValue(tag);
      mockRepo.remove.mockResolvedValue(tag);

      const { success, data } = await service.deleteOne({ input: 1 });

      expect(success).toBe(true);
      expect(data).toBe(1);
      expect(mockRepo.remove).toHaveBeenCalledWith(tag);
    });

    it('throws BadRequestException when tag not found', async () => {
      mockRepo.findOne.mockResolvedValue(null);

      await expect(service.deleteOne({ input: 999 })).rejects.toThrow(BadRequestException);
      expect(mockRepo.remove).not.toHaveBeenCalled();
    });
  });

  // ── count ──────────────────────────────────────────────────────

  describe('count', () => {
    it('returns the correct count', async () => {
      mockRepo.count.mockResolvedValue(5);

      const result = await service.count({ query: {} });
      expect(result).toBe(5);
    });
  });
});

4. Handler Unit Test Pattern

Handler tests verify the thin delegation rule — nothing more.

CQRS = two separate kitchens: Commands mutate state, Queries read state — they never share a stove. A handler test verifies only one thing: does execute() call the correct service method with the correct args? There is no business logic to test in the handler — if there were, it would be in the wrong kitchen.

From Meteor? In Meteor, Meteor.methods({ createTask }) mixed routing, logic, and DB calls in one block — untestable in isolation. In NestJS, the handler is a thin one-liner; you can test it without a database, without a bus, without any infrastructure at all.

Memory hook: Handler test = verify the postal sorting facility routes to the right driver. One assertion: serviceMethod was called with message.args.

// apps/api/src/modules/tag/test/tag.cqrs.spec.ts
import {
  CountTagQueryHandler,
  CreateOneTagCommandHandler,
  DeleteOneTagCommandHandler,
  FindManyTagQueryHandler,
  FindOneTagQueryHandler,
  UpdateOneTagCommandHandler,
} from '../cqrs/tag.cqrs.handler';
import {
  CountTagQuery,
  CreateOneTagCommand,
  DeleteOneTagCommand,
  FindManyTagQuery,
  FindOneTagQuery,
  UpdateOneTagCommand,
} from '../cqrs/tag.cqrs.input';
import { TagService } from '../tag.service';

// Create a mock for each service method
const mockService = {
  findOne: jest.fn(),
  findMany: jest.fn(),
  count: jest.fn(),
  createOne: jest.fn(),
  updateOne: jest.fn(),
  deleteOne: jest.fn(),
} as unknown as TagService;

describe('Tag CQRS Handlers', () => {
  beforeEach(() => jest.clearAllMocks());

  const cases = [
    {
      name: 'FindOneTagQueryHandler',
      HandlerClass: FindOneTagQueryHandler,
      QueryClass: FindOneTagQuery,
      serviceMethod: 'findOne',
      args: { query: { filter: { id: { eq: 1 } } } },
    },
    {
      name: 'FindManyTagQueryHandler',
      HandlerClass: FindManyTagQueryHandler,
      QueryClass: FindManyTagQuery,
      serviceMethod: 'findMany',
      args: { query: {} },
    },
    {
      name: 'CountTagQueryHandler',
      HandlerClass: CountTagQueryHandler,
      QueryClass: CountTagQuery,
      serviceMethod: 'count',
      args: { query: {} },
    },
    {
      name: 'CreateOneTagCommandHandler',
      HandlerClass: CreateOneTagCommandHandler,
      QueryClass: CreateOneTagCommand,
      serviceMethod: 'createOne',
      args: { input: { name: 'Work', slug: 'work' } },
    },
    {
      name: 'UpdateOneTagCommandHandler',
      HandlerClass: UpdateOneTagCommandHandler,
      QueryClass: UpdateOneTagCommand,
      serviceMethod: 'updateOne',
      args: { query: { filter: { id: { eq: 1 } } }, input: { name: 'New Name' } },
    },
    {
      name: 'DeleteOneTagCommandHandler',
      HandlerClass: DeleteOneTagCommandHandler,
      QueryClass: DeleteOneTagCommand,
      serviceMethod: 'deleteOne',
      args: { input: 1 },
    },
  ];

  cases.forEach(({ name, HandlerClass, QueryClass, serviceMethod, args }) => {
    describe(name, () => {
      it(`delegates to service.${serviceMethod}(message.args) and returns the result`, async () => {
        const expectedResult = { success: true, data: {} };
        (mockService[serviceMethod as keyof TagService] as jest.Mock).mockResolvedValue(expectedResult);

        const handler = new (HandlerClass as any)(mockService);
        const message = new (QueryClass as any)(args);
        const result = await handler.execute(message);

        expect(mockService[serviceMethod as keyof TagService]).toHaveBeenCalledWith(message.args);
        expect(result).toEqual(expectedResult);
      });
    });
  });
});

5. E2E Test Setup

E2E tests use a real NestJS application connected to a real test database. They verify the complete stack — guards, pipes, bus routing, service logic, TypeORM queries, and PostgreSQL constraints — all together.

ValidationPipe = customs hall: The global setup applies the same ValidationPipe config as main.tswhitelist: true, forbidNonWhitelisted: true, transform: true. Without this line in the E2E setup, your E2E tests run against a different configuration than production, making them meaningless. The customs hall must match the one at the real hospital entrance.

From Meteor? Meteor’s full-stack tests required a running Meteor server with no way to reset state between tests. NestJS E2E tests spin up a full app, run against a dedicated test database, and reset between suites with a TRUNCATE — fast, isolated, and deterministic.

5.1 Global Setup

// apps/api-e2e/src/support/global-setup.ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../../../apps/api/src/app/app.module';
import { DataSource } from 'typeorm';

let app: INestApplication;
let dataSource: DataSource;

export async function setup() {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();

  app = moduleRef.createNestApplication();

  // Same global pipes as main.ts — critical for accurate E2E
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );

  await app.init();

  dataSource = app.get(DataSource);

  // Expose globally for tests
  global.__APP__ = app;
  global.__DATA_SOURCE__ = dataSource;

  // Create an authenticated user and token for tests
  const authResponse = await makeRequest(app, {
    query: `mutation {
      register(input: {
        fullname: "E2E Test User"
        username: "e2etestuser"
        email: "e2e@test.com"
        password: "Secret123!"
      }) { accessToken }
    }`,
  });
  global.__TOKEN__ = authResponse.data?.register?.accessToken;
}

export async function teardown() {
  await app.close();
}

// Helper: send a GraphQL request to the test app
async function makeRequest(app: INestApplication, body: object) {
  const { default: request } = await import('supertest');
  const response = await request(app.getHttpServer())
    .post('/graphql')
    .send(body)
    .set('Content-Type', 'application/json');
  return response.body;
}

5.2 Test Database Reset

// apps/api-e2e/src/support/reset-db.ts
import { DataSource } from 'typeorm';

export async function resetTestDb(dataSource: DataSource) {
  // Truncate in the right order (respect FK constraints)
  await dataSource.query(`
    TRUNCATE TABLE todo, tag RESTART IDENTITY CASCADE
  `);
  // Don't truncate the user table — we need the test user created in global-setup
}

5.3 Jest Configuration for E2E

// jest.e2e.config.js
module.exports = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  testEnvironment: 'node',
  testRegex: '.e2e-spec.ts$',
  transform: { '^.+\\.(t|j)s$': 'ts-jest' },
  globalSetup: './apps/api-e2e/src/support/global-setup.ts',
  globalTeardown: './apps/api-e2e/src/support/global-teardown.ts',
  testTimeout: 30000,  // E2E tests can be slower
};

6. Complete E2E Test: Tag Module

// apps/api-e2e/src/api/tag.e2e-spec.ts
import * as request from 'supertest';

const graphql = (query: string, variables?: object, token?: string) =>
  request(global.__APP__.getHttpServer())
    .post('/graphql')
    .send({ query, variables })
    .set('Content-Type', 'application/json')
    .set('Authorization', token ? `Bearer ${token}` : '');

describe('Tag API (e2e)', () => {
  let createdTagId: number;

  beforeEach(async () => {
    await resetTestDb(global.__DATA_SOURCE__);
  });

  // ── createTag ─────────────────────────────────────────────────

  describe('createTag mutation', () => {
    it('creates a tag when authenticated', async () => {
      const response = await graphql(
        `mutation CreateTag($input: CreateTagInput!) {
           createTag(input: $input) { id name slug color createdAt }
         }`,
        { input: { name: 'Work', slug: 'work', color: '#3b82f6' } },
        global.__TOKEN__,
      );

      expect(response.status).toBe(200);
      expect(response.body.errors).toBeUndefined();

      const tag = response.body.data.createTag;
      expect(tag.id).toBeDefined();
      expect(tag.name).toBe('Work');
      expect(tag.slug).toBe('work');
      expect(tag.color).toBe('#3b82f6');
      createdTagId = tag.id;
    });

    it('returns Unauthorized when not authenticated', async () => {
      const response = await graphql(
        `mutation { createTag(input: { name: "Fail", slug: "fail" }) { id } }`,
      );
      // No token → 401
      expect(response.body.errors[0].message).toMatch(/unauthorized/i);
    });

    it('returns 400 for invalid slug format', async () => {
      const response = await graphql(
        `mutation CreateTag($input: CreateTagInput!) {
           createTag(input: $input) { id }
         }`,
        { input: { name: 'Bad Slug', slug: 'BAD SLUG!' } },
        global.__TOKEN__,
      );
      expect(response.body.errors).toBeDefined();
      expect(response.body.errors[0].message).toMatch(/slug/i);
    });

    it('returns 400 for duplicate slug', async () => {
      // Create first tag
      await graphql(
        `mutation CreateTag($input: CreateTagInput!) { createTag(input: $input) { id } }`,
        { input: { name: 'Work', slug: 'work' } },
        global.__TOKEN__,
      );

      // Try to create duplicate
      const response = await graphql(
        `mutation CreateTag($input: CreateTagInput!) { createTag(input: $input) { id } }`,
        { input: { name: 'Work 2', slug: 'work' } },  // same slug
        global.__TOKEN__,
      );
      expect(response.body.errors[0].message).toMatch(/already exists/i);
    });
  });

  // ── getTags ───────────────────────────────────────────────────

  describe('getTags query', () => {
    beforeEach(async () => {
      // Seed some tags
      for (const tag of [
        { name: 'Work', slug: 'work' },
        { name: 'Personal', slug: 'personal' },
        { name: 'Urgent', slug: 'urgent', color: '#ef4444' },
      ]) {
        await graphql(
          `mutation CreateTag($input: CreateTagInput!) { createTag(input: $input) { id } }`,
          { input: tag },
          global.__TOKEN__,
        );
      }
    });

    it('returns paginated tags without authentication', async () => {
      const response = await graphql(`
        query {
          getTags(paging: { first: 10 }) {
            totalCount
            edges { node { id name slug } cursor }
            pageInfo { hasNextPage }
          }
        }
      `);

      expect(response.body.errors).toBeUndefined();
      const { totalCount, edges } = response.body.data.getTags;
      expect(totalCount).toBe(3);
      expect(edges).toHaveLength(3);
    });

    it('filters tags by name', async () => {
      const response = await graphql(`
        query {
          getTags(filter: { name: { like: "%ork%" } }) {
            totalCount
            edges { node { name } }
          }
        }
      `);

      expect(response.body.data.getTags.totalCount).toBe(1);
      expect(response.body.data.getTags.edges[0].node.name).toBe('Work');
    });

    it('returns next page using cursor', async () => {
      const firstPage = await graphql(`
        query { getTags(paging: { first: 2 }) {
          edges { cursor node { name } }
          pageInfo { hasNextPage endCursor }
        }}
      `);

      const { hasNextPage, endCursor } = firstPage.body.data.getTags.pageInfo;
      expect(hasNextPage).toBe(true);

      const secondPage = await graphql(`
        query GetTags($after: ConnectionCursor!) {
          getTags(paging: { first: 2, after: $after }) {
            edges { node { name } }
            pageInfo { hasNextPage }
          }
        }
      `, { after: endCursor });

      expect(secondPage.body.data.getTags.edges).toHaveLength(1);
      expect(secondPage.body.data.getTags.pageInfo.hasNextPage).toBe(false);
    });
  });

  // ── updateTag ─────────────────────────────────────────────────

  describe('updateTag mutation', () => {
    it('updates a tag when authenticated', async () => {
      // Create tag
      const created = await graphql(
        `mutation CreateTag($input: CreateTagInput!) { createTag(input: $input) { id } }`,
        { input: { name: 'Work', slug: 'work' } },
        global.__TOKEN__,
      );
      const id = created.body.data.createTag.id;

      // Update
      const response = await graphql(
        `mutation UpdateTag($id: Int!, $input: UpdateTagInput!) {
           updateTag(id: $id, input: $input) { id name color updatedAt }
         }`,
        { id, input: { name: 'Work Tasks', color: '#2563eb' } },
        global.__TOKEN__,
      );

      expect(response.body.errors).toBeUndefined();
      const updated = response.body.data.updateTag;
      expect(updated.name).toBe('Work Tasks');
      expect(updated.color).toBe('#2563eb');
    });

    it('returns 400 for non-existent tag', async () => {
      const response = await graphql(
        `mutation { updateTag(id: 99999, input: { name: "X" }) { id } }`,
        {},
        global.__TOKEN__,
      );
      expect(response.body.errors[0].message).toMatch(/not found/i);
    });
  });

  // ── deleteTag ─────────────────────────────────────────────────

  describe('deleteTag mutation', () => {
    it('deletes a tag and returns true', async () => {
      const created = await graphql(
        `mutation CreateTag($input: CreateTagInput!) { createTag(input: $input) { id } }`,
        { input: { name: 'Delete Me', slug: 'delete-me' } },
        global.__TOKEN__,
      );
      const id = created.body.data.createTag.id;

      const deleteResponse = await graphql(
        `mutation DeleteTag($id: Int!) { deleteTag(id: $id) }`,
        { id },
        global.__TOKEN__,
      );
      expect(deleteResponse.body.data.deleteTag).toBe(true);

      // Verify it's gone
      const getResponse = await graphql(`query { tag(id: ${id}) { id } }`);
      expect(getResponse.body.data.tag).toBeNull();
    });
  });
});

Memory hook: E2E global setup = ribbon-cutting ceremony. Apply the same pipes as main.ts. Expose app and dataSource globally. Create one test user and token once.

7. E2E Test: Auth Guard Integration

Guard = gate officer: The Auth E2E tests verify the gate officer layer directly — no token means the officer turns you away before your request reaches the ward (the resolver). The unit tests for the service never exercised this; only the full-stack E2E test can confirm the gate officer is actually in place and wired correctly.

From Meteor? In Meteor, if (!this.userId) throw new Meteor.Error('not-authorized') was scattered inside method bodies — easy to forget on one method. In NestJS, @UseGuards(AuthJwtGuard) is at the resolver level, and the E2E test catches any mutation where you forgot to add it.

Memory hook: Auth E2E = the gate officer test. Verify unauthenticated requests get 401, not just that authenticated ones succeed.

// apps/api-e2e/src/api/auth.e2e-spec.ts
describe('Auth (e2e)', () => {
  describe('register mutation', () => {
    it('registers a new user and returns tokens', async () => {
      const response = await graphql(`
        mutation {
          register(input: {
            fullname: "New User"
            username: "newuser123"
            email: "newuser@test.com"
            password: "Secret123!"
          }) {
            accessToken
            refreshToken
          }
        }
      `);

      expect(response.body.errors).toBeUndefined();
      expect(response.body.data.register.accessToken).toBeDefined();
      expect(response.body.data.register.refreshToken).toBeDefined();

      // Tokens should be JWT format (3 dot-separated base64 segments)
      const parts = response.body.data.register.accessToken.split('.');
      expect(parts).toHaveLength(3);
    });

    it('returns 400 for weak password', async () => {
      const response = await graphql(`
        mutation {
          register(input: {
            fullname: "User"
            username: "weakpassuser"
            email: "weak@test.com"
            password: "weak"
          }) { accessToken }
        }
      `);
      expect(response.body.errors).toBeDefined();
    });

    it('returns 400 for duplicate username', async () => {
      // e2etestuser already exists from global-setup
      const response = await graphql(`
        mutation {
          register(input: {
            fullname: "Dupe"
            username: "e2etestuser"
            email: "dupe@test.com"
            password: "Secret123!"
          }) { accessToken }
        }
      `);
      expect(response.body.errors[0].message).toMatch(/username/i);
    });
  });

  describe('me query', () => {
    it('returns current user when authenticated', async () => {
      const response = await graphql(
        `query { me { id fullname email status } }`,
        {},
        global.__TOKEN__,
      );
      expect(response.body.errors).toBeUndefined();
      expect(response.body.data.me.fullname).toBe('E2E Test User');
      expect(response.body.data.me.email).toBe('e2e@test.com');
    });

    it('returns Unauthorized without token', async () => {
      const response = await graphql(`query { me { id } }`);
      expect(response.body.errors[0].message).toMatch(/unauthorized/i);
    });
  });
});

8. Running Tests

# Unit tests (fast, no DB)
yarn api:test

# Unit tests with coverage
yarn api:test --coverage

# Watch mode (re-runs affected tests on file change)
yarn api:test --watch

# E2E tests (requires Docker containers running)
yarn docker:dev
yarn api:e2e

# Run a single test file
npx jest apps/api/src/modules/tag/test/tag.service.spec.ts

# Run tests matching a pattern
npx jest --testNamePattern="createOne"

Expected CI Output

PASS apps/api/src/modules/tag/test/tag.service.spec.ts
  TagService
    createOne
      ✓ creates and returns a new tag when slug is unique (4ms)
      ✓ throws BadRequestException when slug already exists (2ms)
    updateOne
      ✓ updates name and returns before/updated pair (3ms)
      ✓ throws when tag not found (1ms)
      ✓ throws when new slug is already taken by another tag (1ms)
    deleteOne
      ✓ removes the tag and returns its id (1ms)
      ✓ throws BadRequestException when tag not found (1ms)
    count
      ✓ returns the correct count (1ms)

PASS apps/api/src/modules/tag/test/tag.cqrs.spec.ts
  Tag CQRS Handlers
    FindOneTagQueryHandler
      ✓ delegates to service.findOne(message.args) and returns the result (2ms)
    ... (all handlers pass)

PASS apps/api-e2e/src/api/tag.e2e-spec.ts
  Tag API (e2e)
    createTag mutation
      ✓ creates a tag when authenticated (312ms)
      ✓ returns Unauthorized when not authenticated (89ms)
      ✓ returns 400 for invalid slug format (95ms)
      ✓ returns 400 for duplicate slug (210ms)
    getTags query
      ✓ returns paginated tags without authentication (145ms)
      ✓ filters tags by name (98ms)
      ✓ returns next page using cursor (176ms)
    ...

9. Testing Checklist for Every New Module

Unit tests:
[✅] service.spec.ts — happy path for every public method
[✅] service.spec.ts — error case: record not found
[✅] service.spec.ts — error case: unique constraint violation
[✅] cqrs.spec.ts   — each handler delegates to correct service method
[✅] cqrs.spec.ts   — each handler passes message.args (not the full message)

E2E tests:
[✅] create — success with auth
[✅] create — 401 without auth
[✅] create — 400 for invalid input
[✅] create — 400 for business rule violation (duplicate, FK not found)
[✅] list   — paginated response shape
[✅] list   — filter works
[✅] list   — cursor pagination (second page)
[✅] update — success
[✅] update — 404 for non-existent record
[✅] delete — success, record is gone
[✅] auth   — ownership: cannot access another user's records

Quick Reference

ConceptAnalogyMeteor equivalentThe one rule
Unit test philosophyMock clinic — no real patients, no real equipmentVelocity / mocha tests required a running Meteor serverTest your code, not the framework
Test.createTestingModule()Staffing office in test mode — sends stand-ins instead of real staffNo equivalent — Meteor had no DI container to mockRegister only what the class under test needs
getRepositoryToken(Entity)The archivist’s name tagGlobal TasksCollection overwriteUse as provide key so mock replaces the right dependency
Service unit testSpecialist doctor tested in a mock hospitalLogic buried inside method body — untestable in isolationMock the repository; never hit a real DB in unit tests
CQRS handler unit testPostal sorting facility routing testMeteor.methods body mixed routing + logicOne assertion: handler called service.method with message.args
E2E global setupRibbon-cutting ceremonyFull Meteor server start — no state reset between testsApply the same ValidationPipe config as main.ts
E2E database resetTRUNCATE between testsNo equivalent — state leaked between Meteor testsTruncate in FK-safe order; keep the global test user
Auth guard E2EGate officer test — verify the entrance is actually lockedif (!this.userId) scattered in method bodiesAlways test the 401 case, not just the 200 case
GuardGate officer.allow() / .deny() at DB layerReturns true or throws. Runs before Pipe.
ValidationPipe (E2E setup)Customs hallcheck(input, String) — optional, per-methodMust match main.ts config exactly or E2E results are meaningless

Summary

LayerTest typeMock strategyWhen it breaks
HandlerUnitMock TagService with jest.fn()Handler contains logic
ServiceUnitMock TypeORM repo with jest.fn()Business rule regression
Resolver + full stackE2EReal NestJS app + real PostgreSQLAuth guard broken, migration wrong, FK violated

The key distinction:


Edit page
Share this post:

Next Post
Queues & Real-time
Previous Post
Case Study 2 - Todo Module (FK + Auth + DataLoader)