TestPilot enables testing any Android APK on standard JVM without emulators or physical devices. By converting DEX bytecode to JVM bytecode and providing an Android API shim layer, developers can run fast, reliable UI tests directly on their development machines or CI servers.
"Test any APK, anywhere JVM runs"
Input: APK file → Output: Test results on pure JVM
val session = TestPilot.load("app.apk").launch()
// Tap by coordinates
session.tap(100f, 200f)
// Tap by view ID
session.tap(R.id.login_button)
// Lifecycle control
session.pause().resume().stop().destroy()
// Screenshot (using layoutlib for pixel-perfect rendering)
val screenshot = session.takeScreenshot("""
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World" />
</LinearLayout>
""")
ImageIO.write(screenshot, "PNG", File("screenshot.png"))Replace Maestro + Emulator with pure JVM execution for faster, more reliable testing.
Current State:
┌─────────────┐ ┌─────────┐ ┌───────────────────┐
│ Test Cases │ ──▶ │ Maestro │ ──▶ │ Emulator / Device │
└─────────────┘ └─────────┘ └───────────────────┘
│
Slow startup, resource heavy, CI struggles
Target State:
┌─────────────┐ ┌───────────────┐ ┌───────────────┐
│ Test Cases │ ──▶ │ TestPilot SDK │ ──▶ │ App Simulator │
└─────────────┘ └───────────────┘ └───────────────┘
│ │
APK as input Pure JVM, instant startup
| Pain Point | Current State | With TestPilot |
|---|---|---|
| CI Startup Time | Emulator cold start 30-60s | JVM process < 2s |
| Parallelization | Limited by KVM/machine resources | Dozens of instances on single machine |
| Flakiness | System animations, popup interference | Fully controlled environment |
| Debug Capability | adb logcat log retrieval | Direct breakpoints, same process |
| Input | Source code + build | Just APK |
┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐
│ APK │ ──▶│ Unpack │ ──▶│ DEX → JVM │ ──▶│ Bytecode │ ──▶│ Run │
│ │ │ │ │ (dex2jar) │ │ Rewrite │ │ on JVM │
└─────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘
│ │
▼ ▼
resources/ android.view.View
AndroidManifest.xml ↓
io.johnsonlee.testpilot.simulator.View
- APK Unpacking: Extract classes.dex, resources, AndroidManifest.xml
- DEX → JVM Conversion: Convert Dalvik bytecode to JVM bytecode using dex2jar/enjarify
- Bytecode Rewriting: Replace
android.*references with TestPilot shim classes - Execution: Load transformed classes and run on standard JVM
- Android uses DEX/ART bytecode format, JVM uses class files
- Solution: Use proven tools (dex2jar, enjarify) for conversion
- Risk: Some edge cases may not convert perfectly
android.*packages rely heavily on native implementations- Solution: Implement shim layer that mimics Android API behavior
- Priority: Focus on UI-related APIs first (View, Activity, Resources)
- Many APKs contain .so native libraries
- These cannot run on standard JVM
- Solution: Stub/mock JNI calls, or provide pure-Java alternatives
- Scope limitation: Apps heavily dependent on native code may not be fully testable
- Binary XML parsing (AndroidManifest, layouts)
- resources.arsc qualifier resolution (density, locale, night mode)
- R.java constant mapping
┌───────────────────────────────────────────────────────────────────┐
│ TestPilot SDK │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ APK Loader │ │ Assertions │ │ UI Actions │ │
│ │ │ │ │ │ (tap/swipe/input) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ App Simulator │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Transformed APK Code │ │
│ │ (android.* → io.johnsonlee.testpilot.simulator.*) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Android API Shim Layer │ │
│ │ Activity | View | Resources | Intent | ... │ │
│ └─────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ Rendering (Layoutlib) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Android Official Layoutlib │ │
│ │ Pixel-perfect rendering on JVM │ │
│ └─────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ JVM (JDK 21+) │
└───────────────────────────────────────────────────────────────────┘
TestPilot uses a dual-layer architecture to achieve both accurate behavior simulation and pixel-perfect rendering:
| Layer | Technology | Purpose |
|---|---|---|
| Behavior Simulation | Bytecode Rewriting + Shim | Activity lifecycle, touch events, view hierarchy traversal |
| Rendering | Android Layoutlib | Screenshot capture, visual regression testing |
Why this approach?
- Shim Layer: Fast, lightweight simulation for behavior testing (lifecycle, touch dispatch)
- Layoutlib: Android's official rendering library used by Android Studio, ensures pixel-perfect screenshots that match real device rendering
This is similar to how Paparazzi (by Cash App) works for screenshot testing.
| Component | Approach |
|---|---|
| APK Processing | Unzip + dexlib2 + ASM bytecode rewriting |
| Activity Lifecycle | State machine + callback chain |
| View System | measure/layout/draw pipeline implementation |
| LayoutInflater | Binary XML parsing + reflection-based construction |
| Resources | resources.arsc parsing + qualifier resolution |
| Event Dispatch | TouchEvent simulation via View hierarchy |
| Rendering | Android Layoutlib (official Android rendering library) |
- Pure Kotlin/Java APKs
- Standard UI components (View, ViewGroup, common widgets)
- Activity lifecycle testing
- UI interaction testing (tap, swipe, text input)
- Layout verification
- Native code (JNI/.so libraries)
- Hardware features (Camera, Bluetooth, sensors)
- System services requiring real Android (ContentProvider with system data)
- Compose UI (future consideration)
class ActivityLifecycleTest {
@Test
fun `onCreate should be called before onStart`() {
val calls = mutableListOf<String>()
val activity = TestActivity { calls += it }
activityController.create().start()
assertThat(calls).containsExactly("onCreate", "onStart")
}
}class ApkLoadingTest {
@Test
fun `should load and launch simple APK`() {
val app = TestPilot.load("test-fixtures/simple-app.apk")
app.launch("com.example.MainActivity")
assertThat(app.currentActivity).isNotNull()
assertThat(app.findView<TextView>(R.id.title).text).isEqualTo("Hello")
}
}class LayoutRenderingTest {
private val snapshots = SnapshotManager(File("src/test/snapshots"))
@Test
fun `LinearLayout vertical should match Android rendering`() {
val session = TestPilot.load("test-fixtures/layout-test.apk").launch()
val screenshot = session.takeScreenshot("""
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World" />
</LinearLayout>
""")
// Assert against golden image with 1% tolerance
screenshot.assertMatchesSnapshot(snapshots, "layout_activity", tolerance = 0.01)
}
@Test
fun `record new golden image`() {
val session = TestPilot.load("app.apk").launch()
val screenshot = session.takeScreenshot(layoutXml)
// Auto-record if golden doesn't exist
screenshot.assertMatchesSnapshot(snapshots, "new_screen", recordIfMissing = true)
}
}class ViewMeasureSpecTest {
@Test
fun `MeasureSpec packing should match Android`() {
val androidResults = loadGoldenData("measure_spec_cases.json")
androidResults.forEach { case ->
val jvmResult = MeasureSpec.makeMeasureSpec(case.size, case.mode)
assertThat(jvmResult).isEqualTo(case.expected)
}
}
}- Basic Activity lifecycle state machine
- Basic View/ViewGroup implementation
- Simple Canvas rendering
- APK unpacking (extract DEX, resources, manifest)
- DEX → JVM conversion (using dexlib2 + ASM)
- Basic bytecode rewriting framework (android.* → shim mapping)
Result: Successfully loaded a real APK (7.5MB, 3713 classes) with 0 conversion errors
- DEX instruction to JVM bytecode translation
- AndroidManifest.xml binary parsing
- Resources.arsc parsing (resource ID mapping)
- LayoutInflater with binary XML
- Common widgets (TextView, Button, ImageView, EditText, ScrollView, ProgressBar, etc.)
- Touch event dispatch
- TestPilot SDK basic API
Result: Full APK loading pipeline with touch event dispatch. Supports tap interactions and event listeners.
- Layoutlib Integration - Android official rendering library
- Add layoutlib dependencies
- Create renderer module
- Implement
takeScreenshot()API - Visual comparison utilities (ImageComparator, SnapshotManager, assertions)
- Complete Resources system with qualifiers
- Fragment support
- RecyclerView
- ViewPager
- More widgets coverage
Goal: Test medium-complexity real-world APKs with pixel-perfect screenshots
-
android.view.WindowAPI support (statusBarColor, navigationBarColor, flags, decorView, etc.) - Performance optimization (caching, incremental processing)
- Comprehensive widget support
- CI/CD integration guide
- Documentation & examples
- Edge case handling
| Module | Estimated Effort |
|---|---|
| APK Processing | 1-2 days |
| DEX → JVM Conversion | 1 day |
| Bytecode Rewriting | 2-3 days |
| Android API Shim | 15-25 days |
| Rendering Backend | 3-5 days |
| TestPilot SDK | 3-5 days |
| Total | 25-40 days |
Note: Shim layer is the long tail - core 20% APIs cover 80% of use cases.
- Fast UI Testing: Test any APK on pure JVM, no emulator needed
- CI/CD Optimization: Parallel test execution without KVM overhead
- Quick Verification: Instant APK testing without device deployment
- Reliable Tests: Eliminate flakiness from system animations and popups
- Better Debugging: Same-process debugging with standard IDE tools
- Robolectric: Shadow-based Android testing (requires source, TestPilot takes APK)
- Paparazzi: Screenshot testing using Layoutlib (requires source, TestPilot takes APK)
- Android Layoutlib: Official Android rendering library used by Android Studio
- dex2jar/dexlib2: DEX to JAR conversion
- Maestro: Test orchestration API inspiration
- ASM: Bytecode manipulation library
| Approach | Pros | Cons | Decision |
|---|---|---|---|
| DEX → JVM conversion | Mature tools, cacheable | Some conversion edge cases | ✅ Chosen |
| DEX interpreter | 100% fidelity | Huge effort, slow execution | ❌ Too complex |
| Source-based (like Robolectric) | Simple | Can't test arbitrary APK | ❌ Different goal |
| Optimized emulator | Real Android | Still slow, needs KVM | ❌ No differentiation |
TestPilot - Test any Android APK, anywhere JVM runs