Mobile Game Testing Strategy: Performance, Compatibility, and UX

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?

Read more