다른 버그를 잡다가 계측 테스트를 Android 10 실기기에서 돌렸습니다. 평소에는 최신 기기로만 돌리던 것이었습니다.
java.lang.SecurityException: Unknown permission
android.permission.READ_MEDIA_IMAGES
갤러리 테스트 세 건이 전부 이걸로 죽었습니다. 테스트가 실패한 게 아니라 본문이 시작조차 못 한 것입니다.
범인은 이 줄이었습니다.
// ❌ API 33 전용 권한을 모든 기기에 요구한다
@get:Rule(order = 0)
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.READ_MEDIA_IMAGES,
)
원인 — 권한 이름이 API 33에서 갈린다
READ_MEDIA_IMAGES는 API 33(Android 13)에서 생긴 권한입니다. 그 미만 기기에는 그런 이름의 권한이 시스템에 아예 없습니다.
GrantPermissionRule은 테스트 본문 전에 이 권한들을 부여하려 하는데, 존재하지 않는 이름을 받으면 SecurityException을 던집니다. @Rule 단계에서 던지므로 @Before도 @Test도 실행되지 않습니다.
여기서 정신이 번쩍 들었던 건, 제 앱의 minSdk가 26이라는 사실이었습니다. 즉 갤러리 기능은 지원 범위의 상단 일부에서만 검증돼 왔습니다. 그 아래 구간은 테스트가 존재는 하는데 한 번도 돌아 본 적이 없었습니다.
고치는 법 — 매니페스트와 같은 경계로 가른다
@get:Rule(order = 0)
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_IMAGES
else
Manifest.permission.READ_EXTERNAL_STORAGE
)
사진을 읽는 앱이라면
READ_EXTERNAL_STORAGE에 android:maxSdkVersion="32"가 붙어 있을 것입니다. 즉 앱은 두 권한을 이미 33에서 가르고 있었고, 테스트만 그 사실을 몰랐습니다. 테스트의 권한 목록은 매니페스트를 따라가는 게 맞습니다.
더 나쁜 쪽 — 아무것도 검증하지 않으면서 늘 통과하던 테스트
위의 건은 그래도 빨간불이라 눈에 띕니다. 같은 날 발견한 다른 테스트가 훨씬 나빴습니다.
// ❌ "갤러리로 이동했는가"를 본다면서 빈 목록 문구를 확인한다
@Test
fun galleryContent_shown_afterOpeningGallery() {
composeTestRule.onNodeWithContentDescription("갤러리").performClick()
composeTestRule.onNodeWithText("저장된 사진이 없습니다").assertIsDisplayed()
}
이 테스트는 계속 통과하고 있었습니다. 이유가 문제입니다. 그 테스트 클래스는 사진 읽기 권한을 부여하지 않았고, 그래서 갤러리는 사진이 있든 없든 늘 비어 보였습니다.
즉 이 테스트는
- 화면 이동을 검증한다고 이름을 붙였지만
- 실제로는 목록이 비어 있는지를 보고 있었고
- 권한이 없어서 영원히 비어 있었습니다
실패할 수 없는 초록불이었습니다. 권한을 제대로 넣자마자 곧바로 빨간불이 됐습니다.
고친 방식은 관심사에 맞는 것을 보게 한 것입니다. 이동을 확인하고 싶으면 목록 내용이 아니라 탭이 선택됐는지를 봅니다.
@Test
fun galleryScreen_retainsStateAfterTabSwitch() {
composeTestRule.onNodeWithText(str(R.string.tab_settings)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(str(R.string.tab_gallery)).performClick()
composeTestRule.waitForIdle()
// "무엇이 떴는가"가 아니라 "갤러리로 돌아왔는가"가 이 테스트의 관심사다
composeTestRule.onNodeWithText(str(R.string.tab_gallery)).assertIsSelected()
}
"빈 상태"를 전제하는 테스트는 실패가 아니라 스킵이어야 한다
빈 목록 문구를 진짜로 확인하고 싶은 테스트도 물론 있습니다. 그건 기기에 사진이 없을 때만 성립하는 전제입니다. 사진이 있는 실기기에서 빨간불로 두면 진짜 회귀와 구분이 안 됩니다.
@Test
fun galleryScreen_showsEmptyMessageWhenNoPhotos() {
assumeTrue("사진이 있는 기기에서는 빈 상태를 볼 수 없다", galleryIsEmpty())
composeTestRule.onNodeWithText(str(R.string.gallery_empty)).assertIsDisplayed()
}
전제 판정은 화면과 같은 질의를 재사용한다
galleryIsEmpty()를 짤 때 경로를 테스트에 따로 적고 싶어집니다. 그러면 앱이 저장 폴더를 바꿨을 때 조용히 어긋납니다. 화면이 쓰는 질의 함수를 그대로 가져다 씁니다.
private fun galleryIsEmpty(): Boolean {
val (selection, args) = galleryQuery(Build.VERSION.SDK_INT) // 화면과 같은 질의
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
val cursor = ctx.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Images.Media._ID), selection, args, null
) ?: return true
cursor.use { return it.count == 0 }
}
무엇을 놓쳤나
이 테스트들이 못 잡고 지나간 것이 실제로 있었습니다. 같은 앱에서 SQLiteException: no such column: relative_path로 갤러리를 여는 순간 앱이 죽는 버그가 API 28 이하에 있었습니다. MediaStore.RELATIVE_PATH가 API 29부터인데 minSdk가 26이었던 것입니다.
갤러리 화면을 여는 계측 테스트가 분명히 있었는데도 못 잡았습니다. 그 테스트가 API 33 이상에서만 돌고 있었기 때문입니다. 자세한 내용은 따로 정리했습니다 — (MediaStore relative_path 편).
테스트 자신이 minSdk에서 돌고 있는지를 따로 확인해야 합니다. 두 개는 다른 질문입니다.
일반화 — 초록불의 두 가지 뜻
이번 일을 한 문장으로 줄이면 이렇습니다.
초록불이 "검증됐다"는 뜻인지 "조건에 못 닿았다"는 뜻인지 갈라 봐야 합니다.
아래는 전부 결과 요약에서 통과처럼 보이는 것들입니다.
| 모양 | 실제 | 드러내는 법 |
|---|---|---|
assumeTrue 스킵 | 그 경로를 안 밟았다 | 실행 결과의 스킵 수를 매번 확인한다 |
| 권한 미부여 | 화면이 늘 빈 상태라 무엇이든 통과 | 권한을 준 상태에서 빨간불이 나는지 본다 |
| 단정문이 관심사와 다름 | 이름과 검증 내용이 어긋남 | 테스트 이름을 소리 내어 읽고 단정문과 대조한다 |
| 기기가 경계 밖 | 지원 구간의 일부만 검증 | minSdk 기기·에뮬에서 한 번 돌린다 |
수정 전 코드를 넣고 그 테스트가 빨간불이 되는지 확인하세요. 빨간불이 안 되면 그 테스트는 버그를 잡지 못합니다. 초록불만 보고 넘기면 그 사실을 영영 모릅니다.
결과
권한 분기를 넣고 단정문을 바로잡은 뒤 두 환경에서 돌렸습니다.
실기기(Android 10) : 계측 42개 · 0 실패 · 2 스킵
에뮬레이터(전면 카메라 없음) : 계측 42개 · 0 실패 · 1 스킵
스킵 수가 환경마다 다른 것이 정상입니다. 그 차이가 곧 "이 환경에서는 어떤 전제가 성립하지 않았는가"를 말해 줍니다. 스킵이 0으로만 나오면 오히려 전제 처리를 안 한 것일 수 있습니다.
정리
GrantPermissionRule에 API 33 전용 권한 이름을 그대로 넘기지 마세요. 33 미만에서SecurityException으로 본문이 시작조차 못 합니다.- 권한 목록의 경계는 매니페스트의
maxSdkVersion과 같게 맞춥니다. - 권한을 안 준 테스트는 화면이 늘 비어 보여 아무거나 통과시킵니다.
- "빈 상태" 전제는 실패가 아니라 스킵(
assumeTrue)으로 처리합니다. - 전제 판정은 화면과 같은 질의를 재사용합니다.
- 테스트도 minSdk에서 한 번 돌리세요. 앱을 돌린 것과는 별개의 확인입니다.
자주 묻는 것
CI에서는 최신 API 에뮬레이터만 쓰는데요?
흔한 구성이고, 그래서 이 문제가 오래 숨습니다. 전체 스위트를 두 배로 돌릴 필요는 없습니다. 저장소·권한·미디어를 건드리는 테스트 클래스만 minSdk 에뮬레이터에서 한 번 더 돌려도 이 부류는 걸러집니다.
@SdkSuppress로 33 미만을 제외하면 안 되나요?
그건 "33 미만에서는 검증하지 않겠다"고 선언하는 것입니다. 기능 자체가 33+ 전용이면 옳은 선택이지만, 갤러리처럼 minSdk 전 구간에서 동작해야 하는 기능에 쓰면 문제를 감추는 쪽이 됩니다. 제 경우가 정확히 그랬습니다.
connectedAndroidTest로 돌리면 권한이 자꾸 초기화되는데요?
connectedAndroidTest는 앱을 설치하면서 권한 상태를 지웁니다. 저는 설치 → pm grant → am instrument 순서로 나눠 돌립니다. GrantPermissionRule과 별개로, 테스트 밖에서 미리 필요한 권한을 주고 시작하는 편이 결과가 안정적입니다.