Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 31 — App Permissions Testing Matrix

Đã đọc 0/13 mục · còn khoảng 11 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là QA cho một ứng dụng gọi xe kiểu Grab. Người dùng mở app lần đầu, app hỏi xin quyền vị trí. Họ bấm "Không cho phép". Ứng dụng crash. Hoặc tệ hơn: nó không crash nhưng bản đồ trắng xóa, không có thông báo nào, và người dùng chỉ biết đứng nhìn màn hình trống rồi gỡ app. Bạn vừa mất một khách hàng vì một luồng permission chưa được test.

Permission — hay "quyền truy cập" — là ranh giới giữa ứng dụng của bạn và những tài nguyên nhạy cảm của người dùng: camera, vị trí, danh bạ, micro, ảnh, thông báo. Cả iOS lẫn Android đều bắt buộc app phải xin phép rõ ràng trước khi chạm vào những thứ này. Và đây chính là mảnh đất màu mỡ của bug, bởi vì mỗi quyền không phải là một công tắc bật/tắt đơn giản. Nó là một ma trận trạng thái: chưa hỏi, đã cho phép, đã từ chối, từ chối vĩnh viễn, cho phép một lần, cho phép khi đang dùng app, cho phép vị trí gần đúng thay vì chính xác... Mỗi trạng thái dẫn app đi một nhánh code khác nhau.

Trong bài này chúng ta tập trung duy nhất vào một thứ: xây dựng một Permissions Testing Matrix — bảng ma trận test quyền — để bạn không bao giờ bỏ sót một tổ hợp trạng thái nào. Đây là kỹ năng phân biệt một QA mobile nghiệp dư với một người chuyên nghiệp. Người nghiệp dư test "cho phép rồi thấy chạy được là xong". Người chuyên nghiệp test cả 6 nhánh của một quyền.

Khái niệm cốt lõi

Permission là một máy trạng thái, không phải công tắc

Điểm sai lầm lớn nhất của người mới là nghĩ quyền chỉ có "cho" và "không cho". Thực tế, trạng thái một quyền phong phú hơn nhiều, và khác nhau giữa hai nền tảng.

Trên iOS, một quyền như camera có các trạng thái: notDetermined (chưa hỏi lần nào), authorized (đã cho), denied (đã từ chối), restricted (bị chặn bởi kiểm soát của phụ huynh hoặc chính sách MDM doanh nghiệp). Riêng vị trí còn thêm authorizedWhenInUse (chỉ khi đang mở app) và authorizedAlways (cả khi chạy nền), cùng với accuracyAuthorization là fullAccuracy hoặc reducedAccuracy (vị trí gần đúng — tính năng có từ iOS 14).

Trên Android, mô hình runtime permission (từ Android 6.0 trở đi) có: chưa cấp, đã cấp (granted), từ chối (denied — có thể hỏi lại), và "Don't ask again" tức từ chối vĩnh viễn (shouldShowRequestPermissionRationale trả về false sau khi bị denied). Từ Android 10+ có quyền vị trí nền tách riêng (ACCESS_BACKGROUND_LOCATION), Android 11+ có "cho phép một lần" (one-time), và Android 12+ tách vị trí chính xác (ACCESS_FINE_LOCATION) khỏi gần đúng (ACCESS_COARSE_LOCATION) — người dùng có thể chọn "vị trí gần đúng" ngay trong dialog.

Ma trận quyền phổ biến — bối cảnh Việt Nam

Dưới đây là những quyền bạn gặp thường xuyên nhất khi test app ở Việt Nam, kèm khóa cấu hình cần kiểm tra:

QuyềnUse case điển hình ở VNiOS Info.plist keyAndroid permission
CameraChụp ảnh CCCD/eKYC, quét mã QR VietQR chuyển khoảnNSCameraUsageDescriptionandroid.permission.CAMERA
Vị tríGrab/Be gọi xe, giao đồ ăn ShopeeFoodNSLocationWhenInUseUsageDescriptionACCESS_FINE_LOCATION
Danh bạMomo tìm bạn để chuyển tiềnNSContactsUsageDescriptionREAD_CONTACTS
MicroGhi âm ghi chú, gọi video ZaloNSMicrophoneUsageDescriptionRECORD_AUDIO
Ảnh/Thư việnĐăng ảnh sản phẩm lên chợ, đổi avatarNSPhotoLibraryUsageDescriptionREAD_MEDIA_IMAGES (A13+)
Thông báoBáo tài xế đã đến, khuyến mãi(xin runtime, không cần plist)POST_NOTIFICATIONS (A13+)
Sinh trắc họcĐăng nhập bằng vân tay/khuôn mặtNSFaceIDUsageDescriptionUSE_BIOMETRIC
Lưu ý cực kỳ quan trọng cho iOS: nếu bạn dùng một quyền mà thiếu key mô tả trong Info.plist, app sẽ bị Apple từ chối duyệt hoặc crash ngay khi gọi API. Đây là bug bạn phải bắt được trước khi lên App Store.

Cấu trúc một Permissions Testing Matrix

Ma trận test đúng nghĩa là giao của ba trục:

  • Trục quyền — từng permission app dùng.
  • Trục trạng thái — với mỗi quyền, liệt kê các trạng thái có thể (grant, deny, deny-forever, allow-once, reduced-accuracy...).
  • Trục thời điểm — người dùng thay đổi quyền khi nào: lần đầu mở, đang giữa luồng, hoặc rời app vào Settings tắt quyền rồi quay lại (đây là kịch bản bị bỏ sót nhiều nhất).
Với mỗi ô trong ma trận, bạn định nghĩa hành vi mong đợi: app hiển thị gì, có gợi ý mở Settings không, có degrade gracefully không, hay crash.

Tình huống thực tế

Ví dụ 1 — App gọi xe "BeGo" và cái crash vị trí

Một startup gọi xe giả định tên BeGo tại TP.HCM ra mắt bản beta cho 2.000 tài xế. Ba ngày sau, tỷ lệ crash tăng vọt lên 4,7%. Đội QA ban đầu bối rối vì họ đã test luồng "cho phép vị trí" rất kỹ.

Điều tra crash log cho thấy: các thiết bị crash đều là người từ chối quyền vị trí rồi vẫn cố bấm nút "Tìm chuyến". Code gọi thẳng locationManager.location!.coordinate — dùng force-unwrap trên iOS — trong khi location là nil vì chưa được cấp quyền. Trên trục ma trận, họ đã test ô "authorized × màn hình chính" nhưng bỏ trống ô "denied × bấm tìm chuyến".

Bài học: luồng thất bại quyền quan trọng ngang luồng thành công. Sau sự cố, BeGo thêm vào ma trận một cột bắt buộc cho mọi quyền: "người dùng denied rồi tiếp tục thao tác". Kết quả là màn hình phải hiện một empty-state lịch sự: "BeGo cần vị trí để tìm tài xế gần bạn" kèm nút "Mở Cài đặt". Crash rate về 0,3%.

Ví dụ 2 — eKYC ngân hàng và quyền camera bị "Don't ask again"

Một ngân hàng số ở Việt Nam triển khai eKYC (định danh điện tử) yêu cầu chụp ảnh CCCD và selfie. QA test trên máy Android Samsung, mọi thứ mượt mà. Nhưng bộ phận chăm sóc khách hàng báo về hàng loạt phàn nàn: "Tôi không chụp được ảnh, app cứ đứng im".

Nguyên nhân: người dùng lần đầu vô tình bấm "Từ chối" quyền camera hai lần, kích hoạt trạng thái "Don't ask again" của Android. Từ lúc đó, khi app gọi requestPermissions(), hệ điều hành không hiện dialog nữa mà trả về denied ngay lập tức. App hiểu nhầm là "người dùng vừa từ chối" và... không làm gì cả. Người dùng kẹt vĩnh viễn.

Cách xử lý đúng mà QA phải verify: trước khi request, app gọi shouldShowRequestPermissionRationale(). Nếu nó trả về false và quyền chưa được cấp, nghĩa là đã bị chặn vĩnh viễn — app phải dẫn người dùng ra màn hình Settings của app bằng intent ACTION_APPLICATION_DETAILS_SETTINGS, kèm hướng dẫn "Bật quyền Camera trong Cài đặt". QA bổ sung ô "camera × denied-forever × bấm chụp CCCD" vào ma trận. Đây là ô mà 80% team bỏ sót.

Ví dụ 3 — Vị trí gần đúng (reduced accuracy) trên iOS làm sai giá cước

Một app giao đồ ăn thử nghiệm trên iOS 15. Từ iOS 14, người dùng có thể cấp quyền vị trí nhưng chọn "Vị trí gần đúng" (reduced accuracy) — hệ thống chỉ trả tọa độ với sai số vài km. QA không để ý trục accuracy này vì trên máy test luôn để full accuracy.

Ra production, một số khách bật "Vị trí gần đúng" vì lý do riêng tư. Hậu quả: app tính khoảng cách giao hàng sai, có đơn báo phí ship 12.000đ trong khi thực tế cách nhà hàng 8km — lẽ ra 35.000đ. Nhà hàng lỗ, khách hàng thì bối rối khi shipper báo "sai địa chỉ".

Bài học: với quyền vị trí trên iOS, ma trận phải có hai ô riêng: authorizedWhenInUse + fullAccuracy và authorizedWhenInUse + reducedAccuracy. Trong trường hợp reduced, app nên phát hiện qua accuracyAuthorization và chủ động yêu cầu tạm nâng độ chính xác (requestTemporaryFullAccuracyAuthorization) cho tác vụ cần chính xác, hoặc yêu cầu người dùng nhập địa chỉ tay.

Hướng dẫn từng bước

Bước 1 — Liệt kê tất cả quyền app thực sự dùng. Trên iOS, mở Info.plist và tìm mọi key kết thúc bằng UsageDescription. Trên Android, đọc AndroidManifest.xml tìm các thẻ <uses-permission>. Đối chiếu với tính năng thực tế — nếu có quyền khai báo mà không tính năng nào dùng, đó là red flag (App Store review sẽ hỏi).

Bước 2 — Với mỗi quyền, dựng danh sách trạng thái. Dùng bảng: chưa hỏi / cho phép / từ chối / từ chối vĩnh viễn. Thêm biến thể đặc thù: vị trí thêm when-in-use, always, reduced accuracy; thông báo thêm provisional (iOS gửi im lặng).

Bước 3 — Thêm trục thời điểm. Với mỗi ô trạng thái, hỏi: điều gì xảy ra nếu người dùng vào Settings của điện thoại tắt quyền khi app đang chạy nền rồi quay lại? Nhiều app cache trạng thái quyền lúc khởi động và không kiểm tra lại — dẫn tới hành vi sai.

Bước 4 — Định nghĩa hành vi mong đợi cho từng ô. Viết rõ: hiển thị gì, có nút mở Settings không, có degrade gracefully không. Đây trở thành test case.

Bước 5 — Chuẩn bị công cụ reset trạng thái. Đây là bước kỹ thuật cứu bạn khỏi phải cài lại app 30 lần:

  • iOS Simulator: xcrun simctl privacy booted reset camera com.company.app (hoặc grant/revoke).
  • Android: adb shell pm reset-permissions để reset toàn bộ, hoặc adb shell pm revoke com.company.app android.permission.CAMERA để thu hồi một quyền cụ thể, adb shell pm grant ... để cấp.
Bước 6 — Chạy từng ô và ghi kết quả. Đánh dấu pass/fail cho mỗi giao điểm. Ô nào fail → tạo bug với ảnh chụp trạng thái quyền trong Settings làm bằng chứng.

Bước 7 — Kiểm tra thông báo mô tả quyền (purpose string). Đọc kỹ chuỗi mô tả hiện trong dialog xin quyền. Trên iOS đây là bắt buộc và Apple đọc nó khi duyệt. Chuỗi phải bằng tiếng Việt tự nhiên, giải thích tại sao cần quyền: "BeGo cần vị trí của bạn để tìm tài xế gần nhất" — không phải "App cần location".

Lỗi thường gặp & mẹo

Chỉ test luồng "happy path". Đây là lỗi số một. Test cho phép rồi thấy chạy được không đủ. Với mỗi quyền, ít nhất phải phủ 4 nhánh: cho, từ chối, từ chối vĩnh viễn, và tắt-từ-Settings-khi-đang-dùng.

Quên state khi quay lại từ background. Người dùng có thể tắt quyền từ trung tâm điều khiển hệ điều hành rồi mở lại app. iOS thậm chí sẽ kill và khởi động lại app khi bạn đổi một số quyền như vị trí. Hãy test kịch bản này, đừng giả định quyền không đổi trong một phiên.

Nhầm giữa "denied" và "denied vĩnh viễn" trên Android. Hai trạng thái này trả về giá trị giống nhau khi kiểm tra, chỉ phân biệt qua shouldShowRequestPermissionRationale. Nếu app không phân biệt, người dùng bị từ chối vĩnh viễn sẽ kẹt. Luôn test đủ số lần từ chối để kích hoạt trạng thái này.

Bỏ qua quyền POST_NOTIFICATIONS trên Android 13+. Trước Android 13, thông báo không cần xin quyền. Nhiều app cũ nâng cấp lên và quên rằng giờ đây trên máy Android 13+, nếu không xin quyền, người dùng sẽ không nhận được thông báo nào — và không có lỗi rõ ràng. Test riêng ô này trên máy Android 13, 14.

Thiếu purpose string trên iOS. Gọi API camera mà không có NSCameraUsageDescription → crash ngay lập tức, không cảnh báo. Mẹo: dùng script CI grep tất cả API nhạy cảm và đối chiếu với plist trước mỗi build.

Mẹo phủ nhanh: ưu tiên test các quyền theo mức rủi ro — quyền gắn với tiền/định danh (camera cho eKYC, sinh trắc học cho đăng nhập) phải test 100% ma trận; quyền phụ (thông báo khuyến mãi) có thể test rút gọn.

Bài tập thực hành

  • Dựng ma trận cho một app thật. Chọn một app bạn đang dùng (ví dụ ShopeeFood hoặc Momo). Cài trên một thiết bị test, dùng công cụ reset quyền ở Bước 5, và lập bảng ma trận cho quyền vị trí: liệt kê đủ 6 trạng thái (chưa hỏi, when-in-use full, when-in-use reduced, always, denied, denied vĩnh viễn) × 2 thời điểm (lần đầu, tắt-từ-Settings). Ghi lại hành vi thực tế mỗi ô.
  • Săn bug purpose string. Với một app Android bạn có file APK hoặc source, đọc AndroidManifest.xml, liệt kê mọi uses-permission, rồi kiểm chứng từng quyền có thực sự được tính năng nào dùng không. Tìm quyền "thừa".
  • Kịch bản denied-forever. Trên một thiết bị Android, cố tình từ chối quyền camera của một app eKYC hai lần để kích hoạt "Don't ask again", rồi thử tiếp tục luồng chụp ảnh. App có dẫn bạn ra Settings không, hay để bạn kẹt? Viết bug report nếu nó kẹt, kèm ảnh màn hình trạng thái quyền.

Tóm tắt

Permission không phải công tắc bật/tắt mà là một máy trạng thái nhiều nhánh, khác nhau giữa iOS và Android. Sức mạnh của một QA mobile chuyên nghiệp nằm ở chỗ dựng được Permissions Testing Matrix — giao của ba trục: quyền × trạng thái × thời điểm thay đổi. Ba câu chuyện của BeGo (crash vì denied), ngân hàng eKYC (kẹt vì denied-forever), và app giao đồ ăn (sai cước vì reduced accuracy) đều bắt nguồn từ những ô ma trận bị bỏ trống.

Hãy nhớ ba nguyên tắc cốt lõi: (1) luồng thất bại quyền quan trọng ngang luồng thành công — luôn test denied và denied vĩnh viễn; (2) trạng thái quyền có thể đổi bất cứ lúc nào, kể cả khi app chạy nền — đừng cache mù quáng; (3) mỗi quyền cần một purpose string tiếng Việt rõ ràng, và trên iOS thiếu nó là crash. Khi bạn có bảng ma trận đầy đủ và công cụ simctl/adb để reset trạng thái nhanh, việc test quyền chuyển từ "may rủi" thành "phủ đủ có hệ thống" — đúng phong cách của một QA đáng tin cậy.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi