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

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);

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.

// 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.

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();
    });
  });
});

7. E2E Test: Auth Guard Integration

// 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

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)