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

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
+122 -48
View File
@@ -2,13 +2,14 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// iOS 스토어 제출 구성 검사.
/// 스토어 제출 준비물 검사.
///
/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인
/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어
/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다.
const _teamId = 'V28G543VK5'; // Fancake Inc.
/// **여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다.** 문제는 심사에 넣거나
/// 사용자가 권한을 누르는 순간에야 드러나고, 그때는 원인이 코드처럼 보인다.
/// 실제로 이 프로젝트에서 최소 지원 OS 가 계약값과 다른 채로 나가고 있었다.
///
/// 파일을 문자열로 읽는 이유는 plist·gradle 파서를 붙이지 않기 위해서다.
/// 키가 있는지만 보면 되고, 값의 해석은 빌드가 한다.
String _read(String path) {
final file = File(path);
@@ -17,47 +18,62 @@ String _read(String path) {
}
void main() {
group('iOS — 서명', () {
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
.allMatches(pbx)
.map((m) => m.group(1))
.toSet();
expect(teams, {_teamId});
expect(_read('ios/ExportOptions.plist'), contains(_teamId));
group('iOS — 권한 사용 문구', () {
late final String plist = _read('ios/Runner/Info.plist');
test('권한을 요청하는 기능마다 문구가 있다', () {
// 문구 없이 요청하면 iOS 가 앱을 즉시 종료한다. 크래시로 보인다.
for (final key in const [
'NSPhotoLibraryUsageDescription', // 프로필 사진
'NSCameraUsageDescription', // 프로필 · 방문 인증 사진
'NSLocationWhenInUseUsageDescription', // 위치 퀘스트
'NSMotionUsageDescription', // 만보기
'NSHealthShareUsageDescription', // 걸음 수
'NSUserTrackingUsageDescription', // 광고
]) {
expect(plist, contains(key), reason: key);
}
});
test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
expect(
_read('ios/Runner/Runner.entitlements'),
contains('com.apple.developer.applesignin'),
);
expect(
'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
.allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
.length,
3, // Debug · Release · Profile
);
test('문구가 용도를 말한다', () {
// 「접근이 필요합니다」처럼 용도 없는 문장은 심사 반려 사유다.
expect(plist, contains('프로필 사진을 고르기 위해'));
expect(plist, contains('퀘스트 보상을 드리기 위해'));
expect(plist, contains('다른 건강 정보는 읽지 않습니다'));
});
});
group('iOS — 제출 선언과 Firebase 설정', () {
group('iOS — 제출 선언', () {
late final String plist = _read('ios/Runner/Info.plist');
late final String entitlements = _read('ios/Runner/Runner.entitlements');
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
expect(plist, contains('ITSAppUsesNonExemptEncryption'));
});
test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () {
test('Google 로그인 콜백 스킴 자리가 있다', () {
expect(plist, contains('CFBundleURLTypes'));
// 값은 Firebase 설정 파일에서 뽑아 넣는다. 손으로 적으면 오타로 조용히 실패한다.
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
for (final c in const ['Debug', 'Release']) {
expect(
_read('ios/Flutter/$c.xcconfig'),
contains('#include? "../config/Google.xcconfig"'),
reason: c,
);
}
});
test('Sign in with Apple Capability 가 있다', () {
// 정책 §P-01 — 소셜 로그인을 제공하면 Apple 심사 필수 조건이다.
expect(entitlements, contains('com.apple.developer.applesignin'));
});
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로만 서명한다', () {
// 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 간다(10/2 실제 발생).
const teamId = 'V28G543VK5'; // Fancake Inc.
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
.allMatches(pbx)
.map((m) => m.group(1))
.toSet();
expect(teams, {teamId});
final options = _read('ios/ExportOptions.plist');
expect(options, contains('app-store-connect'));
expect(options, contains(teamId));
});
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
@@ -69,18 +85,76 @@ void main() {
});
});
test('설정·서명 자산을 git 이 추적하지 않는다', () {
final result = Process.runSync('git', [
'ls-files',
'--',
'ios/Runner/GoogleService-Info.plist',
'ios/config/Google.xcconfig',
'android/app/google-services.json',
'*.p12',
'*.p8',
'*.mobileprovision',
]);
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
expect((result.stdout as String).trim(), isEmpty);
group('Android — 계약값과 배포 경계', () {
late final String gradle = _read('android/app/build.gradle.kts');
test('최소 지원 OS 가 계약값 API 29 다', () {
// 별첨 1 v1.0 이 Android 10 으로 정했다. Flutter 기본값 24 로 두면
// 검증하지 않은 기기까지 설치가 열린다.
expect(gradle, contains('minSdk = 29'));
});
test('평문 예외가 개발 소스 세트에만 있다', () {
for (final variant in const ['debug', 'profile']) {
expect(
File('android/app/src/$variant/res/xml/network_security_config.xml')
.existsSync(),
isTrue,
reason: variant,
);
}
// main 에 있으면 릴리스 빌드로 새어 중간자 공격 표면이 열린다.
expect(
File('android/app/src/main/res/xml/network_security_config.xml')
.existsSync(),
isFalse,
);
expect(
_read('android/app/src/main/AndroidManifest.xml'),
isNot(contains('networkSecurityConfig')),
);
});
});
group('서명 자산이 저장소에 올라가지 않는다', () {
late final String ignore = _read('.gitignore');
test('키스토어·설정 파일 패턴이 걸려 있다', () {
for (final pattern in const [
'key.properties',
'*.jks',
'*.keystore',
'*.p12',
'*.p8',
'*.mobileprovision',
'google-services.json',
'GoogleService-Info.plist',
]) {
expect(ignore, contains(pattern), reason: pattern);
}
});
test('git 이 실제로 추적하고 있지 않다', () {
// .gitignore 에 적혀 있어도 이미 추적 중이면 계속 올라간다.
// 패턴 검사만으로는 그 상태를 잡지 못하므로 git 에 직접 묻는다.
final result = Process.runSync('git', [
'ls-files',
'--',
'android/app/google-services.json',
'ios/Runner/GoogleService-Info.plist',
'android/key.properties',
'*.jks',
'*.keystore',
'*.p8',
], workingDirectory: '.');
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
final tracked = (result.stdout as String).trim();
expect(
tracked,
isEmpty,
reason: '서명·설정 자산이 저장소에 올라가 있습니다:\n$tracked',
);
});
});
}