Flutter 위젯 테스트 91개, 로컬 DB(drift) 붙은 화면을 안 깨지게 테스트하기

혼자 앱을 만들면 “테스트 짤 시간에 기능 하나 더”의 유혹이 크다. 그래도 나는 도메인·화면에 테스트를 붙였다 — 24개 파일, 91개 케이스. 덕분에 2-상태 사이클로 도메인을 통째로 갈아엎는 큰 리팩터링을 겁 없이 했다. 테스트가 없었다면 그 개편은 못 했을 것이다. 특히 로컬 DB(drift)가 붙은 화면을 테스트할 때의 함정을 공유한다.

순수 로직은 단위테스트, 화면은 위젯테스트

테스트를 두 층으로 나눴다. 순수 도메인 함수는 의존성이 없어 단위테스트가 빠르고 촘촘하다. 실제로 이런 것들을 단위테스트로 덮었다.

  • 단식/식사 페이즈 도출: 이벤트 로그 → idle/fasting/completed/eating의 모든 분기. 목표시간 경계(완주 직전/직후) 같은 엣지 케이스까지.
  • 광고 게이트: 빈도 캡·광고제거 기간 판정. “3시간 전 봤으면 억제, 지났으면 노출” 같은 경계값.
  • 식사창 길이 계산, BMI, 개입 카피 선택 등.

반대로 화면은 위젯테스트로 실제 렌더·탭 흐름을 검증한다. 로직을 도메인으로 밀어낸 만큼 화면 테스트는 얇아진다 — “버튼을 누르면 시간 입력창이 뜬다” 같은 UI 계약만 확인하면 된다. 로직과 화면을 나눈 이 구조가, 테스트를 감당 가능하게 만든 핵심이었다.

함정: drift 스트림이 테스트 종료 후에도 살아있다

위젯테스트에서 drift의 watch 스트림을 구독하는 화면을 띄우면, 테스트가 끝나도 스트림·타이머가 정리되지 않아 “pending timer” 류 에러가 나거나 다음 테스트가 오염된다. 화면이 백그라운드 구독을 남긴 채 종료되기 때문이다.

해결은 단순하다. 테스트 끝에서 화면을 빈 위젯으로 교체하고 한 프레임 흘려 dispose를 유도한다.

await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 1));

이 두 줄이면 구독이 dispose되며 스트림/타이머가 깔끔히 닫힌다. 사소해 보이지만 안 하면 “혼자선 통과하는데 같이 돌리면 깨지는” 유령 실패에 시달린다. 이 flakiness는 원인 찾기가 특히 고약하다 — 테스트 순서·타이밍에 따라 나타났다 사라지기 때문이다. 나도 처음엔 “왜 CI에서만 깨지지?”로 한참 헤맸다.

정리(teardown)도 테스트의 일부다

여기서 얻은 교훈. 로컬 DB나 스트림이 붙은 화면은 “열었을 때 잘 뜨나”뿐 아니라 “닫았을 때 잘 정리되나”까지 테스트 범위다. 리소스 정리를 안 하면 메모리 누수·중복 구독으로 실기기에서 뒤늦게 터진다. 테스트에서 정리를 강제하는 습관이, 실기기 버그를 미리 잡아준다.

analyze 클린을 기준선으로

마지막으로 flutter analyze를 항상 0 경고로 유지했다. 경고가 쌓이면 진짜 문제가 그 속에 묻힌다. 테스트 91개 통과 + analyze 클린이 “이 커밋은 안전하다”의 최소 기준선이었다. 1인 개발에서 테스트는 사치가 아니라 미래의 나를 위한 안전망이고, 코드 리뷰어가 없는 만큼 이 자동 기준선이 유일한 게이트키퍼다.

테스트가 리팩터링을 가능하게 했다

이 테스트들의 진짜 값어치는 “버그를 잡는다”가 아니라 “큰 변경을 겁 없이 하게 한다”였다. 나는 단식 도메인을 “세션 기반”에서 “이벤트 로그 기반 2-상태 사이클”로 통째로 갈아엎었는데, 91개 케이스가 초록불인 걸 확인하며 진행하니 개편이 두렵지 않았다. 통합 테스트로 온보딩→단식→식사 전체 흐름도 한 번씩 훑는다. 테스트가 없었다면 “혹시 이거 건드리면 저게 깨지나” 하는 두려움에 리팩터링을 미뤘을 것이고, 코드는 점점 손대기 무서운 덩어리가 됐을 것이다. 1인 개발일수록 이 안전망이 크다.

댓글 남기기

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.ㅇ