AgentSkillsCN

tdd-workflow

适用于Clean架构的NestJS端到端测试工作流。可用于新功能开发、Bug修复,以及API接口的测试。基于Jest与Supertest的集成测试方案。

SKILL.md
--- frontmatter
name: tdd-workflow
description: NestJS E2E testing workflow for Clean Architecture. Use for new features, bug fixes, and API endpoints. Jest + Supertest based integration testing.
tools: Read, Write, Edit, Bash, Grep, Glob

NestJS E2E Testing Workflow

This skill guides Test-Driven Development for NestJS serverless APIs with Clean Architecture.

When to Use

  • Adding new API endpoints
  • Modifying existing functionality or fixing bugs
  • Implementing Use Cases or Repositories
  • Changing business logic

Before Starting

Check project-specific test setup:

bash
# Find test configuration
find . -name "jest*.config.*" -o -name "jest.config.*"

# Find test setup files
find . -path "*/test/*" -name "setup*.ts"

# Find existing test examples
find . -name "*.e2e-spec.ts" | head -5

# Find test fixtures
find . -path "*/test/*" -name "*fixture*"

# Find mock implementations
find . -path "*/test/*" -name "*mock*"

Review existing test files to understand:

  • Project test file structure
  • Available fixtures and helper functions
  • Mock configurations
  • Authentication patterns

Test Strategy

E2E Tests Only (No Unit Tests)

This workflow focuses on E2E integration tests:

Test TypeUseReason
Unit TestNOLow ROI for overhead
E2E Integration TestYESValidates complete API flow
Browser E2ENOServerless API only

Test Coverage Scope

code
HTTP Request → Controller → UseCase → Repository → Database
     ↑                                                  ↓
     └──────────────── Response ←──────────────────────┘
  • Uses real NestJS app instance
  • Uses real database (test DB)
  • Only mocks external services (auth providers, SMS, storage, etc.)

TDD Workflow

Step 1: Write Tests First (RED)

Convert requirements to test cases BEFORE writing any implementation:

typescript
// test/{domain}.e2e-spec.ts
describe('Order Integration', () => {
  let fixtures: TestFixtures;

  beforeAll(() => {
    fixtures = new TestFixtures(getDb());
  });

  beforeEach(async () => {
    await clearDatabase(getDb());
  });

  describe('POST /app/orders', () => {
    it('creates an order with valid data', async () => {
      // Arrange: Setup test data using fixtures
      const user = await fixtures.createUser();
      const product = await fixtures.createProduct();

      // Act: Call API
      const response = await getRequest()
        .post('/app/orders')
        .set('x-user-id', String(user.id))
        .send({
          productId: product.id,
          quantity: 2,
        });

      // Assert: Verify result
      expect(response.status).toBe(201);
      expect(response.body.productId).toBe(product.id);
    });

    it('returns 404 when product does not exist', async () => {
      const user = await fixtures.createUser();

      const response = await getRequest()
        .post('/app/orders')
        .set('x-user-id', String(user.id))
        .send({ productId: 99999 });

      expect(response.status).toBe(404);
      expect(response.body.code).toBe('ProductNotFound');
    });
  });
});

Step 2: Run Tests (Verify FAILURE)

bash
pnpm test:e2e -- --testPathPattern="order"
# Tests MUST fail at this point (not implemented yet)

Step 3: Implement (GREEN)

Write minimal code to pass the tests, following Clean Architecture:

  1. Domain Entity (core/domain/entities/)
  2. Repository Interface (core/domain/repositories/)
  3. Domain Errors (core/domain/errors/)
  4. Use Case (app/application/use-cases/)
  5. Repository Implementation (core/infrastructure/persistence/)
  6. Mapper (core/infrastructure/persistence/drizzle/mappers/)
  7. Controller + DTOs (app/presentation/)
  8. Module Registration (app/modules/)

Step 4: Run Tests (Verify SUCCESS)

bash
pnpm test:e2e -- --testPathPattern="order"
# All tests MUST pass now

Step 5: Refactor (IMPROVE)

With passing tests as safety net:

  • Remove duplication
  • Improve naming
  • Optimize performance
  • Ensure lint passes

Step 6: Run Full Suite

bash
pnpm test:e2e
pnpm lint

Test Naming Convention

Success Cases: Use Active Verbs

typescript
it('creates a new order', async () => { ... });
it('retrieves product list with pagination', async () => { ... });
it('updates order status to shipped', async () => { ... });
it('filters products by category', async () => { ... });

Failure Cases: "returns {status} when..."

typescript
it('returns 404 when product does not exist', async () => { ... });
it('returns 400 when required field is missing', async () => { ... });
it('returns 401 when request is not authenticated', async () => { ... });
it('returns 403 when user lacks permission', async () => { ... });
it('returns 409 when resource already exists', async () => { ... });

Edge Cases: Specify Condition

typescript
it('returns empty array when no products exist', async () => { ... });
it('returns empty array when page exceeds total', async () => { ... });
it('caps size at maximum when exceeded', async () => { ... });

Required Tests (MUST Write)

1. Happy Path (Success Flow)

At least ONE success case per endpoint:

typescript
describe('POST /app/products', () => {
  it('creates a new product', async () => {
    const response = await getRequest()
      .post('/app/products')
      .send({ name: 'Product', price: 1000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(Number),
      name: 'Product',
    });
  });
});

2. Business Rule Validation

Test domain logic is correctly enforced:

typescript
it('returns 400 when changing COMPLETED order to PENDING', async () => {
  const order = await fixtures.createOrder({ status: 'COMPLETED' });

  const response = await getRequest()
    .patch(`/app/orders/${order.id}/status`)
    .send({ status: 'PENDING' });

  expect(response.status).toBe(400);
  expect(response.body.code).toBe('InvalidStatusTransition');
});

3. Authentication/Authorization (For Protected Endpoints)

typescript
describe('Authentication/Authorization', () => {
  it('returns 401 when not authenticated', async () => {
    const response = await getRequest().get('/app/users/me');
    expect(response.status).toBe(401);
  });

  it('returns 403 when accessing another user resource', async () => {
    const otherUser = await fixtures.createUser();
    const response = await getRequest()
      .get(`/app/users/${otherUser.id}/orders`)
      .set('x-user-id', String(currentUser.id));

    expect(response.status).toBe(403);
  });
});

4. Required Field Validation (ONE Representative Case)

Don't test every field - test ONE representative case:

typescript
it('returns 400 when name is missing', async () => {
  const response = await getRequest()
    .post('/app/products')
    .send({ price: 1000 }); // name missing

  expect(response.status).toBe(400);
});

5. Resource Existence

typescript
it('returns 404 when product does not exist', async () => {
  const response = await getRequest().get('/app/products/999999');
  expect(response.status).toBe(404);
});

Unnecessary Tests (DO NOT Write)

1. Framework-Guaranteed Functionality

typescript
// BAD: class-validator handles this
it('returns 400 when name is not a string', ...);
it('returns 400 when price is negative', ...);
it('returns 400 when email format is invalid', ...);

// BAD: NestJS ParseIntPipe handles this
it('returns 400 when id is not a number', ...);

2. Individual Field Validation for Every Field

typescript
// BAD: Testing each field separately
it('returns 400 when name is missing', ...);
it('returns 400 when brandId is missing', ...);
it('returns 400 when price is missing', ...);

// GOOD: ONE representative case
it('returns 400 when required field is missing', ...);

3. Redundant CRUD Tests

typescript
// BAD: Testing same logic repeatedly
it('retrieves 1 product', ...);
it('retrieves 2 products', ...);
it('retrieves 10 products', ...);

// GOOD: Representative cases only
it('retrieves product list', ...);
it('returns empty array when no products exist', ...);

4. Implementation Details

typescript
// BAD: Testing internal implementation
it('calls Repository.save() once', ...);
it('refreshes cache', ...);

// GOOD: Test external behavior only
it('creates the product', ...);

5. Type Checking

typescript
// BAD: TypeScript validates at compile time
it('response.id is number type', ...);
it('response.createdAt is string type', ...);

Test Structure

Describe Block Organization

typescript
describe('Product API', () => {
  describe('GET /app/products', () => {
    describe('Success', () => {
      it('retrieves product list', ...);
      it('filters by category', ...);
      it('applies pagination', ...);
    });

    describe('Failure', () => {
      it('returns 400 when page is invalid', ...);
    });
  });

  describe('POST /app/products', () => {
    describe('Success', () => {
      it('creates a new product', ...);
    });

    describe('Failure', () => {
      it('returns 404 when category does not exist', ...);
      it('returns 400 when required field is missing', ...);
    });
  });
});

Arrange-Act-Assert Pattern

typescript
it('filters products by category', async () => {
  // Arrange: Setup test data
  const category = await fixtures.createCategory({ name: 'Electronics' });
  await fixtures.createProduct({ categoryId: category.id });
  await fixtures.createProduct({ categoryId: null }); // Different category

  // Act: Call API
  const response = await getRequest()
    .get('/app/products')
    .query({ categoryId: category.id });

  // Assert: Verify result
  expect(response.status).toBe(200);
  expect(response.body.total).toBe(1);
});

Minimum Tests Per Endpoint

Endpoint TypeMin TestsComposition
List (GET)2-3Success 1, Empty 1, Filter 1 (if applicable)
Detail (GET)2Success 1, 404 1
Create (POST)2-3Success 1, Required missing 1, Business rule violation 1
Update (PATCH/PUT)2-3Success 1, 404 1, Business rule violation 1
Delete (DELETE)2Success 1, 404 1

Response Verification Scope

MUST Verify

typescript
// Status code
expect(response.status).toBe(200);

// Core business fields
expect(response.body.id).toBe(product.id);
expect(response.body.status).toBe('AVAILABLE');

// List response structure
expect(response.body.total).toBe(1);
expect(response.body.data).toHaveLength(1);

// Error codes (custom domain errors)
expect(response.body.code).toBe('ProductNotFound');

DON'T Over-Verify

typescript
// BAD: Verifying every field
expect(response.body.name).toBe('...');
expect(response.body.categoryId).toBe(1);
expect(response.body.price).toBe(1000);
expect(response.body.createdAt).toBe('...');
expect(response.body.updatedAt).toBe('...');

// GOOD: Verify structure and key fields
expect(response.body).toMatchObject({
  id: expect.any(Number),
  name: 'Product Name',
});

Test Fixtures Pattern

Fixture Design Principles

typescript
// test/fixtures/test-fixtures.ts
export class TestFixtures {
  constructor(private readonly db: DatabaseService) {}

  // Basic creation with sensible defaults
  async createEntity(overrides?: Partial<EntityInsert>) {
    const data = {
      field1: 'default value',
      field2: 100,
      ...overrides,
    };
    const [result] = await this.db.insert(entityTable).values(data).$returningId();
    return { id: result.id, ...data };
  }

  // Creation with required dependencies
  async createChildEntity(parentId: number, overrides?: Partial<ChildInsert>) {
    const data = {
      parentId,
      name: 'default name',
      ...overrides,
    };
    const [result] = await this.db.insert(childTable).values(data).$returningId();
    return { id: result.id, ...data };
  }

  // State-specific factory methods
  async createActiveUser() {
    return this.createUser({ status: 'ACTIVE' });
  }

  async createSuspendedUser() {
    return this.createUser({ status: 'SUSPENDED' });
  }
}

Database Cleanup Pattern

typescript
// Clear all test data between tests
export async function clearDatabase(db: DatabaseService) {
  await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0`);
  // Delete in dependency order (child → parent)
  await db.execute(sql`TRUNCATE TABLE child_table`);
  await db.execute(sql`TRUNCATE TABLE parent_table`);
  await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1`);
}

Note: Check your project's test/fixtures/ directory for available fixture methods and update clearDatabase when adding new tables.


Mock Patterns

Mock Structure

Mocks should be centralized and reusable:

typescript
// test/mocks/{service}.mock.ts
export const createMockService = () => ({
  method1: jest.fn().mockResolvedValue({ success: true }),
  method2: jest.fn().mockResolvedValue(null),
});

// Reset mocks between tests
beforeEach(() => {
  jest.clearAllMocks();
});

Common Mock Scenarios

typescript
// External API mocks
export const createMockExternalApi = () => ({
  call: jest.fn().mockResolvedValue({ data: 'response' }),
});

// Auth provider mocks
export const createMockAuthProvider = () => ({
  validateToken: jest.fn().mockResolvedValue({ userId: 1, valid: true }),
  createUser: jest.fn().mockResolvedValue({ id: 'ext-123' }),
});

// Storage service mocks
export const createMockStorage = () => ({
  upload: jest.fn().mockResolvedValue({ url: 'https://cdn.example.com/file.jpg' }),
  delete: jest.fn().mockResolvedValue(undefined),
});

Applying Mocks in Test Setup

typescript
// test/setup.ts
moduleFixture = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(EXTERNAL_SERVICE)
  .useValue(createMockExternalService())
  .overrideProvider(AUTH_PROVIDER)
  .useValue(createMockAuthProvider())
  .compile();

Note: Check your project's test/mocks/ directory for available mocks and test/setup.ts for mock configuration.


Authentication Helpers Pattern

Request Helper Pattern

typescript
// test/helpers/request.helper.ts

// For user-authenticated requests
export function withUserHeaders(request: Request, userId: number) {
  return request.set('x-user-id', String(userId));
}

// For admin-authenticated requests
export function withAdminHeaders(request: Request, adminId: string) {
  return request.set('authorization', `Bearer mock-token-${adminId}`);
}

// Usage
const response = await withUserHeaders(
  getRequest().get('/app/orders'),
  user.id,
);

Note: Check your project's test/helpers/ directory for available authentication helpers.


Test Execution

Common Commands

bash
# Run all tests
pnpm test:e2e

# Run specific file
pnpm test:e2e -- --testPathPattern="{domain}"

# Run specific test by name
pnpm test:e2e -- -t "creates a new order"

# Watch mode (development)
pnpm test:e2e -- --watch

# Run with specific project (if configured)
pnpm test:e2e -- --selectProjects app
pnpm test:e2e -- --selectProjects admin

Anti-Patterns to Avoid

1. Testing Implementation Details

typescript
// BAD: Testing internal state
const spy = jest.spyOn(repository, 'save');
await request.post('/products').send({ ... });
expect(spy).toHaveBeenCalledTimes(1);

// GOOD: Test external behavior
const response = await request.post('/products').send({ ... });
expect(response.status).toBe(201);

2. Hardcoded IDs

typescript
// BAD: Using hardcoded IDs
it('retrieves product', async () => {
  const response = await getRequest().get('/app/products/1');
  expect(response.status).toBe(200);
});

// GOOD: Use fixture-created data
it('retrieves product', async () => {
  const product = await fixtures.createProduct();
  const response = await getRequest().get(`/app/products/${product.id}`);
  expect(response.status).toBe(200);
});

3. Test Dependencies

typescript
// BAD: Tests depend on order
it('creates product', async () => { createdId = response.body.id; });
it('retrieves created product', async () => { get(`/products/${createdId}`); });

// GOOD: Each test is independent
it('retrieves product', async () => {
  const product = await fixtures.createProduct();
  const response = await getRequest().get(`/products/${product.id}`);
});

4. Vague Assertions

typescript
// BAD: Vague assertions
expect(response.body).toBeTruthy();
expect(response.body.data.length).toBeGreaterThan(0);

// GOOD: Specific assertions
expect(response.body.total).toBe(3);
expect(response.body.data).toHaveLength(3);
expect(response.body.data[0].name).toBe('Expected Name');

5. Missing Database Cleanup

typescript
// BAD: Data leaks between tests
beforeEach(async () => {
  // No cleanup
});

// GOOD: Clean slate for each test
beforeEach(async () => {
  await clearDatabase(getDb());
});

New Feature Development Flow

1. Understand Project Structure

bash
# Review existing test patterns
find . -name "*.e2e-spec.ts" | head -3 | xargs head -50

2. Create Test File Following Project Conventions

typescript
// test/{domain}.e2e-spec.ts
import { clearDatabase, TestFixtures } from './fixtures/test-fixtures';
import { getDb, getRequest } from './setup';

describe('{Domain} Integration', () => {
  let fixtures: TestFixtures;

  beforeAll(() => {
    fixtures = new TestFixtures(getDb());
  });

  beforeEach(async () => {
    await clearDatabase(getDb());
  });

  // Write test cases
});

3. Write Tests (They Will FAIL)

bash
pnpm test:e2e -- --testPathPattern="{domain}"
# Verify tests fail

4. Implement Clean Architecture Layers

Follow your project's directory structure for:

  • Domain Entity
  • Repository Interface
  • Domain Errors
  • Use Case
  • Repository Implementation
  • Mapper
  • Controller + DTOs
  • Module Registration

5. Verify Tests Pass

bash
pnpm test:e2e -- --testPathPattern="{domain}"
# All tests should pass

6. Run Lint and Full Suite

bash
pnpm lint
pnpm test:e2e

Test Checklist Before Commit

Writing

  • Does this test validate business value?
  • Is this already guaranteed by the framework?
  • Does this duplicate another test?

After Writing

  • Does the test name clearly describe success/failure?
  • Does it follow Arrange-Act-Assert pattern?
  • Is the verification scope appropriate?
  • Does clearDatabase run in beforeEach?
  • Are all test data created via fixtures?

Remember: No code without tests. Tests are the specification, documentation, and safety net. Write tests FIRST, then implement.