Test-Driven Development

SkillDev tools

TDD red-green-refactor cycle, test structure, mocking patterns for Vitest/Jest. Use when starting a new feature, fixing a bug, or refactoring — write the test first, then the implementation.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Test-Driven Development skill

What this skill tells your AI

The instructions your AI receives, as published by sabahattink/antigravity-fullstack-hq in skills/test-driven-development/SKILL.md and read by ahel’s review.

The Red-Green-Refactor Cycle

RED    → Write a failing test that describes the desired behavior
GREEN  → Write the minimum code to make it pass (no more, no less)
REFACTOR → Improve the code without changing behavior; tests stay green

Never skip the RED step. If you write code before the test, you lose confidence that your test actually tests anything.

Test Structure: AAA Pattern

// Arrange → Act → Assert — every test follows this shape

describe('UserService', () => {
  describe('create', () => {
    it('should hash the password before saving', async () => {
      // ARRANGE — set up everything the test needs
      const dto: CreateUserDto = {
        email: 'jane@example.com',
        password: 'S3cureP@ss!',
        name: 'Jane Doe',
      }
      const mockRepo    = createMockUserRepo()
      const mockHasher  = createMockHasher({ result: 'hashed_password' })
      const sut         = new UserService(mockRepo, mockHasher)

      // ACT — call the thing under test
      await sut.create(dto)

      // ASSERT — verify outcomes
      expect(mockHasher.hash).toHaveBeenCalledWith('S3cureP@ss!')
      expect(mockRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ passwordHash: 'hashed_password' })
      )
    })
  })
})

Vitest Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    globals:     true,
    environment: 'node',
    setupFiles:  ['./src/test/setup.ts'],
    coverage: {
      provider:   'v8',
      reporter:   ['text', 'html', 'lcov'],
      thresholds: { lines: 80, functions: 80, branches: 80 },
      exclude:    ['**/*.dto.ts', '**/*.entity.ts', '**/index.ts'],
    },
  },
})

// src/test/setup.ts
import { vi } from 'vitest'

// Reset all mocks between tests — prevents state leakage
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.restoreAllMocks() })

Mocking Patterns

Factory Functions for Mocks

// test/factories/user-repo.factory.ts
import { vi } from 'vitest'
import type { IUserRepository } from '../../domain/user/user.repository.interface'

export function createMockUserRepo(
  overrides: Partial<IUserRepository> = {}
): jest.Mocked<IUserRepository> {
  return {
    findById:      vi.fn().mockResolvedValue(null),
    findByEmail:   vi.fn().mockResolvedValue(null),
    existsByEmail: vi.fn().mockResolvedValue(false),
    save:          vi.fn().mockResolvedValue(undefined),
    delete:        vi.fn().mockResolvedValue(undefined),
    ...overrides,
  }
}

// Usage in a test
const repo = createMockUserRepo({
  findByEmail: vi.fn().mockResolvedValue(existingUser),
})

Spying on Methods

// Spy on a real object's method without replacing implementation
it('should call findById with the correct id', async () => {
  const repo   = new UserTypeOrmRepository(dataSource)
  const spy    = vi.spyOn(repo, 'findById')
  const service = new UserService(repo)

  await service.findOneOrFail(42)

  expect(spy).toHaveBeenCalledWith(42)
  expect(spy).toHaveBeenCalledTimes(1)
})

Module Mocking

// Mock an entire module
vi.mock('../lib/email-client', () => ({
  sendEmail: vi.fn().mockResolvedValue({ messageId: 'mock-id' }),
}))

// In test:
import { sendEmail } from '../lib/email-client'
expect(sendEmail).toHaveBeenCalledWith(
  expect.objectContaining({ to: 'jane@example.com' })
)

Testing Domain Logic

// Pure domain logic — no mocks needed, just test the entity
describe('User entity', () => {
  describe('rename', () => {
    it('should return a new User with the updated name', () => {
      const user = User.create({ id: UserId.generate(), email: Email.create('j@x.com'), name: 'Old Name' })

      const updated = user.rename('New Name')

      expect(updated.name).toBe('New Name')
      expect(user.name).toBe('Old Name')  // original is unchanged — immutability
    })

    it('should throw DomainError when name is blank', () => {
      const user = User.create({ id: UserId.generate(), email: Email.create('j@x.com'), name: 'Jane' })

      expect(() => user.rename('   ')).toThrow(DomainError)
      expect(() => user.rename('   ')).toThrow('Name cannot be empty')
    })
  })

  describe('promote', () => {
    it('should throw DomainError when actor is not admin', () => {
      const actor  = makeUser({ role: UserRole.USER })
      const target = makeUser({ role: UserRole.USER })

      expect(() => target.promote(UserRole.ADMIN, actor)).toThrow(DomainError)
    })
  })
})

Testing Services (Unit)

describe('CreateUserHandler', () => {
  let handler: CreateUserHandler
  let repo:    ReturnType<typeof createMockUserRepo>
  let hasher:  { hash: ReturnType<typeof vi.fn> }
  let eventBus: { publish: ReturnType<typeof vi.fn> }

  beforeEach(() => {
    repo     = createMockUserRepo()
    hasher   = { hash: vi.fn().mockResolvedValue('$2b$12$hashed') }
    eventBus = { publish: vi.fn() }
    handler  = new CreateUserHandler(repo, hasher, eventBus)
  })

  it('should save a new user and publish UserCreatedEvent', async () => {
    const cmd = new CreateUserCommand('jane@example.com', 'Pass123!', 'Jane', 'actor-id')

    const userId = await handler.execute(cmd)

    expect(repo.save).toHaveBeenCalledTimes(1)
    expect(eventBus.publish).toHaveBeenCalledWith(
      expect.objectContaining({ eventType: 'user.created', email: 'jane@example.com' })
    )
    expect(userId).toBeDefined()
  })

  it('should throw ConflictException when email already exists', async () => {
    repo.existsByEmail.mockResolvedValue(true)

    await expect(
      handler.execute(new CreateUserCommand('taken@example.com', 'Pass123!', 'Bob', 'actor'))
    ).rejects.toThrow(ConflictException)

    expect(repo.save).not.toHaveBeenCalled()
  })
})

Integration Tests with Test DB

// test/integration/users.integration.spec.ts
import { Test } from '@nestjs/testing'
import { TypeOrmModule } from '@nestjs/typeorm'
import { getRepositoryToken } from '@nestjs/typeorm'

describe('UsersService (integration)', () => {
  let app: INestApplication
  let service: UsersService

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type:       'sqlite',
          database:   ':memory:',
          entities:   [UserOrmEntity],
          synchronize: true,
        }),
        TypeOrmModule.forFeature([UserOrmEntity]),
        UsersModule,
      ],
    }).compile()

    app     = module.createNestApplication()
    service = module.get(UsersService)
    await app.init()
  })

  afterAll(() => app.close())

  afterEach(async () => {
    // Clean up between tests
    const repo = app.get(getRepositoryToken(UserOrmEntity))
    await repo.clear()
  })

  it('should persist and retrieve a user', async () => {
    const created = await service.create(
      { email: 'test@example.com', password: 'Pass123!', name: 'Test' },
      { id: 'actor-1' } as AuthUser
    )

    const found = await service.findOneOrFail(created.id)

    expect(found.email).toBe('test@example.com')
    expect(found.name).toBe('Test')
  })
})

Test Doubles Reference

TypeWhen to Use
StubReturn a canned value (no assertions needed)
MockVerify calls were made (use expect(mock).toHaveBeenCalled)
SpyWrap a real method to observe calls
FakeLightweight real implementation (in-memory DB)
DummyPlaceholder that's never called (satisfy a constructor)

Coverage Targets

lines:     80% minimum
functions: 80% minimum
branches:  80% minimum

Exclude from coverage:

  • DTOs, entities, configuration files
  • index.ts barrel files
  • Migration files
  • Type declaration files (*.d.ts)

Test File Organization

src/
├── users/
│   ├── users.service.ts
│   ├── users.service.spec.ts        # unit test — same dir
│   └── users.repository.spec.ts
test/
├── integration/
│   └── users.integration.spec.ts    # integration — separate dir
├── e2e/
│   └── users.e2e.spec.ts
└── factories/
    ├── user.factory.ts
    └── order.factory.ts

Forbidden Patterns

  • Never test implementation details — test observable behavior
  • Never share mutable state between tests — use beforeEach to reset
  • Never write tests that depend on execution order
  • Never assert on exact error messages from third-party libraries — those can change
  • Never mock the system under test (SUT) itself
  • Never skip the failing test step (RED) — you need to see it fail first
  • Never write tests for trivial getters/setters — focus on behavior
  • Never use setTimeout in tests — use fake timers (vi.useFakeTimers())

Signals

GitHub stars
30
Forks
9
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
test-driven-development-sabahattink
Source
github.com/sabahattink/antigravity-fullstack-hq