API 서버 테스트 작성
서버 코드도 테스트가 필요합니다. HTTP 요청을 보내고 응답을 검증하는 테스트를 작성합니다. dart_frog는 테스트용 유틸리티를 제공합니다. 인메모리 SQLite로 데이터베이스를 격리합니다.
mocktail — dart_frog 테스트에서 권장
dart_frog의 공식 문서는 mocktail 패키지를 권장합니다. mockito와 비슷하지만 코드 생성이 필요 없습니다.
# 수정: pubspec.yaml (dev_dependencies)dev_dependencies: lints: ^3.0.0 test: ^1.24.0 mocktail: ^1.0.3 dart_frog_test: ^0.1.2
dart pub get
단위 테스트 — AuthService
외부 의존성이 없는 AuthService 로직을 먼저 테스트합니다.
// 새 파일: test/services/auth_service_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'package:todo_server/src/dto/auth_dto.dart';
import 'package:todo_server/src/exceptions.dart';
import 'package:todo_server/src/models/user.dart';
import 'package:todo_server/src/repositories/user_repository.dart';
import 'package:todo_server/src/services/auth_service.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late MockUserRepository mockRepo;
late AuthService authService;
const testSecret = 'test-secret-key-must-be-at-least-32-characters-long';
setUp(() {
mockRepo = MockUserRepository();
authService = AuthService(
userRepository: mockRepo,
jwtSecret: testSecret,
);
});
User makeUser({int id = 1, String email = '[email protected]'}) {
return User(
id: id,
email: email,
passwordHash: authService.hashPasswordForTest('password123'),
createdAt: DateTime(2024),
);
}
group('register', () {
test('새 사용자 등록 성공', () {
final user = makeUser();
when(() => mockRepo.findByEmail(any())).thenReturn(null);
when(() => mockRepo.create(
email: any(named: 'email'),
passwordHash: any(named: 'passwordHash'),
)).thenReturn(user);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'password123',
});
final result = authService.register(request);
expect(result.token, isNotEmpty);
expect(result.user['email'], equals('[email protected]'));
});
test('이미 존재하는 이메일 → ValidationException', () {
final user = makeUser();
when(() => mockRepo.findByEmail(any())).thenReturn(user);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'password123',
});
expect(
() => authService.register(request),
throwsA(isA<ValidationException>()),
);
});
});
group('login', () {
test('올바른 자격증명으로 로그인 성공', () {
final user = makeUser();
when(() => mockRepo.findByEmail(any())).thenReturn(user);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'password123',
});
final result = authService.login(request);
expect(result.token, isNotEmpty);
});
test('존재하지 않는 이메일 → UnauthorizedException', () {
when(() => mockRepo.findByEmail(any())).thenReturn(null);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'password123',
});
expect(
() => authService.login(request),
throwsA(isA<UnauthorizedException>()),
);
});
test('잘못된 비밀번호 → UnauthorizedException', () {
final user = makeUser();
when(() => mockRepo.findByEmail(any())).thenReturn(user);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'wrongpassword',
});
expect(
() => authService.login(request),
throwsA(isA<UnauthorizedException>()),
);
});
});
group('verifyToken', () {
test('유효한 토큰 검증 성공', () {
final user = makeUser();
when(() => mockRepo.findByEmail(any())).thenReturn(null);
when(() => mockRepo.create(
email: any(named: 'email'),
passwordHash: any(named: 'passwordHash'),
)).thenReturn(user);
final request = AuthRequest.fromJson({
'email': '[email protected]',
'password': 'password123',
});
final authResponse = authService.register(request);
final userId = authService.verifyToken(authResponse.token);
expect(userId, equals(1));
});
test('잘못된 토큰 → UnauthorizedException', () {
expect(
() => authService.verifyToken('invalid.token'),
throwsA(isA<UnauthorizedException>()),
);
});
});
}
AuthService에 테스트용 헬퍼를 추가합니다.
// 수정: lib/src/services/auth_service.dart
// (hashPasswordForTest 메서드 추가)
/// 테스트 전용: 비밀번호를 해시합니다.
String hashPasswordForTest(String password) => _hashPassword(password);
통합 테스트 — 인메모리 DB
실제 Repository와 인메모리 SQLite를 함께 사용합니다.
// 새 파일: test/integration/todo_repository_test.dart
import 'package:test/test.dart';
import 'package:todo_server/src/database.dart';
import 'package:todo_server/src/exceptions.dart';
import 'package:todo_server/src/repositories/todo_repository.dart';
import 'package:todo_server/src/repositories/user_repository.dart';
void main() {
late Database db;
late TodoRepository todoRepo;
late UserRepository userRepo;
late int testUserId;
setUp(() {
// 인메모리 DB 사용 (각 테스트마다 새로 생성)
db = Database.openInMemory();
todoRepo = TodoRepository(db);
userRepo = UserRepository(db);
// 테스트 사용자 생성
final user = userRepo.create(
email: '[email protected]',
passwordHash: 'hash',
);
testUserId = user.id;
});
tearDown(() {
db.close();
});
group('create', () {
test('할 일 생성 성공', () {
final todo = todoRepo.create(title: '테스트 할 일', userId: testUserId);
expect(todo.id, greaterThan(0));
expect(todo.title, equals('테스트 할 일'));
expect(todo.completed, isFalse);
expect(todo.userId, equals(testUserId));
});
});
group('findByUserId', () {
test('사용자 할 일 목록 조회', () {
todoRepo.create(title: '할 일 1', userId: testUserId);
todoRepo.create(title: '할 일 2', userId: testUserId);
final todos = todoRepo.findByUserId(testUserId);
expect(todos, hasLength(2));
});
test('완료 필터링', () {
todoRepo.create(title: '미완료', userId: testUserId);
final todo2 = todoRepo.create(title: '완료', userId: testUserId);
todoRepo.update(id: todo2.id, completed: true);
final incomplete = todoRepo.findByUserId(testUserId, completed: false);
expect(incomplete, hasLength(1));
expect(incomplete.first.title, equals('미완료'));
});
test('다른 사용자 할 일 미포함', () {
final other = userRepo.create(email: '[email protected]', passwordHash: 'h');
todoRepo.create(title: '내 할 일', userId: testUserId);
todoRepo.create(title: '다른 사람 할 일', userId: other.id);
final myTodos = todoRepo.findByUserId(testUserId);
expect(myTodos, hasLength(1));
expect(myTodos.first.title, equals('내 할 일'));
});
});
group('update', () {
test('제목 수정', () {
final todo = todoRepo.create(title: '원래 제목', userId: testUserId);
final updated = todoRepo.update(id: todo.id, title: '새 제목');
expect(updated.title, equals('새 제목'));
expect(updated.updatedAt, isNotNull);
});
test('완료 상태 변경', () {
final todo = todoRepo.create(title: '할 일', userId: testUserId);
final updated = todoRepo.update(id: todo.id, completed: true);
expect(updated.completed, isTrue);
});
test('존재하지 않는 ID → NotFoundException', () {
expect(
() => todoRepo.update(id: 9999, title: '없는 할 일'),
throwsA(isA<NotFoundException>()),
);
});
});
group('delete', () {
test('할 일 삭제', () {
final todo = todoRepo.create(title: '삭제할 할 일', userId: testUserId);
todoRepo.delete(todo.id);
final found = todoRepo.findById(todo.id);
expect(found, isNull);
});
test('존재하지 않는 ID → NotFoundException', () {
expect(
() => todoRepo.delete(9999),
throwsA(isA<NotFoundException>()),
);
});
});
}
HTTP 라우트 핸들러 테스트
dart_frog_test를 사용해 핸들러를 직접 테스트합니다. 서버를 실제로 실행하지 않아도 됩니다.
// 새 파일: test/routes/todos_test.dart
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:dart_frog_test/dart_frog_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'package:todo_server/src/models/todo.dart';
import 'package:todo_server/src/repositories/todo_repository.dart';
// routes/todos/index.dart의 onRequest 함수 직접 임포트
import '../../routes/todos/index.dart' as route;
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
late MockTodoRepository mockRepo;
setUp(() {
mockRepo = MockTodoRepository();
});
Todo makeTodo({int id = 1, String title = '할 일', bool completed = false}) {
return Todo(
id: id,
title: title,
completed: completed,
userId: 1,
createdAt: DateTime(2024),
);
}
RequestContext makeContext({
HttpMethod method = HttpMethod.get,
Map<String, String>? queryParams,
dynamic body,
}) {
return buildRequestContext(
uri: Uri.parse(
'http://localhost/todos${_buildQuery(queryParams)}',
),
method: method,
body: body != null ? jsonEncode(body) : null,
headers: body != null ? {'Content-Type': 'application/json'} : null,
).provide<TodoRepository>(() => mockRepo)
.provide<int>(() => 1); // userId
}
group('GET /todos', () {
test('빈 목록 반환', () async {
when(() => mockRepo.findByUserId(any(),
completed: any(named: 'completed'),
limit: any(named: 'limit'),
offset: any(named: 'offset')))
.thenReturn([]);
when(() => mockRepo.countByUserId(any(),
completed: any(named: 'completed')))
.thenReturn(0);
final context = makeContext();
final response = await route.onRequest(context);
expect(response.statusCode, equals(200));
final json = await response.json() as Map<String, dynamic>;
expect(json['data'], isEmpty);
expect(json['meta']['total'], equals(0));
});
test('할 일 목록 반환', () async {
final todos = [makeTodo(id: 1), makeTodo(id: 2, title: '두 번째')];
when(() => mockRepo.findByUserId(any(),
completed: any(named: 'completed'),
limit: any(named: 'limit'),
offset: any(named: 'offset')))
.thenReturn(todos);
when(() => mockRepo.countByUserId(any(),
completed: any(named: 'completed')))
.thenReturn(2);
final context = makeContext();
final response = await route.onRequest(context);
expect(response.statusCode, equals(200));
final json = await response.json() as Map<String, dynamic>;
expect((json['data'] as List), hasLength(2));
});
});
group('POST /todos', () {
test('할 일 생성 성공', () async {
final newTodo = makeTodo(id: 3, title: '새 할 일');
when(() => mockRepo.create(
title: any(named: 'title'),
userId: any(named: 'userId'),
)).thenReturn(newTodo);
final context = makeContext(
method: HttpMethod.post,
body: {'title': '새 할 일'},
);
final response = await route.onRequest(context);
expect(response.statusCode, equals(201));
final json = await response.json() as Map<String, dynamic>;
expect(json['data']['title'], equals('새 할 일'));
});
test('빈 title → 422', () async {
final context = makeContext(
method: HttpMethod.post,
body: {'title': ''},
);
// ValidationException은 errorHandler 미들웨어에서 처리
// 핸들러 자체에서 예외를 던지는지 확인
await expectLater(
() => route.onRequest(context),
throwsA(isA<ValidationException>()),
);
});
});
}
String _buildQuery(Map<String, String>? params) {
if (params == null || params.isEmpty) return '';
final query = params.entries.map((e) => '${e.key}=${e.value}').join('&');
return '?$query';
}
테스트 실행
# 전체 테스트dart test# 통합 테스트만dart test test/integration/# 특정 파일dart test test/services/auth_service_test.dart# 상세 출력dart test --reporter expanded
Dockerfile — 배포 준비
# 새 파일: DockerfileFROM dart:stable AS builderWORKDIR /appCOPY pubspec.yaml pubspec.lock ./RUN dart pub getCOPY . .RUN dart_frog buildRUN dart compile exe .dart_frog/server.dart -o build/server# 경량 런타임 이미지FROM debian:bullseye-slim# SQLite 라이브러리 설치RUN apt-get update && apt-get install -y libsqlite3-0 && rm -rf /var/lib/apt/lists/*WORKDIR /appCOPY --from=builder /app/build/server ./server# 데이터 볼륨VOLUME ["/data"]ENV DB_PATH=/data/todo.dbEXPOSE 8080CMD ["./server"]
# 빌드docker build -t todo_server .# 실행docker run -p 8080:8080 \ -v $(pwd)/data:/data \ -e JWT_SECRET="your-secret-key-at-least-32-chars" \ todo_server
PART 06 마무리
PART 06에서 완전한 REST API 서버를 완성했습니다.
- Ch 01:
shelfvsdart_frog비교,dart_frog선택 이유 - Ch 02: 프로젝트 생성, 파일 기반 라우팅, 핫 리로드
- Ch 03: CORS, 로깅, 에러 처리 미들웨어
- Ch 04: DTO 패턴, 유효성 검사, 표준 응답 구조
- Ch 05: SQLite 연동, Repository 패턴, 의존성 주입
- Ch 06: JWT 기반 인증, 회원가입/로그인
- Ch 07: 단위 테스트, 통합 테스트, 라우트 핸들러 테스트
todo_server는 다음 기능을 모두 갖춘 실제 API 서버입니다.
- RESTful 엔드포인트 (CRUD)
- JWT 인증
- SQLite 데이터 영속성
- 표준화된 에러 응답
- Docker 배포
PART 07에서는 이번에 만든 경험을 바탕으로 패키지를 만들고 pub.dev에 배포합니다.