Mobile Game Testing Strategy: Performance, Compatibility, and UX
Mobile games face a testing challenge that PC and console games don't: thousands of different devices, CPU/GPU combinations, OS versions, and screen sizes. A game that runs at 60fps on a Pixel 8 might choke on a mid-range Samsung from two years ago. A UI that's perfectly readable on an iPhone 15 might be unplayable on an older small-screen device.
This guide covers a practical mobile game testing strategy for studios shipping on iOS and Android.
Device Matrix Planning
You cannot test on every device. The goal is a matrix that covers:
- Your target audience's most common devices
- Known problematic hardware combinations
- OS version coverage
Building Your Matrix
# Device matrix builder based on your analytics
def build_device_matrix(analytics_data: list, max_devices: int = 20) -> list:
"""
Select test devices that maximize coverage of your user base.
analytics_data: list of {'device': str, 'os_version': str, 'sessions': int}
"""
# Sort by sessions (most common first)
sorted_devices = sorted(analytics_data, key=lambda d: d['sessions'], reverse=True)
matrix = []
covered_os_versions = set()
covered_gpu_families = set()
for device in sorted_devices:
if len(matrix) >= max_devices:
break
# Always include if new OS version or GPU family
is_new_os = device['os_version'] not in covered_os_versions
is_new_gpu = device.get('gpu_family') not in covered_gpu_families
if is_new_os or is_new_gpu or len(matrix) < 5:
matrix.append(device)
covered_os_versions.add(device['os_version'])
if device.get('gpu_family'):
covered_gpu_families.add(device['gpu_family'])
return matrix
# Example matrix categories
DEVICE_CATEGORIES = {
"flagship_ios": ["iPhone 15 Pro", "iPhone 15"],
"flagship_android": ["Pixel 8 Pro", "Samsung Galaxy S24"],
"mid_range_android": ["Samsung Galaxy A55", "Xiaomi Redmi Note 13"],
"low_end_android": ["Samsung Galaxy A15", "Tecno Pop 8"],
"tablet_ios": ["iPad Pro M4", "iPad 10th gen"],
"tablet_android": ["Samsung Galaxy Tab S9"]
}Priority Testing Tiers
Not all tests run on all devices. Use a tiered approach:
Tier 1 (P0) — Run on every build:
- Top 5 most common devices from your analytics
- The 2 lowest-spec devices you officially support
- One iOS and one Android flagship
Tier 2 (P1) — Run on release candidates:
- Full device matrix
- Both OS minimum versions
Tier 3 (P2) — Run quarterly:
- Extended matrix including edge cases
- Tablets, foldables, specific regional popular devices
Performance Testing
FPS Monitoring
# Using ADB to capture frame timing from Android
import subprocess
import re
import time
def capture_fps_data(package_name: str, duration_seconds: int = 60) -> dict:
"""Capture frame timing data from an Android device."""
# Reset surfaceflinger stats
subprocess.run(["adb", "shell", "dumpsys", "SurfaceFlinger", "--latency-clear",
f"SurfaceView[{package_name}]"])
time.sleep(duration_seconds)
# Get frame timing data
result = subprocess.run(
["adb", "shell", "dumpsys", "SurfaceFlinger", "--latency",
f"SurfaceView[{package_name}]"],
capture_output=True, text=True
)
# Parse frame durations
lines = result.stdout.strip().split('\n')
frame_times_ms = []
for line in lines[1:]: # Skip header
values = line.split()
if len(values) >= 3:
try:
desired_ns = int(values[0])
actual_ns = int(values[1])
frame_time_ms = (actual_ns - desired_ns) / 1_000_000
if 0 < frame_time_ms < 1000: # Filter outliers
frame_times_ms.append(frame_time_ms)
except ValueError:
pass
if not frame_times_ms:
return {"error": "No frame data captured"}
fps_values = [1000 / ft for ft in frame_times_ms]
fps_values.sort()
return {
"mean_fps": sum(fps_values) / len(fps_values),
"p5_fps": fps_values[int(0.05 * len(fps_values))], # 5th percentile (worst)
"p50_fps": fps_values[int(0.50 * len(fps_values))],
"p95_fps": fps_values[int(0.95 * len(fps_values))],
"frame_count": len(fps_values),
"dropped_frames": sum(1 for ft in frame_times_ms if ft > 33.3) # >33ms = dropped at 30fps
}
def test_gameplay_maintains_30fps_on_low_end():
# Navigate to a demanding scene
start_gameplay_scenario("crowded-battlefield-scene")
fps_data = capture_fps_data(package_name="com.yourcompany.yourgame",
duration_seconds=60)
# P5 FPS should be at least 24fps even in worst moments
assert fps_data["p5_fps"] >= 24, \
f"P5 FPS {fps_data['p5_fps']:.1f} below 24fps minimum on low-end device"
# Dropped frame rate should be under 5%
drop_rate = fps_data["dropped_frames"] / fps_data["frame_count"]
assert drop_rate < 0.05, \
f"Dropped frame rate {drop_rate:.2%} exceeds 5% threshold"Memory Testing
def get_memory_usage_mb(package_name: str) -> dict:
"""Get memory breakdown for a running Android app."""
result = subprocess.run(
["adb", "shell", "dumpsys", "meminfo", package_name],
capture_output=True, text=True
)
memory = {}
for line in result.stdout.split('\n'):
if 'TOTAL:' in line:
parts = line.split()
memory['total_pss_mb'] = int(parts[1]) / 1024
elif 'Native Heap:' in line:
parts = line.split()
memory['native_heap_mb'] = int(parts[2]) / 1024
elif 'Dalvik Heap:' in line:
parts = line.split()
memory['dalvik_heap_mb'] = int(parts[2]) / 1024
return memory
def test_no_memory_leak_over_session():
"""Memory should not grow unboundedly during a 30-minute play session."""
initial_memory = get_memory_usage_mb("com.yourcompany.yourgame")
# Play through multiple levels
for level in range(5):
play_through_level(level)
time.sleep(5) # Allow GC
final_memory = get_memory_usage_mb("com.yourcompany.yourgame")
memory_growth_mb = final_memory['total_pss_mb'] - initial_memory['total_pss_mb']
assert memory_growth_mb < 50, \
f"Memory grew by {memory_growth_mb:.1f}MB during session — possible leak"Battery and Thermal Testing
def get_battery_stats(package_name: str) -> dict:
"""Get battery consumption attributed to the game."""
subprocess.run(["adb", "shell", "dumpsys", "batterystats", "--reset"])
time.sleep(600) # Play for 10 minutes
result = subprocess.run(
["adb", "shell", "dumpsys", "batterystats"],
capture_output=True, text=True
)
# Parse battery drain percentage
lines = [l for l in result.stdout.split('\n') if package_name in l]
drain_pct = 0.0
for line in lines:
match = re.search(r'(\d+\.\d+)%', line)
if match:
drain_pct += float(match.group(1))
return {"battery_drain_10min_pct": drain_pct}
def test_battery_drain_acceptable():
stats = get_battery_stats("com.yourcompany.yourgame")
# Should not drain more than 10% in 10 minutes of active play
assert stats["battery_drain_10min_pct"] < 10, \
f"Battery drain {stats['battery_drain_10min_pct']:.1f}% in 10 min is too high"Touch Input Testing
# Using UIAutomator2 for Android touch testing
import uiautomator2 as u2
def test_tap_response_latency():
"""Touch-to-visual-feedback should be under 100ms."""
device = u2.connect()
# Take screenshot before tap
before = device.screenshot()
# Record tap time
tap_time = time.perf_counter_ns()
device.click(540, 960) # Tap center of screen
# Wait for visual change
timeout_ms = 200
start = time.perf_counter_ns()
while (time.perf_counter_ns() - start) / 1_000_000 < timeout_ms:
after = device.screenshot()
if images_differ(before, after, threshold=0.01):
response_ms = (time.perf_counter_ns() - tap_time) / 1_000_000
assert response_ms < 100, f"Touch response latency {response_ms:.1f}ms exceeds 100ms"
return
time.sleep(0.01)
pytest.fail("No visual response to touch within 200ms")
def test_swipe_gesture_recognized():
device = u2.connect()
initial_page = get_current_game_page(device)
# Swipe left (should navigate forward)
device.swipe(800, 960, 200, 960, duration=0.3)
time.sleep(0.5)
next_page = get_current_game_page(device)
assert next_page != initial_page, "Swipe gesture not recognized"Localization Testing
Mobile games ship globally. Test text rendering for all supported languages:
RTLLANGUAGES = {"ar", "he", "fa", "ur"}
LONG_TEXT_LANGUAGES = {"de", "fi", "nl"} # German/Finnish/Dutch strings are often 40-60% longer
def test_ui_doesnt_overflow_in_german():
device = u2.connect()
switch_language(device, "de")
navigate_to_main_menu(device)
# Check that no text is cut off
screenshot = device.screenshot()
# Use OCR to verify text is complete (requires pytesseract)
import pytesseract
text = pytesseract.image_to_string(screenshot, lang='deu')
assert "..." not in text, "Text truncated in German — UI overflow"
assert "…" not in text, "Text truncated in German — UI overflow"
def test_rtl_layout_for_arabic():
device = u2.connect()
switch_language(device, "ar")
navigate_to_main_menu(device)
# Verify menu buttons are on the correct side for RTL
settings_button = device(resourceId="com.yourcompany.yourgame:id/settings_button")
assert settings_button.info['bounds']['right'] < 200, \
"Settings button not on right side for RTL layout"Test Automation with Appium
from appium import webdriver
from appium.options import UiAutomator2Options
def get_appium_driver(device_udid: str) -> webdriver.Remote:
options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "Test Device"
options.udid = device_udid
options.app_package = "com.yourcompany.yourgame"
options.app_activity = "com.yourcompany.yourgame.MainActivity"
options.no_reset = True # Don't reinstall between tests
return webdriver.Remote("http://localhost:4723", options=options)
def test_tutorial_completes_successfully():
driver = get_appium_driver(device_udid=os.environ["ANDROID_DEVICE_UDID"])
try:
# Wait for game to load
WebDriverWait(driver, 30).until(
EC.presence_of_element_located((By.ID, "main_menu_play_button"))
)
# Start tutorial
driver.find_element(By.ID, "tutorial_start_button").click()
# Complete each tutorial step
for step in range(5):
next_btn = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "tutorial_next_button"))
)
next_btn.click()
# Verify tutorial complete screen
completion_text = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "tutorial_complete_title"))
)
assert "Complete" in completion_text.text
finally:
driver.quit()Mobile game testing is necessarily a blend of automation and manual testing on real hardware. Automate everything that can be automated — performance metrics, memory, touch response, localization overflow — and reserve manual testing for the human experience qualities that automation can't measure: does the game feel fun? Is the difficulty fair? Does the UI feel responsive?