Copilot によるテストとコード品質
テストは保守可能なプロジェクトの基礎です。単なるセーフティネットではありません バグに対してだけでなく、 実行可能ドキュメント コードがどのように実行されるかを説明します 行動すべきだ。 GitHub Copilot はテスト作成を劇的にスピードアップします。 TDD からガイド付きリファクタリング、デバッグ支援からエッジケースのカバレッジまで。
この記事では、各レベルで Copilot を活用する方法を検討します。 テストピラミッド、具体的なプロンプト、実用的な例、ベスト プラクティスを示します。 堅牢で保守可能なテストを実現します。
📚 シリーズ概要
| # | アイテム | 集中 |
|---|---|---|
| 1 | 基礎と考え方 | セットアップとメンタリティ |
| 2 | コンセプトと要件 | アイデアから MVP まで |
| 3 | バックエンドのアーキテクチャ | APIとデータベース |
| 4 | フロントエンドの構造 | UIとコンポーネント |
| 5 | 迅速なエンジニアリング | MCP プロンプトとエージェント |
| 6 | 📍 現在位置 → テストと品質 | ユニット、統合、E2E |
| 7 | ドキュメント | README、API ドキュメント、ADR |
| 8 | デプロイとDevOps | ドッカー、CI/CD |
| 9 | 進化 | スケーラビリティとメンテナンス |
テストピラミッド
テスト ピラミッドは、速度のバランスをとるための最適な戦略を定義します。 カバー範囲と保守性。 Copilot はあらゆるレベルでお手伝いします。
🔺 テストピラミッド
╱╲
╱ ╲
╱ E2E╲ ← Pochi (5-10%)
╱──────╲ Lenti, costosi, fragili
╱ ╲ Testano flussi utente completi
╱Integration╲ ← Moderati (15-25%)
╱────────────╲ Velocità media
╱ ╲ Testano interazioni tra componenti
╱ Unit Tests ╲ ← Molti (70-80%)
╱──────────────────╲ Veloci, isolati, stabili
╱ ╲ Testano singole unità
╱______________________╲
📊 レベル別の機能
| レベル | スピード | 絶縁 | 料金 | 自信 |
|---|---|---|---|---|
| ユニット | ~1ms | 高い | ベース | 内部ロジック |
| 統合 | ~100ミリ秒 | 中くらい | 中くらい | インタラクション |
| E2E | ~5秒 | ベース | 高い | 完全なシステム |
単体テスト: ピラミッドの基礎
単体テストでは、個々の関数またはクラスを完全に分離してテストします。 これらは高速で信頼性が高く、開発中に即座にフィードバックを提供します。
単体テストの生成を求めるプロンプト
Generate comprehensive unit tests for this service:
```typescript
[PASTE SERVICE CODE HERE]
```
TESTING REQUIREMENTS:
- Framework: Jest with TypeScript
- Coverage target: >90% for this critical service
- Mock ALL external dependencies (repositories, external services)
TEST CATEGORIES TO COVER:
1. Happy Path: Normal successful operations
2. Validation: Invalid inputs, edge cases
3. Error Handling: Expected errors are thrown correctly
4. Edge Cases: Empty arrays, null values, boundary conditions
5. State Changes: Verify side effects (calls to dependencies)
OUTPUT FORMAT:
- Complete test file with all imports
- Use describe/it blocks with clear descriptions
- Include beforeEach for setup
- Group tests by method
- Use AAA pattern (Arrange, Act, Assert)
- Add comments explaining non-obvious test cases
NAMING CONVENTION:
- Describe: "ServiceName"
- Nested describe: "methodName"
- It: "should [expected behavior] when [condition]"
完全な例: UserService 単体テスト
import {{ '{' }} UserService {{ '}' }} from './user.service';
import {{ '{' }} UserRepository {{ '}' }} from './user.repository';
import {{ '{' }} EmailService {{ '}' }} from '@shared/services/email.service';
import {{ '{' }} ValidationError, NotFoundError, ConflictError {{ '}' }} from '@shared/errors';
import {{ '{' }} createUserFixture, validUserDto {{ '}' }} from './fixtures/user.fixtures';
describe('UserService', () => {{ '{' }}
let service: UserService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {{ '{' }}
// Create fresh mocks for each test
mockUserRepository = {{ '{' }}
findById: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
findAll: jest.fn(),
{{ '}' }} as any;
mockEmailService = {{ '{' }}
sendWelcomeEmail: jest.fn(),
sendPasswordResetEmail: jest.fn(),
{{ '}' }} as any;
service = new UserService(mockUserRepository, mockEmailService);
{{ '}' }});
afterEach(() => {{ '{' }}
jest.clearAllMocks();
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// CREATE USER
// ═══════════════════════════════════════════════════════════
describe('createUser', () => {{ '{' }}
describe('happy path', () => {{ '{' }}
it('should create user and send welcome email when valid data provided', async () => {{ '{' }}
// Arrange
const dto = validUserDto;
const expectedUser = createUserFixture({{ '{' }} id: 'user-123', ...dto {{ '}' }});
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.create.mockResolvedValue(expectedUser);
mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined);
// Act
const result = await service.createUser(dto);
// Assert
expect(result.id).toBe('user-123');
expect(result.email).toBe(dto.email);
expect(mockUserRepository.create).toHaveBeenCalledWith(
expect.objectContaining({{ '{' }}
name: dto.name,
email: dto.email,
// Password should be hashed, not plain text
password: expect.not.stringContaining(dto.password),
{{ '}' }})
);
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
expectedUser.email,
expectedUser.name
);
{{ '}' }});
it('should hash password before saving', async () => {{ '{' }}
// Arrange
const dto = {{ '{' }} ...validUserDto, password: 'PlainPassword123!' {{ '}' }};
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.create.mockResolvedValue(createUserFixture());
// Act
await service.createUser(dto);
// Assert
const createCall = mockUserRepository.create.mock.calls[0][0];
expect(createCall.password).not.toBe('PlainPassword123!');
expect(createCall.password.length).toBeGreaterThan(50); // bcrypt hash length
{{ '}' }});
{{ '}' }});
describe('validation errors', () => {{ '{' }}
it('should throw ConflictError when email already exists', async () => {{ '{' }}
// Arrange
const existingUser = createUserFixture({{ '{' }} email: 'exists@test.com' {{ '}' }});
mockUserRepository.findByEmail.mockResolvedValue(existingUser);
// Act & Assert
await expect(
service.createUser({{ '{' }} ...validUserDto, email: 'exists@test.com' {{ '}' }})
).rejects.toThrow(ConflictError);
expect(mockUserRepository.create).not.toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
{{ '}' }});
it('should throw ValidationError when password is too weak', async () => {{ '{' }}
// Arrange
mockUserRepository.findByEmail.mockResolvedValue(null);
// Act & Assert
await expect(
service.createUser({{ '{' }} ...validUserDto, password: '123' {{ '}' }})
).rejects.toThrow(ValidationError);
{{ '}' }});
it('should throw ValidationError when email format is invalid', async () => {{ '{' }}
await expect(
service.createUser({{ '{' }} ...validUserDto, email: 'not-an-email' {{ '}' }})
).rejects.toThrow(ValidationError);
{{ '}' }});
{{ '}' }});
describe('edge cases', () => {{ '{' }}
it('should trim whitespace from email', async () => {{ '{' }}
// Arrange
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.create.mockResolvedValue(createUserFixture());
// Act
await service.createUser({{ '{' }}
...validUserDto,
email: ' user@test.com '
{{ '}' }});
// Assert
expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('user@test.com');
{{ '}' }});
it('should normalize email to lowercase', async () => {{ '{' }}
// Arrange
mockUserRepository.findByEmail.mockResolvedValue(null);
mockUserRepository.create.mockResolvedValue(createUserFixture());
// Act
await service.createUser({{ '{' }}
...validUserDto,
email: 'User@TEST.com'
{{ '}' }});
// Assert
expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('user@test.com');
{{ '}' }});
{{ '}' }});
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// GET USER BY ID
// ═══════════════════════════════════════════════════════════
describe('getUserById', () => {{ '{' }}
it('should return user when found', async () => {{ '{' }}
// Arrange
const user = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
mockUserRepository.findById.mockResolvedValue(user);
// Act
const result = await service.getUserById('user-123');
// Assert
expect(result).toEqual(user);
expect(mockUserRepository.findById).toHaveBeenCalledWith('user-123');
{{ '}' }});
it('should throw NotFoundError when user does not exist', async () => {{ '{' }}
// Arrange
mockUserRepository.findById.mockResolvedValue(null);
// Act & Assert
await expect(service.getUserById('non-existent'))
.rejects.toThrow(NotFoundError);
{{ '}' }});
it('should throw ValidationError when id is empty', async () => {{ '{' }}
await expect(service.getUserById('')).rejects.toThrow(ValidationError);
{{ '}' }});
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// UPDATE USER
// ═══════════════════════════════════════════════════════════
describe('updateUser', () => {{ '{' }}
it('should update user fields', async () => {{ '{' }}
// Arrange
const existingUser = createUserFixture({{ '{' }} id: 'user-123', name: 'Old Name' {{ '}' }});
const updatedUser = {{ '{' }} ...existingUser, name: 'New Name' {{ '}' }};
mockUserRepository.findById.mockResolvedValue(existingUser);
mockUserRepository.update.mockResolvedValue(updatedUser);
// Act
const result = await service.updateUser('user-123', {{ '{' }} name: 'New Name' {{ '}' }});
// Assert
expect(result.name).toBe('New Name');
expect(mockUserRepository.update).toHaveBeenCalledWith('user-123', {{ '{' }} name: 'New Name' {{ '}' }});
{{ '}' }});
it('should throw ConflictError when updating to existing email', async () => {{ '{' }}
// Arrange
const existingUser = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
const otherUser = createUserFixture({{ '{' }} id: 'user-456', email: 'taken@test.com' {{ '}' }});
mockUserRepository.findById.mockResolvedValue(existingUser);
mockUserRepository.findByEmail.mockResolvedValue(otherUser);
// Act & Assert
await expect(
service.updateUser('user-123', {{ '{' }} email: 'taken@test.com' {{ '}' }})
).rejects.toThrow(ConflictError);
{{ '}' }});
it('should allow updating to same email (no change)', async () => {{ '{' }}
// Arrange
const existingUser = createUserFixture({{ '{' }} id: 'user-123', email: 'same@test.com' {{ '}' }});
mockUserRepository.findById.mockResolvedValue(existingUser);
mockUserRepository.findByEmail.mockResolvedValue(existingUser); // Same user
mockUserRepository.update.mockResolvedValue(existingUser);
// Act & Assert - should not throw
await expect(
service.updateUser('user-123', {{ '{' }} email: 'same@test.com' {{ '}' }})
).resolves.not.toThrow();
{{ '}' }});
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// SOFT DELETE USER
// ═══════════════════════════════════════════════════════════
describe('softDeleteUser', () => {{ '{' }}
it('should soft delete existing user', async () => {{ '{' }}
// Arrange
const user = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
mockUserRepository.findById.mockResolvedValue(user);
mockUserRepository.softDelete.mockResolvedValue(undefined);
// Act
await service.softDeleteUser('user-123');
// Assert
expect(mockUserRepository.softDelete).toHaveBeenCalledWith('user-123');
{{ '}' }});
it('should throw NotFoundError when user does not exist', async () => {{ '{' }}
// Arrange
mockUserRepository.findById.mockResolvedValue(null);
// Act & Assert
await expect(service.softDeleteUser('non-existent'))
.rejects.toThrow(NotFoundError);
expect(mockUserRepository.softDelete).not.toHaveBeenCalled();
{{ '}' }});
{{ '}' }});
{{ '}' }});
統合テスト: インタラクションのテスト
統合テストでは、コンポーネントが正しく連携して動作することを検証します。 ルーティング、ミドルウェア、検証、データベース アクセスを含む完全な API をテストします。
統合テストのプロンプト
Generate integration tests for this API:
ENDPOINTS:
- POST /api/users → Create user (requires auth)
- GET /api/users/:id → Get user by ID
- PATCH /api/users/:id → Update user (owner only)
- DELETE /api/users/:id → Soft delete (admin only)
TESTING REQUIREMENTS:
- Framework: Jest + Supertest
- Database: Test PostgreSQL (use transactions, rollback after each test)
- Auth: JWT tokens (mock or real test tokens)
TEST SCENARIOS FOR EACH ENDPOINT:
1. Success case with valid request
2. Validation errors (400) - invalid body, missing fields
3. Authentication errors (401) - missing/invalid token
4. Authorization errors (403) - wrong role/permissions
5. Not found errors (404) - resource doesn't exist
6. Conflict errors (409) - duplicate resources
SETUP/TEARDOWN:
- beforeAll: Create test database, run migrations
- beforeEach: Start transaction
- afterEach: Rollback transaction
- afterAll: Close database connection
Include helper functions for:
- Creating authenticated requests
- Generating test users
- Cleaning up test data
完全な例: ユーザー API 統合テスト
import request from 'supertest';
import {{ '{' }} app {{ '}' }} from '../app';
import {{ '{' }} db {{ '}' }} from '../database';
import {{ '{' }} createTestUser, getAuthToken, cleanupTestData {{ '}' }} from './helpers';
import {{ '{' }} UserRole {{ '}' }} from '@shared/types';
describe('Users API Integration Tests', () => {{ '{' }}
let adminToken: string;
let userToken: string;
let testUserId: string;
beforeAll(async () => {{ '{' }}
await db.connect();
await db.migrate();
// Create test users and get tokens
const admin = await createTestUser({{ '{' }} role: UserRole.ADMIN {{ '}' }});
const user = await createTestUser({{ '{' }} role: UserRole.USER {{ '}' }});
adminToken = await getAuthToken(admin);
userToken = await getAuthToken(user);
testUserId = user.id;
{{ '}' }});
afterAll(async () => {{ '{' }}
await cleanupTestData();
await db.disconnect();
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// POST /api/users - Create User
// ═══════════════════════════════════════════════════════════
describe('POST /api/users', () => {{ '{' }}
const validPayload = {{ '{' }}
name: 'Integration Test User',
email: 'integration@test.com',
password: 'SecurePass123!',
{{ '}' }};
describe('success cases', () => {{ '{' }}
it('should create user with valid data (201)', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }} ...validPayload, email: `unique-${{ '{' }}Date.now(){{ '}' }}@test.com` {{ '}' }});
expect(response.status).toBe(201);
expect(response.body.data).toMatchObject({{ '{' }}
name: validPayload.name,
email: expect.stringContaining('@test.com'),
{{ '}' }});
expect(response.body.data.password).toBeUndefined(); // Never return password
expect(response.body.data.id).toBeDefined();
{{ '}' }});
{{ '}' }});
describe('validation errors (400)', () => {{ '{' }}
it('should return 400 when email is invalid', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }} ...validPayload, email: 'not-an-email' {{ '}' }});
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.details).toContainEqual(
expect.objectContaining({{ '{' }} field: 'email' {{ '}' }})
);
{{ '}' }});
it('should return 400 when password is too weak', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }} ...validPayload, password: '123' {{ '}' }});
expect(response.status).toBe(400);
expect(response.body.error.details).toContainEqual(
expect.objectContaining({{ '{' }} field: 'password' {{ '}' }})
);
{{ '}' }});
it('should return 400 when required fields are missing', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }}{{ '}' }}); // Empty body
expect(response.status).toBe(400);
{{ '}' }});
{{ '}' }});
describe('authentication errors (401)', () => {{ '{' }}
it('should return 401 without auth token', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.send(validPayload);
expect(response.status).toBe(401);
expect(response.body.error.code).toBe('UNAUTHORIZED');
{{ '}' }});
it('should return 401 with invalid token', async () => {{ '{' }}
const response = await request(app)
.post('/api/users')
.set('Authorization', 'Bearer invalid-token')
.send(validPayload);
expect(response.status).toBe(401);
{{ '}' }});
it('should return 401 with expired token', async () => {{ '{' }}
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}expiredToken{{ '}' }}`)
.send(validPayload);
expect(response.status).toBe(401);
{{ '}' }});
{{ '}' }});
describe('conflict errors (409)', () => {{ '{' }}
it('should return 409 when email already exists', async () => {{ '{' }}
// First, create a user
const email = `conflict-${{ '{' }}Date.now(){{ '}' }}@test.com`;
await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }} ...validPayload, email {{ '}' }});
// Try to create with same email
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
.send({{ '{' }} ...validPayload, email {{ '}' }});
expect(response.status).toBe(409);
expect(response.body.error.code).toBe('CONFLICT');
{{ '}' }});
{{ '}' }});
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// GET /api/users/:id - Get User by ID
// ═══════════════════════════════════════════════════════════
describe('GET /api/users/:id', () => {{ '{' }}
it('should return user when found (200)', async () => {{ '{' }}
const response = await request(app)
.get(`/api/users/${{ '{' }}testUserId{{ '}' }}`)
.set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);
expect(response.status).toBe(200);
expect(response.body.data.id).toBe(testUserId);
expect(response.body.data.password).toBeUndefined();
{{ '}' }});
it('should return 404 when user not found', async () => {{ '{' }}
const response = await request(app)
.get('/api/users/non-existent-id')
.set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);
expect(response.status).toBe(404);
expect(response.body.error.code).toBe('NOT_FOUND');
{{ '}' }});
it('should return 400 for invalid UUID format', async () => {{ '{' }}
const response = await request(app)
.get('/api/users/not-a-uuid')
.set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);
expect(response.status).toBe(400);
{{ '}' }});
{{ '}' }});
// ═══════════════════════════════════════════════════════════
// DELETE /api/users/:id - Soft Delete (Admin Only)
// ═══════════════════════════════════════════════════════════
describe('DELETE /api/users/:id', () => {{ '{' }}
it('should soft delete user when admin (204)', async () => {{ '{' }}
// Create user to delete
const userToDelete = await createTestUser();
const response = await request(app)
.delete(`/api/users/${{ '{' }}userToDelete.id{{ '}' }}`)
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`);
expect(response.status).toBe(204);
// Verify user is soft deleted (can't fetch)
const getResponse = await request(app)
.get(`/api/users/${{ '{' }}userToDelete.id{{ '}' }}`)
.set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`);
expect(getResponse.status).toBe(404);
{{ '}' }});
it('should return 403 when non-admin tries to delete', async () => {{ '{' }}
const response = await request(app)
.delete(`/api/users/${{ '{' }}testUserId{{ '}' }}`)
.set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);
expect(response.status).toBe(403);
expect(response.body.error.code).toBe('FORBIDDEN');
{{ '}' }});
{{ '}' }});
{{ '}' }});
E2E テスト: 完全なユーザー フロー
エンドツーエンドのテストは、テストによって実際のユーザーの行動をシミュレートします。 ブラウザからデータベースまでのアプリケーション全体。
E2E テストのプロンプト
Generate E2E tests for user registration and login flow:
FLOW TO TEST:
1. User visits homepage
2. Clicks "Sign Up" button
3. Fills registration form (name, email, password)
4. Submits form
5. Sees success message
6. Is redirected to dashboard
7. Can log out and log back in
FRAMEWORK: Playwright with TypeScript
TEST SCENARIOS:
1. Happy path: Complete registration and login
2. Validation: Form shows errors for invalid input
3. Duplicate email: Shows appropriate error
4. Login with wrong password: Shows error
5. Remember me: Session persists after browser close
INCLUDE:
- Page Object Model for reusable selectors
- Helper functions for common actions
- Screenshot on failure
- Retry logic for flaky network requests
Playwright を使用した E2E の例
import {{ '{' }} test, expect {{ '}' }} from '@playwright/test';
import {{ '{' }} LoginPage {{ '}' }} from './pages/login.page';
import {{ '{' }} RegisterPage {{ '}' }} from './pages/register.page';
import {{ '{' }} DashboardPage {{ '}' }} from './pages/dashboard.page';
import {{ '{' }} generateTestEmail {{ '}' }} from './helpers';
test.describe('User Authentication Flow', () => {{ '{' }}
test.describe('Registration', () => {{ '{' }}
test('should complete registration successfully', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
const registerPage = new RegisterPage(page);
const dashboardPage = new DashboardPage(page);
const testEmail = generateTestEmail();
// Navigate to registration
await registerPage.goto();
await expect(page).toHaveTitle(/Sign Up/);
// Fill form
await registerPage.fillName('Test User');
await registerPage.fillEmail(testEmail);
await registerPage.fillPassword('SecurePass123!');
await registerPage.fillConfirmPassword('SecurePass123!');
// Submit and verify
await registerPage.submit();
// Should redirect to dashboard
await expect(page).toHaveURL(/\/dashboard/);
await expect(dashboardPage.welcomeMessage).toContainText('Welcome, Test User');
{{ '}' }});
test('should show validation errors for invalid input', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
const registerPage = new RegisterPage(page);
await registerPage.goto();
// Submit empty form
await registerPage.submit();
// Check validation messages
await expect(registerPage.nameError).toBeVisible();
await expect(registerPage.emailError).toBeVisible();
await expect(registerPage.passwordError).toBeVisible();
{{ '}' }});
test('should show error for duplicate email', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.fillName('Duplicate User');
await registerPage.fillEmail('existing@test.com'); // Pre-seeded
await registerPage.fillPassword('SecurePass123!');
await registerPage.fillConfirmPassword('SecurePass123!');
await registerPage.submit();
await expect(registerPage.formError).toContainText('Email already registered');
{{ '}' }});
{{ '}' }});
test.describe('Login', () => {{ '{' }}
test('should login with valid credentials', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.fillEmail('test@example.com');
await loginPage.fillPassword('TestPass123!');
await loginPage.submit();
await expect(page).toHaveURL(/\/dashboard/);
await expect(dashboardPage.userMenu).toBeVisible();
{{ '}' }});
test('should show error for invalid credentials', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillEmail('test@example.com');
await loginPage.fillPassword('WrongPassword');
await loginPage.submit();
await expect(loginPage.errorMessage).toContainText('Invalid credentials');
await expect(page).toHaveURL(/\/login/); // Still on login page
{{ '}' }});
{{ '}' }});
test.describe('Session Management', () => {{ '{' }}
test('should persist session with remember me', async ({{ '{' }} page, context {{ '}' }}) => {{ '{' }}
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillEmail('test@example.com');
await loginPage.fillPassword('TestPass123!');
await loginPage.checkRememberMe();
await loginPage.submit();
// Close and reopen browser
await context.clearCookies();
const cookies = await context.cookies();
await context.addCookies(cookies);
// Navigate to protected page
await page.goto('/dashboard');
// Should still be logged in
await expect(page).toHaveURL(/\/dashboard/);
{{ '}' }});
{{ '}' }});
{{ '}' }});
Copilot を使用した TDD ワークフロー
テスト駆動開発 (TDD) は、Copilot を使用するとさらに効果的に機能します。 最初にテストを作成してから、Copilot に実装を提案させます。
🔄 副操縦士による TDD
| ステップ | Tu | 副操縦士 |
|---|---|---|
| 1.レッド | 失敗する書き込みテスト | テストケースを提案します |
| 2.グリーン | 実装を依頼する | 最小限のコードを生成する |
| 3. リファクタリング | 改善を求める | リファクタリングを提案する |
| 4.リピート | 新しいテストを追加 | エッジケースを提案する |
I'm following TDD. Here's my failing test:
```typescript
describe('calculateDiscount', () => {{ '{' }}
it('should apply 10% discount for orders over $100', () => {{ '{' }}
expect(calculateDiscount(150)).toBe(15);
{{ '}' }});
it('should apply 20% discount for orders over $500', () => {{ '{' }}
expect(calculateDiscount(600)).toBe(120);
{{ '}' }});
it('should return 0 for orders under $100', () => {{ '{' }}
expect(calculateDiscount(50)).toBe(0);
{{ '}' }});
{{ '}' }});
```
1. Implement the MINIMUM code to make these tests pass
2. Don't add features not covered by tests
3. After implementation, suggest additional edge cases I should test
テストカバレッジとギャップ分析
Copilot を使用して、発見されていないコードを特定し、欠落しているテストを生成します。
My coverage report shows uncovered lines:
FILE: src/services/order.service.ts
UNCOVERED LINES: 45-52, 78-85, 120-135
Here's the code at those lines:
```typescript
// Lines 45-52
if (order.status === 'cancelled') {{ '{' }}
throw new ValidationError('Cannot modify cancelled order');
{{ '}' }}
// Lines 78-85
if (items.some(item => item.quantity > item.stock)) {{ '{' }}
const outOfStock = items.filter(i => i.quantity > i.stock);
throw new ValidationError(`Insufficient stock for: ${{ '{' }}outOfStock.map(i => i.name).join(', '){{ '}' }}`);
{{ '}' }}
// Lines 120-135
if (coupon && coupon.expiresAt < new Date()) {{ '{' }}
throw new ValidationError('Coupon has expired');
{{ '}' }}
if (coupon && coupon.usageCount >= coupon.maxUsage) {{ '{' }}
throw new ValidationError('Coupon usage limit reached');
{{ '}' }}
```
Generate unit tests to cover these scenarios. Include:
1. Test for each branch
2. Boundary conditions
3. Descriptive test names
テスト治具と工場
適切に構造化されたフィクスチャにより、テストがより読みやすく、保守しやすくなります。
// fixtures/user.fixtures.ts
import {{ '{' }} User, UserRole, UserStatus {{ '}' }} from '@/entities/user.entity';
import {{ '{' }} CreateUserDto {{ '}' }} from '@/dto/create-user.dto';
// ═══════════════════════════════════════════════════════════
// BASE FIXTURES - Valid default values
// ═══════════════════════════════════════════════════════════
export const validUserDto: CreateUserDto = {{ '{' }}
name: 'Test User',
email: 'test@example.com',
password: 'SecurePassword123!',
{{ '}' }};
export const validUser: Partial<User> = {{ '{' }}
id: 'user-123',
name: 'Test User',
email: 'test@example.com',
role: UserRole.USER,
status: UserStatus.ACTIVE,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
{{ '}' }};
// ═══════════════════════════════════════════════════════════
// FACTORY FUNCTION - Create users with overrides
// ═══════════════════════════════════════════════════════════
let userCounter = 0;
export function createUserFixture(overrides: Partial<User> = {{ '{' }}{{ '}' }}): User {{ '{' }}
userCounter++;
return {{ '{' }}
id: `user-${{ '{' }}userCounter{{ '}' }}`,
name: 'Test User',
email: `test-${{ '{' }}userCounter{{ '}' }}@example.com`,
password: '$2b$10$hashedpassword', // Pre-hashed for tests
role: UserRole.USER,
status: UserStatus.ACTIVE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
{{ '}' }} as User;
{{ '}' }}
// ═══════════════════════════════════════════════════════════
// INVALID FIXTURES - For testing validation
// ═══════════════════════════════════════════════════════════
export const invalidUserDtos = {{ '{' }}
missingName: {{ '{' }}
email: 'test@example.com',
password: 'SecurePassword123!',
{{ '}' }},
missingEmail: {{ '{' }}
name: 'Test User',
password: 'SecurePassword123!',
{{ '}' }},
invalidEmail: {{ '{' }}
name: 'Test User',
email: 'not-an-email',
password: 'SecurePassword123!',
{{ '}' }},
weakPassword: {{ '{' }}
name: 'Test User',
email: 'test@example.com',
password: '123',
{{ '}' }},
emptyName: {{ '{' }}
name: '',
email: 'test@example.com',
password: 'SecurePassword123!',
{{ '}' }},
{{ '}' }};
// ═══════════════════════════════════════════════════════════
// SCENARIO FIXTURES - Specific test scenarios
// ═══════════════════════════════════════════════════════════
export const userScenarios = {{ '{' }}
admin: () => createUserFixture({{ '{' }} role: UserRole.ADMIN {{ '}' }}),
inactive: () => createUserFixture({{ '{' }} status: UserStatus.INACTIVE {{ '}' }}),
deleted: () => createUserFixture({{ '{' }}
status: UserStatus.DELETED,
deletedAt: new Date(),
{{ '}' }}),
premium: () => createUserFixture({{ '{' }}
role: UserRole.PREMIUM,
premiumExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
{{ '}' }}),
{{ '}' }};
// ═══════════════════════════════════════════════════════════
// HELPERS - Reset counter for test isolation
// ═══════════════════════════════════════════════════════════
export function resetUserFixtures(): void {{ '{' }}
userCounter = 0;
{{ '}' }}
テストガイド付きリファクタリング
既存のテストを使用すると、安全にリファクタリングできます。 Copilot は動作を維持しながら改善を提案できます。
I want to refactor this code. I have tests that cover its behavior:
CURRENT CODE:
```typescript
[PASTE CODE]
```
EXISTING TESTS (passing):
```typescript
[PASTE TESTS]
```
REFACTORING GOALS:
1. Reduce cyclomatic complexity
2. Extract reusable functions
3. Improve naming
CONSTRAINTS:
- All existing tests MUST still pass
- Don't change public interface
- No new dependencies
Provide:
1. Refactored code
2. Explanation of each change
3. Any new tests needed for extracted functions
テストによるデバッグ支援
This test is failing and I can't figure out why:
TEST:
```typescript
it('should apply discount to order total', async () => {{ '{' }}
const order = await orderService.createOrder({{ '{' }}
items: [{{ '{' }} productId: 'prod-1', quantity: 2 {{ '}' }}],
couponCode: 'SAVE10',
{{ '}' }});
expect(order.total).toBe(90); // Expected $90 after 10% discount
expect(order.discount).toBe(10);
{{ '}' }});
```
ERROR:
```
Expected: 90
Received: 100
```
IMPLEMENTATION:
```typescript
async createOrder(dto: CreateOrderDto): Promise<Order> {{ '{' }}
const items = await this.getOrderItems(dto.items);
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
let discount = 0;
if (dto.couponCode) {{ '{' }}
const coupon = await this.couponRepository.findByCode(dto.couponCode);
if (coupon) {{ '{' }}
discount = subtotal * (coupon.percentage / 100);
{{ '}' }}
{{ '}' }}
return this.orderRepository.create({{ '{' }}
items,
subtotal,
discount,
total: subtotal - discount,
{{ '}' }});
{{ '}' }}
```
Help me:
1. Identify why the test is failing
2. Determine if it's a test bug or implementation bug
3. Provide the fix
テストのベストプラクティス
❌ アンチパターン
- 順序依存のテスト
- テスト間で共有される状態
- 曖昧な名前: 「機能するはずです」
- 複数の無関係なアサーション
- テストが細かすぎる
- 過剰なモック(モックのテスト)
- 理由もなくテストが遅い
- 不安定なテストを無視する
✅ ベストプラクティス
- 独立した個別のテスト
- テストごとに新しいセットアップを行う
- フレンドリ名: 「ユーザーが見つからない場合は 404 を返す必要があります」
- テスト用の論理ステートメント
- 実装ではなく動作をテストする
- 外部依存関係のみをモックする
- 共有設定を最適化する
- 不安定なテストを修正または削除する
テスト品質チェックリスト
✅ 統合前
- ☐ すべてのテストはローカルで合格します
- ☐ 重要なコードのカバレッジ >= 80%
- ☐ 不安定なテストはありません (3 回実行)
- ☐ テスト名はわかりやすいものです
- ☐ エッジケースはカバーされます
- ☐ エラーパスがテストされる
- ☐ テストでは console.log がありません
- ☐ 器具は清潔で再利用可能です
- ☐ モックは現実的です
- ☐ テストは高速です (ユニット合計 10 秒未満)
結論と次のステップ
Copilot が真価を発揮するのはテストです。完全なテストを最初から生成できます 既存のコードから、考慮していなかったエッジケースを提案し、 開発速度を犠牲にすることなく、高いカバレッジを維持するのに役立ちます。
覚えておいてください: Copilot が生成したテストは常に次のようにする必要があります。 検証済み。 AI は合格するテストを生成できますが、実際には重要な動作をテストしません。 あるいは、動作ではなく実装に結びつきすぎているということです。
Nel 次の記事 Copilot の使用方法を見ていきます。 専門的な文書: 完全な README、API ドキュメント OpenAPI、アーキテクチャ決定記録、および JSDoc を使用します。
🎯 覚えておくべき重要なポイント
- ピラミッド: 70% ユニット、20% 統合、10% E2E
- 単位: 完全な分離、疑似依存関係
- 統合: 実際の DB を使用して API をエンドツーエンドでテストする
- E2E: 実際のユーザーの行動をシミュレートする
- TDD: 最初にテストを作成し、後で実装する
- カバレッジ: クリティカルなコードでは 80% 以上を目指す
- 備品: 再利用可能なデータのためのファクトリー関数
- 確認する: 生成されたテストを常に確認する







