iBetter Books
수정

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: shelf vs dart_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에 배포합니다.