platform-codebase/@packages/@testing/test-utils/examples/backend-service.md

10 KiB
Executable file

Example: Backend Service Testing (NestJS)

This example shows how to test a NestJS backend service using @lilith/test-utils.

Service Structure

services/api/
├── src/
│   ├── features/
│   │   └── users/
│   │       ├── users.controller.ts
│   │       ├── users.service.ts
│   │       ├── users.controller.spec.ts
│   │       └── users.service.spec.ts
│   ├── test/
│   │   └── setup.ts
│   └── main.ts
├── package.json
└── vitest.config.ts

Setup

1. Install Dependencies

// package.json
{
  "name": "@myorg/api",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:integration": "vitest run --config vitest.integration.config.ts"
  },
  "devDependencies": {
    "@lilith/test-utils": "workspace:*",
    "@nestjs/testing": "^10.0.0",
    "vitest": "^2.0.0",
    "unplugin-swc": "^1.0.0" // For faster TypeScript compilation
  }
}

2. Configure Vitest

// vitest.config.ts
import { nodePreset } from '@lilith/test-utils/vitest-presets'
import { resolve } from 'path'
import swc from 'unplugin-swc'

export default nodePreset({
  plugins: [swc.vite()], // SWC for faster builds
  test: {
    setupFiles: ['./src/test/setup.ts'],
    exclude: [
      '**/node_modules/**',
      '**/dist/**',
      '**/*.integration.spec.ts', // Run separately
      '**/*.e2e.spec.ts',
    ],
  },
  resolve: {
    alias: {
      '@api': resolve(__dirname, './src'),
    },
  },
})

Unit Testing Services

// src/features/users/users.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Test } from '@nestjs/testing'
import { UsersService } from './users.service'
import { Repository } from 'typeorm'
import { User } from './entities/user.entity'

describe('UsersService', () => {
  let service: UsersService
  let repository: Repository<User>

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: 'UserRepository',
          useValue: {
            find: vi.fn(),
            findOne: vi.fn(),
            save: vi.fn(),
            delete: vi.fn(),
          },
        },
      ],
    }).compile()

    service = module.get<UsersService>(UsersService)
    repository = module.get('UserRepository')
  })

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const users = [{ id: 1, email: 'test@example.com' }]
      vi.spyOn(repository, 'find').mockResolvedValue(users as any)

      const result = await service.findAll()

      expect(result).toEqual(users)
      expect(repository.find).toHaveBeenCalled()
    })
  })

  describe('create', () => {
    it('should create a new user', async () => {
      const userData = { email: 'new@example.com', password: 'secret' }
      const savedUser = { id: 1, ...userData }

      vi.spyOn(repository, 'save').mockResolvedValue(savedUser as any)

      const result = await service.create(userData)

      expect(result).toEqual(savedUser)
      expect(repository.save).toHaveBeenCalledWith(userData)
    })
  })
})

Testing Controllers

// src/features/users/users.controller.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Test } from '@nestjs/testing'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'

describe('UsersController', () => {
  let controller: UsersController
  let service: UsersService

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: {
            findAll: vi.fn(),
            findOne: vi.fn(),
            create: vi.fn(),
            update: vi.fn(),
            delete: vi.fn(),
          },
        },
      ],
    }).compile()

    controller = module.get<UsersController>(UsersController)
    service = module.get<UsersService>(UsersService)
  })

  describe('GET /users', () => {
    it('should return array of users', async () => {
      const users = [{ id: 1, email: 'test@example.com' }]
      vi.spyOn(service, 'findAll').mockResolvedValue(users as any)

      const result = await controller.findAll()

      expect(result).toEqual(users)
    })
  })

  describe('POST /users', () => {
    it('should create a user', async () => {
      const userData = { email: 'new@example.com', password: 'secret' }
      const createdUser = { id: 1, ...userData }

      vi.spyOn(service, 'create').mockResolvedValue(createdUser as any)

      const result = await controller.create(userData)

      expect(result).toEqual(createdUser)
      expect(service.create).toHaveBeenCalledWith(userData)
    })
  })
})

Integration Testing

// vitest.integration.config.ts
import { nodePreset } from '@lilith/test-utils/vitest-presets'
import { resolve } from 'path'
import swc from 'unplugin-swc'

export default nodePreset({
  plugins: [swc.vite()],
  test: {
    include: ['src/**/*.integration.spec.ts'],
    setupFiles: ['./src/test/integration-setup.ts'],
    testTimeout: 30000, // Longer timeout for integration tests
  },
  resolve: {
    alias: {
      '@api': resolve(__dirname, './src'),
    },
  },
})
// src/features/users/users.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { Test } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import { AppModule } from '@api/app.module'
import request from 'supertest'

describe('Users API (Integration)', () => {
  let app: INestApplication

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile()

    app = moduleRef.createNestApplication()
    await app.init()
  })

  afterAll(async () => {
    await app.close()
  })

  describe('POST /users', () => {
    it('should create a user and return 201', async () => {
      const response = await request(app.getHttpServer())
        .post('/users')
        .send({
          email: 'integration@test.com',
          password: 'secure123',
        })
        .expect(201)

      expect(response.body).toHaveProperty('id')
      expect(response.body.email).toBe('integration@test.com')
    })

    it('should return 400 for invalid data', async () => {
      await request(app.getHttpServer())
        .post('/users')
        .send({
          email: 'invalid-email', // Invalid format
        })
        .expect(400)
    })
  })

  describe('GET /users/:id', () => {
    it('should return 404 for non-existent user', async () => {
      await request(app.getHttpServer())
        .get('/users/99999')
        .expect(404)
    })
  })
})

Mocking External Services

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

// Mock external HTTP clients
vi.mock('@nestjs/axios', () => ({
  HttpService: vi.fn(() => ({
    get: vi.fn(),
    post: vi.fn(),
  })),
}))

// Mock blockchain service
vi.mock('@lilith/blockchain', () => ({
  BlockchainService: vi.fn(() => ({
    createWallet: vi.fn(),
    signTransaction: vi.fn(),
  })),
}))

// Mock email service
vi.mock('./@services/email.service', () => ({
  EmailService: vi.fn(() => ({
    sendEmail: vi.fn().mockResolvedValue({ success: true }),
  })),
}))

Database Testing

// src/test/database.helper.ts
import { DataSource } from 'typeorm'

export async function createTestDatabase() {
  const dataSource = new DataSource({
    type: 'postgres',
    host: 'localhost',
    port: 5432,
    username: 'test',
    password: 'test',
    database: 'test_db',
    entities: ['src/**/*.entity.ts'],
    synchronize: true, // Auto-create tables for testing
  })

  await dataSource.initialize()
  return dataSource
}

export async function clearDatabase(dataSource: DataSource) {
  const entities = dataSource.entityMetadatas

  for (const entity of entities) {
    const repository = dataSource.getRepository(entity.name)
    await repository.clear()
  }
}
// src/features/users/users.database.spec.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { createTestDatabase, clearDatabase } from '@api/test/database.helper'
import { DataSource } from 'typeorm'
import { User } from './entities/user.entity'

describe('Users (Database)', () => {
  let dataSource: DataSource

  beforeAll(async () => {
    dataSource = await createTestDatabase()
  })

  afterAll(async () => {
    await dataSource.destroy()
  })

  beforeEach(async () => {
    await clearDatabase(dataSource)
  })

  it('should save user to database', async () => {
    const repository = dataSource.getRepository(User)

    const user = repository.create({
      email: 'db@test.com',
      password: 'hashed',
    })

    const saved = await repository.save(user)

    expect(saved.id).toBeDefined()
    expect(saved.email).toBe('db@test.com')
  })
})

Running Tests

# Unit tests only
pnpm test

# Watch mode
pnpm test:watch

# Integration tests
pnpm test:integration

# All tests
pnpm test && pnpm test:integration

# Coverage
pnpm test --coverage

Best Practices

1. Separate Unit and Integration Tests

  • Unit tests: Fast, isolated, mock dependencies
  • Integration tests: Slower, test real interactions

2. Use Test Fixtures

// src/test/fixtures/user.fixture.ts
export const createUserFixture = (overrides = {}) => ({
  id: 1,
  email: 'test@example.com',
  createdAt: new Date(),
  ...overrides,
})

3. Clean Up After Tests

afterEach(async () => {
  vi.clearAllMocks()
  await clearDatabase(dataSource)
})

4. Test Error Cases

it('should handle database errors gracefully', async () => {
  vi.spyOn(repository, 'save').mockRejectedValue(new Error('DB Error'))

  await expect(service.create(userData)).rejects.toThrow('DB Error')
})

Real-World Example

See @services/api/ for a complete NestJS service with:

  • Unit tests for services and controllers
  • Integration tests for API endpoints
  • Database tests with TypeORM
  • Custom mocks for blockchain and external services

Testing strategy:

  • 68 unit tests passing
  • Custom test setup for mocking dependencies
  • nodePreset with SWC plugin for faster compilation