441 lines
10 KiB
Markdown
441 lines
10 KiB
Markdown
|
|
# 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
|
||
|
|
|
||
|
|
```json
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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'),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
```typescript
|
||
|
|
afterEach(async () => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
await clearDatabase(dataSource)
|
||
|
|
})
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Test Error Cases
|
||
|
|
```typescript
|
||
|
|
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
|