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

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
@@ -0,0 +1,133 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:fancake/features/home/home_repository_firestore.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// POP → 행성 반영 실증 — 별첨 1 ② 완료 시나리오 1 의 **읽는 쪽**.
///
/// **무엇을 재는가.** 서버가 원장을 지나 쓴 값을 앱이 그대로 읽어 HOME·MY 수치로
/// 옮기는지를 잰다. 가짜 Firestore 가 아니라 **실제 Firestore 에뮬레이터**를 물고,
/// 문서도 앱이 만든 것이 아니라 **Cloud Functions 가 쓴 것**을 읽는다. 두 단계가
/// 서로 다른 프로세스라, 필드 이름이 한 글자만 어긋나도 여기서 0 이 나온다.
///
/// 실행 — 두 단계를 순서대로 돈다.
///
/// ```bash
/// # 1. 서버가 상태를 만든다(출석 지급 → 원장 → 기여 차감 → 등급·멤버 갱신)
/// java -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \
/// --host=0.0.0.0 --port=8080 &
/// CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \
/// npm --prefix firebase/functions test
///
/// # 2. 앱이 그 상태를 읽는다
/// fvm flutter test integration_test/pop_loop_emulator_test.dart \
/// -d emulator-5554 --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2
/// ```
///
/// `10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다.
///
/// **로그인만 흉내 낸다.** Auth 에뮬레이터는 `firebase` CLI 안에만 있어 이 환경에서
/// 띄우지 못한다(BUILD.md 2-1). UID 는 서버 테스트가 쓴 것과 같은 값을 넣고, 읽는
/// 경로는 실제 구현을 그대로 지난다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
/// `firebase/functions/test/quest_e2e.test.ts` 가 쓰는 UID.
const uid = 'test-quest-e2e';
/// 출석 퀘스트의 **문서 ID**. 진행 문서도 같은 ID 로 선다(설계서 §2.1).
const attendQuest = 'daily-checkin';
/// 호스트 PC 주소. Android 에뮬레이터는 `10.0.2.2` 로 본다.
const host = String.fromEnvironment('FANUZU_EMULATOR_HOST', defaultValue: '10.0.2.2');
late Backend backend;
setUpAll(() async {
// `FirebaseBoot.init()` 을 쓰지 않는다. 그 경로는 Auth 에뮬레이터(9099)에도
// 붙는데, Auth 는 `firebase` CLI 안에만 있어 이 환경에서 뜨지 않는다(BUILD.md 2-1).
// 거기서 실패하면 Firestore 연결까지 건너뛰어 앱이 실 서버를 바라본다.
//
// 이름 있는 앱을 따로 띄운다. Android 는 `google-services.json` 으로 `[DEFAULT]`
// 앱을 자동 초기화하므로 그 자리를 덮을 수 없고, 덮지 않으면 프로젝트 ID 가
// 달라 에뮬레이터의 다른 칸을 읽는다.
final app = await Firebase.initializeApp(
name: 'fanuzu-emulator-check',
options: const FirebaseOptions(
apiKey: 'demo-key',
appId: '1:0:android:0',
messagingSenderId: '0',
projectId: 'demo-fanuzu',
),
);
final db = FirebaseFirestore.instanceFor(app: app)
..useFirestoreEmulator(host, 8080);
backend = Backend(
firestore: db,
auth: MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: uid, email: 'fan@fanuzu.co.kr'),
),
);
});
testWidgets('에뮬레이터를 물고 서버가 쓴 문서가 보인다', (tester) async {
final user = await backend.db.doc('users/$uid').get();
expect(user.exists, isTrue,
reason: '서버 E2E(quest_e2e.test.ts)를 먼저 돌려 상태를 만드십시오');
// 출석·후원을 지난 뒤의 서버 값이다. 앱이 쓴 값이 아니다.
expect(user.data()!['totalContributedPop'], 3000);
// 레벨은 누적 기여 POP 이 아니라 User EXP 에서 나온다(C-18) — 출석 1건 × 100(후원은 EXP 대상 아님).
expect(user.data()!['userExp'], 100);
expect(user.data()!['userLevel'], 1);
expect(user.data()!['passportTier'], 'SILVER');
});
testWidgets('HOME 이 출석 보상 잔액과 행성 성장 수치를 서버 값에서 읽는다', (tester) async {
final snapshot = await FirestoreHomeRepository(backend).load();
// 출석 1회 보상 10 POP — `fn-quest-attend` 가 원장을 지나 올린 활동 잔액.
expect(snapshot.popBalance, 10);
// 공동 성장 = 출석 보상 10 + 후원 3,000. 개인 기여와 구별한다.
expect(snapshot.growth.currentPop, 3010);
// 공동 Stage가 아직 없는 시나리오이므로 진행률은 확정하지 않는다.
expect(snapshot.growth.growthPercent, isNull);
expect(snapshot.growth.fandomName, '퀘스트 루프 팬덤');
});
testWidgets('MY 가 누적 기여와 등급을 서버 값에서 읽는다', (tester) async {
final profile = await FirestoreMyRepository(backend).profile();
expect(profile.contributedPop, 3000);
// 레벨은 User EXP 기반이다(C-18). 기여 POP 3,000 이 칭호를 올리지 않는다.
expect(profile.userLevel, greaterThanOrEqualTo(1));
expect(profile.passportTier, PassportTier.silver);
expect(profile.balancePop, 10);
expect(profile.planetTiers.single.fandomName, '퀘스트 루프 팬덤');
expect(profile.planetTiers.single.contributedPop, 3000);
});
testWidgets('같은 퀘스트를 다시 참여하면 서버가 막은 자국이 남아 있다', (tester) async {
// 중복 참여 거부(E-POP-03)는 서버가 판정한다. 앱에서 보이는 자국은 진행 카운터다 —
// 같은 날 두 번 눌러도 1 을 넘지 않는다.
//
// **경로가 퀘스트 문서 ID 다.** 앱이 목록을 그릴 때 읽는 자리와 서버가 쓰는 자리가
// 같아야 화면이 「보상완료」로 넘어간다.
final progress =
await backend.db.doc('users/$uid/questProgress/$attendQuest').get();
expect(progress.exists, isTrue);
expect(progress.data()!['count'], 1);
expect(progress.data()!['questId'], attendQuest);
final ledger = await backend.db
.collection('users/$uid/popTransactions')
.where('reasonCode', isEqualTo: 'QUEST_ATTEND')
.get();
expect(ledger.size, 1);
});
}