앱 단위·화면·통합 테스트

This commit is contained in:
Sn-Kinos
2026-10-04 01:43:10 +09:00
parent de44739e63
commit 4f6e1eac61
63 changed files with 14700 additions and 230 deletions
+97 -36
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:fancake/app.dart';
import 'package:fancake/core/config/feature_flags.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/features/auth/auth.dart';
@@ -96,6 +97,25 @@ void main() {
auth.dispose();
});
testWidgets('회원가입 직후에는 가입 완료 화면(182:29344)을 거쳐 셸로 들어간다', (tester) async {
final auth = AuthRepository(
providers: [_SignupProvider()],
releaseMode: true,
);
await tester.pumpWidget(FancakeApp(auth: auth));
await tester.pumpAndSettle();
await auth.signIn(SocialProvider.google, signUp: true);
await tester.pumpAndSettle();
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await tester.tap(find.text('시작'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
auth.dispose();
});
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
await _enterShell(tester);
@@ -157,9 +177,10 @@ void main() {
await source.close();
});
test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () {
test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () {
final station = appTabs.firstWhere((t) => t.label == 'STATION');
expect(station.path, '/station');
expect(Repositories(live: false).station, isNotNull);
});
});
@@ -249,44 +270,51 @@ void main() {
testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async {
await _enterShell(tester);
const roots = {
'HOME': ('S-H-HOME', '오늘의 퀘스트'),
'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'),
'GALAXY': ('S-G-PLANET', '내 행성'),
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
'MY': ('S-M-HOME', '마이 페이지'),
// 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서
// 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다.
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
const implemented = {
'HOME': 'TODAY’S QUEST',
'INSIDE': '최신순',
'GALAXY': 'GALAXY',
'MY': '행성/성운',
};
final roots = implemented.keys;
for (final entry in roots.entries) {
for (final tab in roots) {
await tester.tap(
find.descendant(
of: find.byType(NavigationBar),
matching: find.text(entry.key),
matching: find.text(tab),
),
);
await tester.pumpAndSettle();
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 영원히 기다린다.
// 탭 전환은 프레임 몇 장이면 끝나므로 시간을 정해 넘긴다.
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
final (screenId, screenName) = entry.value;
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 끝나지 않는다.
expect(
find.text(screenId),
findsOneWidget,
reason: '${entry.key} → $screenId',
);
// AppBar 제목과 본문 제목 두 군데에 나온다.
expect(
find.text(screenName),
find.text(implemented[tab]!),
findsWidgets,
reason: '${entry.key} → $screenName',
reason: '$tab → ${implemented[tab]}',
);
// 자리표시가 되살아나면 여기서 잡힌다.
expect(
find.textContaining('2주차 범위'),
findsNothing,
reason: '$tab 이 자리표시로 돌아갔다',
);
expect(
find.textContaining('wireframe/'),
findsNothing,
reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다',
reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다',
);
expect(
find.textContaining('screen-spec'),
findsNothing,
reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다',
reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다',
);
}
});
@@ -297,6 +325,13 @@ void main() {
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
Future<void> _enterShell(WidgetTester tester) async {
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
// 목 모드에서는 이클립스 경보 배너와 라이브 띠가 히어로 아래에 더 붙으므로
// 2400 으로는 TODAY'S QUEST 가 지연 빌드 밖으로 밀린다.
tester.view.physicalSize = const Size(1080, 3200);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle();
await tester.longPress(find.byKey(loginLogoKey));
@@ -319,6 +354,10 @@ Future<void> _enterShell(WidgetTester tester) async {
///
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
/// 계약 대조가 조용히 사라진다.
///
/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과
/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나
/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다.
List<String> _contractTabs() {
final candidates = [
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
@@ -326,24 +365,31 @@ List<String> _contractTabs() {
'design/craft-allowlist.yaml',
'../design/craft-allowlist.yaml',
];
final file = candidates
.map(File.new)
.firstWhere(
(f) => f.existsSync(),
orElse: () => throw StateError(
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
'찾아본 곳: ${candidates.join(' · ')}',
),
);
final line = file.readAsLinesSync().firstWhere(
(l) => l.trimLeft().startsWith('APP_TABS:'),
orElse: () =>
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
);
final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!;
final found = candidates.map(File.new).where((f) => f.existsSync()).toList();
if (found.isEmpty) {
throw StateError(
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
'찾아본 곳: ${candidates.join(' · ')}',
);
}
final lines = {for (final file in found) _appTabsLine(file)};
if (lines.length > 1) {
throw StateError(
'craft-allowlist.yaml 의 APP_TABS 가 사본마다 다르다. 정본과 사본을 맞추라. '
'${found.map((f) => f.path).join(' · ')} → ${lines.join(' / ')}',
);
}
final inner = RegExp(r'\[(.*)\]').firstMatch(lines.single)!.group(1)!;
return inner.split(',').map((s) => s.trim()).toList();
}
/// `APP_TABS:` 한 줄. 없으면 파일 이름을 붙여 실패한다.
String _appTabsLine(File file) => file.readAsLinesSync().firstWhere(
(l) => l.trimLeft().startsWith('APP_TABS:'),
orElse: () =>
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
);
final class _SmokeSession implements AuthSession {
const _SmokeSession(this.currentAccount);
@@ -356,3 +402,18 @@ final class _SmokeSession implements AuthSession {
@override
Future<void> signOut() async {}
}
final class _SignupProvider implements SocialAuthProvider {
@override
AuthProviderKind get provider => SocialProvider.google;
@override
bool get isLive => true;
@override
List<String> get missingConfig => const [];
@override
Future<AuthOutcome> signIn() async => const AuthSucceeded(
AuthAccount(uid: 'new-user', provider: SocialProvider.google),
);
@override
Future<void> signOut() async {}
}