create-e2e-test
$
npx mdskill add mx-space/core/create-e2e-testGenerate E2E test files for controllers or service unit tests.
- Creates test files for controllers or services without database access.
- Integrates with NestJS testing framework and Vitest mocking utilities.
- Analyzes module name input to determine correct file path and structure.
- Outputs TypeScript code with mocked dependencies and test case definitions.
SKILL.md
.github/skills/create-e2e-testView on GitHub ↗
---
name: create-e2e-test
description: Create E2E test file for a specified module. Use when adding end-to-end tests for controllers or unit tests for services and repositories.
argument-hint: <module-name>
disable-model-invocation: true
---
# Create Test
Create test file for a module. Module name: `$ARGUMENTS`
## Test File Locations
- Controller/service tests: `apps/core/test/src/modules/<module-name>/<module-name>.controller.spec.ts`
- Repository tests (with real PG): `apps/core/test/src/modules/<module-name>/<module-name>.repository.spec.ts`
## Test Pattern 1: Unit Test with Mocked Dependencies
For testing controllers, services, or business logic without a real database:
```typescript
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { <Name>Controller } from '~/modules/<name>/<name>.controller'
import { <Name>Service } from '~/modules/<name>/<name>.service'
describe('<Name>Controller', () => {
let controller: <Name>Controller
const mock<Name>Service = {
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deleteById: vi.fn(),
}
beforeEach(async () => {
vi.clearAllMocks()
const module = await Test.createTestingModule({
controllers: [<Name>Controller],
providers: [
{
provide: <Name>Service,
useValue: mock<Name>Service,
},
],
}).compile()
controller = module.get(<Name>Controller)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
it('calls service.findById with the correct id', async () => {
const mockRow = { id: '1234567890', name: 'Test', createdAt: new Date() }
mock<Name>Service.findById.mockResolvedValue(mockRow)
const result = await controller.getById({ id: '1234567890' })
expect(mock<Name>Service.findById).toHaveBeenCalledWith('1234567890')
expect(result).toEqual(mockRow)
})
})
```
## Test Pattern 2: E2E Test with `createE2EApp`
For testing controllers through the full HTTP pipeline (with mocked DB):
```typescript
import { describe, expect, it } from 'vitest'
import { <Name>Controller } from '~/modules/<name>/<name>.controller'
import { <Name>Service } from '~/modules/<name>/<name>.service'
import { <Name>Repository } from '~/modules/<name>/<name>.repository'
import { createE2EApp } from 'test/helper/create-e2e-app'
describe('<Name>Controller (e2e)', () => {
const proxy = createE2EApp({
controllers: [<Name>Controller],
providers: [<Name>Service, <Name>Repository],
})
it('GET /<name>s returns paginated list', async () => {
const res = await proxy.app.inject({
method: 'GET',
url: '/<name>s',
})
expect(res.statusCode).toBe(200)
const json = res.json()
// ResponseInterceptor wraps arrays as { data: [...] }
// Paginated responses include { data: [...], pagination: {...} }
})
it('GET /<name>s/:id returns single item', async () => {
const res = await proxy.app.inject({
method: 'GET',
url: '/<name>s/invalid-id',
})
// Invalid Snowflake IDs should return 400
expect(res.statusCode).toBe(400)
})
})
```
### `createE2EApp` Behavior
The `createE2EApp` helper (from `test/helper/create-e2e-app.ts`):
1. Sets up a NestJS testing module with your controllers and providers
2. Overrides `AuthGuard` with `AuthTestingGuard` — pass header `'test-token': 1` for authenticated requests
3. Registers all standard interceptors (`ResponseInterceptor`, `JSONTransformInterceptor`, `DbQueryInterceptor`, `HttpCacheInterceptor`)
4. Registers the global Zod validation pipe
5. Creates a Fastify-based Nest application
6. Returns `{ app }` — use `proxy.app.inject({ method, url, payload })` for HTTP requests
7. Automatically closes the app in `afterAll`
**Auth in tests**: All requests are treated as unauthenticated by default. To authenticate, pass the test header:
```typescript
const res = await proxy.app.inject({
method: 'POST',
url: '/<name>s',
payload: { name: 'Test' },
headers: { 'test-token': 1 }, // <-- authenticates as mock admin
})
```
## Test Pattern 3: Repository Test with Real PostgreSQL
For testing repository logic against a real database using testcontainers:
```typescript
import path from 'node:path'
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import { beforeAll, beforeEach, describe, expect, it, afterAll } from 'vitest'
import { <name>s } from '~/database/schema'
import { <Name>Repository } from '~/modules/<name>/<name>.repository'
import { SnowflakeService } from '~/shared/id/snowflake.service'
const verifyUrl = process.env.PG_VERIFY_URL
const describeIfPg = verifyUrl ? describe : describe.skip
describeIfPg('<Name>Repository', () => {
let pool: Pool
let db: NodePgDatabase<typeof import('~/database/schema')>
let repository: <Name>Repository
let snowflake: SnowflakeService
beforeAll(async () => {
pool = new Pool({ connectionString: verifyUrl })
db = drizzle(pool, { casing: 'snake_case' })
const migrationsFolder = path.resolve(
__dirname,
'../../../../src/database/migrations',
)
await migrate(db, { migrationsFolder })
snowflake = new SnowflakeService()
repository = new <Name>Repository(db as any, snowflake)
}, 60_000)
beforeEach(async () => {
// Truncate tables between tests (respect FK order)
await pool.query('truncate table <name>s cascade')
})
afterAll(async () => {
if (pool) await pool.end()
})
it('creates a row with a generated Snowflake id', async () => {
const created = await repository.create({
name: 'test',
})
expect(typeof created.id).toBe('string')
expect(created.id).toMatch(/^[1-9]\d+$/) // Snowflake format
expect(created.name).toBe('test')
})
it('findById returns null for missing id', async () => {
const result = await repository.findById('9999999999999999999')
expect(result).toBeNull()
})
it('update mutates only specified fields', async () => {
const created = await repository.create({ name: 'old' })
const updated = await repository.update(created.id, { name: 'new' })
expect(updated?.name).toBe('new')
})
it('deleteById removes the row', async () => {
const created = await repository.create({ name: 'to-delete' })
await repository.deleteById(created.id)
const found = await repository.findById(created.id)
expect(found).toBeNull()
})
})
```
### Running with Testcontainers
To run repository tests that need PostgreSQL, start a container first:
```bash
# Set PG_VERIFY_URL to a running PostgreSQL instance
export PG_VERIFY_URL=postgres://mx:mx@127.0.0.1:5432/mx_verify
# Or use docker:
docker run -d --name pg-test -e POSTGRES_USER=mx -e POSTGRES_PASSWORD=mx -e POSTGRES_DB=mx_verify -p 5433:5432 postgres:17-alpine
export PG_VERIFY_URL=postgres://mx:mx@127.0.0.1:5433/mx_verify
```
Then run the test:
```bash
# Run a single test file
pnpm test -- test/src/modules/<name>/<name>.repository.spec.ts
# Run all tests
pnpm test
# Watch mode
pnpm -C apps/core run test:watch
```
## Common Assertion Patterns
### HTTP Response Assertions
```typescript
// Status codes
expect(res.statusCode).toBe(200)
expect(res.statusCode).toBe(201) // Created
expect(res.statusCode).toBe(204) // No Content (deletes)
expect(res.statusCode).toBe(400) // Validation error
expect(res.statusCode).toBe(404) // Not found
// JSON body (auto snake_case via JSONTransformInterceptor)
const json = res.json()
expect(json).toMatchObject({ name: 'test' })
// Paginated response shape
expect(json).toMatchObject({
data: expect.any(Array),
pagination: expect.objectContaining({
total: expect.any(Number),
current_page: expect.any(Number),
total_page: expect.any(Number),
}),
})
```
### Snowflake ID Assertions
```typescript
// Valid Snowflake ID format
expect(created.id).toMatch(/^[1-9]\d+$/)
expect(typeof created.id).toBe('string')
// Invalid ID throws
await expect(repository.findById('not-an-id')).rejects.toThrow()
```
### Direct DB Inserts for Test Setup
When testing with a real database, use Drizzle directly to set up test data:
```typescript
await db.insert(<name>s).values([
{ id: snowflake.nextId(), name: 'Item 1' },
{ id: snowflake.nextId(), name: 'Item 2' },
])
```
## Test Helpers Location
- `test/helper/create-e2e-app.ts` — E2E app setup with mock auth and Redis
- `test/helper/setup-e2e.ts` — Low-level NestJS testing module setup
- `test/helper/pg-testcontainer.ts` — PostgreSQL 17 testcontainers helper
- `test/helper/redis-mock.helper.ts` — In-memory Redis mock
- `test/mock/guard/auth.guard.ts` — `AuthTestingGuard` (bypasses auth with `test-token` header)
- `test/mock/modules/` — Module-level mocks
## Notes
1. The `createE2EApp` helper uses mock Redis and bypasses auth. For real DB tests, use the repository test pattern.
2. Use `proxy.app.inject()` to send HTTP requests through the full interceptor chain.
3. Responses go through `JSONTransformInterceptor` — field names become snake_case.
4. Paginated responses include `data` and `pagination` fields.
5. Empty delete responses return 204 status code.
6. Repository tests with real PG are gated behind `process.env.PG_VERIFY_URL` — use `describeIfPg` / `describe.skip` pattern so they don't fail in CI without a database.
More from mx-space/core
- api-conventionsMX Space API design conventions. Apply when writing controllers, API endpoints, or handling HTTP requests.
- create-moduleCreate a new NestJS module with repository, service, controller, schema, and Drizzle table definition. Use when adding new feature modules, API endpoints, or business domains.
- mx-pg-controller-migrationUse when verifying and porting an mx-core controller (Post/Note/Page/Comment/Category/etc.) after the MongoDB→PostgreSQL cutover, or when its data shape no longer matches what api-client and admin-vue3 expect. Triggers on "校验 controller"、"check controller"、"迁移 controller"、"修复迁移后的接口"、"data missing after PG migration"、"related/category 字段丢了" and similar.
- mx-reviewReview code for MX Space project conventions. Checks NestJS patterns, Drizzle ORM repositories, Zod schemas, API design, etc.
- release-coreUse when releasing mx-core server (apps/core) or @mx-space/api-client package — version bump, changelog, git tag, Docker build, GitHub Release, and Dokploy redeploy. Triggers on "发版", "release a new version", "cut a release", "bump version", "publish api-client".
- run-testRun tests. Supports running all tests, single file, or pattern-matched tests.
- zod-patternsMX Space project Zod schema patterns. Apply when creating DTOs, validation schemas, or handling request validation.