Copilot을 사용한 테스트 및 코드 품질
테스트는 유지 관리 가능한 프로젝트의 기초입니다. 그것은 단순한 안전망이 아니다 버그에 반대할 뿐만 아니라 실행 가능한 문서 코드가 어떻게 작동하는지 설명합니다. 행동해야 한다. GitHub Copilot은 테스트 작성 속도를 극적으로 향상시킬 수 있습니다. TDD에서 안내식 리팩토링까지, 지원 디버깅부터 엣지 케이스 적용까지.
이 기사에서는 각 수준에서 Copilot을 활용하는 방법을 살펴보겠습니다. 테스트 피라미드, 구체적인 프롬프트, 실제 사례 및 모범 사례 포함 강력하고 유지 관리 가능한 테스트를 얻으려면
📚 시리즈 개요
| # | Articolo | 집중하다 |
|---|---|---|
| 1 | 기초와 사고방식 | 설정과 사고방식 |
| 2 | 개념 및 요구사항 | 아이디어에서 MVP까지 |
| 3 | 백엔드 아키텍처 | API 및 데이터베이스 |
| 4 | 프런트엔드 구조 | UI 및 구성요소 |
| 5 | 신속한 엔지니어링 | MCP 프롬프트 및 에이전트 |
| 6 | 📍 현재 위치 → 테스트 및 품질 | 단위, 통합, E2E |
| 7 | 선적 서류 비치 | 읽어보기, 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 | 높은 | 베이스 | 내부 논리 |
| 완성 | ~100ms | 중간 | 중간 | 상호작용 |
| 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
극작가를 사용한 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이 구현을 제안하도록 합니다.
🔄 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는 통과하는 테스트를 생성할 수 있지만 실제로 중요한 동작을 테스트하지는 않습니다. 또는 행동보다는 구현에 너무 묶여 있습니다.
에서 다음 기사 Copilot을 사용하는 방법을 살펴보겠습니다. 전문 문서: 완전한 README, API 문서 OpenAPI, 아키텍처 결정 기록 및 JSDoc을 사용합니다.
🎯 기억해야 할 핵심 사항
- 피라미드: 70% 단위, 20% 통합, 10% E2E
- 단위: 완전한 격리, 모의 종속성
- 완성: 실제 DB로 API end-to-end 테스트
- E2E: 실제 사용자 행동 시뮬레이션
- TDD: 테스트를 먼저 작성하고 나중에 구현하세요.
- 적용 범위: 중요한 코드의 경우 80% 이상을 목표로 합니다.
- 비품: 재사용 가능한 데이터를 위한 팩토리 기능
- 확인하다: 생성된 테스트를 항상 확인하세요.







