Stop Paying the build_runner Tax: Why I Refuse to Use Mockito in Modern Dart
build_runner 비용을 그만 내자 — 현대 Dart에서 Mockito를 쓰지 않는 이유
Dart와 Flutter 테스트에서 Mockito가 요구하는 build_runner 코드 생성을 비판하고, 코드 생성 없이 동작하는 mocktail로 옮기는 방법을 설명합니다. Mockito와 mocktail의 문법 차이, null safety 때문에 필요한 fallback 등록, 기존 테스트를 파일 단위로 점진적으로 마이그레이션하는 절차를 다룹니다.
- 주제
AI 요약
Dart와 Flutter 프로젝트에서 TDD를 진행하다가 인터페이스에 메서드 하나를 추가하면, 생성된 mock이 새 메서드를 알도록 build_runner를 다시 실행해야 합니다. 글에서는 dart run build_runner build --delete-conflicting-outputs 명령을 실행하는 동안 짧게는 15~30초, 큰 엔터프라이즈 Flutter 코드베이스에서는 최대 2분까지 기다릴 수 있다고 설명합니다. 테스트를 자주 실행해야 하는 TDD의 피드백 주기가 수십 초로 늘어나면 개발자가 변경을 모아서 커밋하고, 테스트를 나중에 실행하게 된다는 주장입니다.
Mockito가 build_runner를 필요로 하게 된 배경
Null safety가 도입되기 전 Dart에서는 class MockRepo extends Mock implements Repo {}처럼 mock을 선언하고 noSuchMethod의 동적 호출에 의존할 수 있었습니다. Mockito는 런타임에 호출을 가로채고 stub을 적용했기 때문에 별도의 코드 생성이 필요하지 않았습니다.
Sound Null Safety가 도입되면서 상황이 달라졌습니다. 인터페이스 메서드가 User처럼 null이 아닌 반환값을 선언하면, stub이 등록되기 전에 noSuchMethod가 null을 반환할 수 없습니다. 그렇게 하면 타입 시스템을 위반해 런타임 오류가 발생합니다. Flutter에서는 성능과 tree-shaking 문제로 무거운 런타임 reflection인 dart:mirrors도 사용할 수 없습니다. 글은 Mockito가 이 문제를 해결하기 위해 build_runner 기반의 컴파일 타임 코드 생성을 선택했다고 설명합니다.
그 결과 테스트 파일마다 .mocks.dart 파일이 생깁니다. 생성된 파일은 수천 줄의 보일러플레이트를 포함해 코드 리뷰와 저장소 검색을 복잡하게 만들고, 여러 브랜치에서 같은 서비스 인터페이스를 수정하면 생성 파일의 병합 충돌도 커집니다. 새 의존성을 mock으로 추가할 때마다 @GenerateMocks 목록을 고치고 build_runner를 다시 실행한 뒤 생성된 심볼을 import해야 한다는 점도 지적합니다.
mocktail의 선언과 stub 방식
글에서 대안으로 제시하는 mocktail은 build_runner, .mocks.dart 파일, annotation 없이 순수한 Dart 코드로 mock을 선언합니다.
class MockUserRepository extends Mock implements UserRepository {}
이처럼 테스트 파일이나 공용 test_helpers.dart에 mock 클래스를 직접 작성합니다. Dart의 implicit interface를 활용하므로 모든 클래스가 제공하는 인터페이스를 구현할 수 있습니다. Mockito처럼 생성 파일을 기다리지 않고 저장한 뒤 바로 테스트를 실행합니다.
주요 문법 변화는 호출을 익명 함수로 감싸는 부분입니다. Mockito에서는 when(repo.name).thenReturn('Randal')처럼 호출을 직접 넘기지만, mocktail에서는 when(() => repo.name).thenReturn('Randal')처럼 작성합니다. 비동기 stub도 when(() => repo.fetch()).thenAnswer((_) async => user) 형태를 사용합니다. 검증 역시 verify(() => repo.login()).called(1)처럼 호출을 closure 안에 넣습니다. 인자 matcher는 anyNamed('id') 대신 any(named: 'id'), anyString 대신 any()를 사용합니다.
closure는 호출을 즉시 평가하지 않고 mocktail이 가로챌 시점까지 미룹니다. 글은 이 지연 방식이 null safety 문제를 피하면서 등록된 stub이나 fallback 값을 적용하는 장치라고 설명합니다.
non-nullable 인자에 필요한 fallback 등록
mocktail을 사용할 때의 주의점은 non-nullable 커스텀 타입에 any()를 적용하는 경우입니다. 예를 들어 saveUser(User user)를 when(() => mockRepo.saveUser(any()))으로 stub하면, matcher 등록 과정에서 User 타입에 맞는 값이 필요합니다. mocktail은 임의의 User 인스턴스를 만들 수 없으므로 등록된 fallback이 없으면 다음과 같은 오류를 냅니다.
Bad state: A test tried to use any() or captureAny() on a User which was not registered.
해결 방법은 Fake를 정의하고 setUpAll에서 한 번 등록하는 것입니다.
class FakeUser extends Fake implements User {}
setUpAll(() { registerFallbackValue(FakeUser()); });
그 뒤 when(() => repo.saveUser(any())).thenAnswer((_) async => true)처럼 matcher를 사용할 수 있습니다. 글은 이 두 줄에 해당하는 Fake 정의와 등록으로 해당 타입의 matcher를 처리한다고 설명합니다.
기존 Mockito 테스트의 점진적 전환
모든 테스트를 한 번에 바꿀 필요는 없습니다. 먼저 dev_dependencies에 mocktail: ^1.0.4를 추가합니다. 그 다음 테스트를 수정할 일이 생길 때마다 해당 파일만 전환합니다.
1. .mocks.dart import를 삭제합니다.
2. @GenerateMocks annotation을 제거합니다.
3. class MockX extends Mock implements X {} 선언을 테스트 파일에 추가합니다.
4. when과 verify 호출을 () => ... 형태로 감쌉니다.
5. 더 이상 쓰지 않는 .mocks.dart 파일을 삭제합니다.
6. 파일 단위 변경을 커밋해 생성 코드가 사라진 diff를 확인합니다.
글은 공식 Flutter 문서와 cookbook에 Mockito가 여전히 자주 등장하는 이유를 과거 관행의 영향으로 봅니다. 최종 주장은 build_runner를 mock 생성에만 사용하고 있다면 mocktail 또는 직접 작성한 fake를 검토하자는 것입니다. 다만 mocktail에도 non-nullable 커스텀 타입 matcher에 fallback을 등록해야 하는 규칙이 있으므로, 완전히 아무 설정도 필요 없는 도구로 소개하지는 않습니다.
원문: dev.to / 번역·요약: Trawling