marcprux
(Marc Prud'hommeaux)
March 2, 2026, 4:18pm
7
I'm happy to report that after an illuminating meeting with the @testing-workgroup I was able to implement support for this in the skip android test front-end:
main ← test-apk
opened 05:54PM - 01 Mar 26 UTC
This PR adds `--apk` mode to `skip android test`, providing two distinct ways to… run Swift tests on Android. The default mode pushes a test executable to the device and runs it via `adb shell`. The new `--apk` mode packages the tests into a real Android APK with a NativeActivity harness, giving them access to the full Android framework through JNI.
## APK mode: `skip android test --apk`
Packages the tests into a real Android APK and runs them as an installed app. Steps:
1. `swift build --build-tests --swift-sdk <triple> -Xlinker -shared -Xlinker -no-pie` cross-compiles the test target as a shared library (`.so`) instead of an executable
2. Collects `.so` dependencies (same three sources as default mode)
3. Copies the test `.so` (renamed to `lib<Package>Test.so`) and all dependency `.so` files into a local staging `lib/<abi>/` directory
4. Generates a test harness SwiftPM package in a temp directory with two targets:
- **CAndroid** (`test_harness.c`): implements `ANativeActivity_onCreate` which stores the activity pointer and spawns a `test_runner` pthread. The runner calls `redirect_stdio()` to pipe stdout/stderr through reader threads that forward each line to logcat via `__android_log_print`. It then calls `dlopen` on the test library, resolves `swt_abiv0_getEntryPoint` via `dlsym`, calls the getter to obtain the entry point, opens the event stream file if configured, and calls the Swift `run_swift_tests()` function. A `handle_test_record()` function writes JSON records to both stdout (which goes to logcat) and the event stream fd.
- **TestHarness** (`TestRunner.swift`): exports `@_cdecl("run_swift_tests")` which `unsafeBitCast`s the raw pointer to the [ST-0002](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0002-json-abi.md) `EntryPoint` type, creates a record handler closure that delegates to the C `handle_test_record`, and calls the entry point in an async Task
5. `swift build --swift-sdk <triple> --package-path <harness>` cross-compiles the harness, producing `libtest_harness.so`
6. Copies `libtest_harness.so` into the APK's `lib/<abi>/` alongside the test library
7. Generates an `AndroidManifest.xml` declaring a `NativeActivity` with `android.app.lib_name` set to `test_harness`
8. `aapt2 link` creates the unsigned APK from the manifest and `android.jar`
9. `zip -r -0` adds the `lib/` tree (all native libraries) into the APK
10. `zipalign` aligns the APK for efficient memory mapping
11. `keytool -genkeypair` generates `~/.android/debug.keystore` if it doesn't already exist
12. `apksigner sign` signs the APK with the debug key
13. `adb uninstall` removes any previous version of the test package
14. `adb install -t` installs the signed APK
15. `adb logcat -c` clears logcat
16. `adb shell am start -n <package>/android.app.NativeActivity` launches the test activity
17. `adb logcat -s SwiftTest:I -v raw` streams test output, forwarding each line to the host's stdout and watching for the `SWIFT_TEST_EXIT_CODE=<n>` sentinel
18. If `--event-stream-output-path` was specified: `adb pull` copies `/data/local/tmp/swift-test-events.jsonl` to the host, then `adb shell rm -f` cleans it up
19. `adb uninstall` removes the test APK (unless `--no-cleanup`)
Because the tests run inside a real Android app process with NativeActivity, they get a full JNI environment with access to the entire Android framework (Context, AssetManager, content providers, system services, etc.). The tradeoff is that resource bundles don't work: the test `.so` is loaded from the APK's `lib/` directory by the Android runtime, and Foundation has no support for resolving `Bundle.module` resources from an APK's native library path. Tests that load bundled resources at runtime will fail to find them.
## Default mode: `skip android test`
As a refresher, the pre-existing default test mode will compile the test target as an XCTest CLI executable and runs it directly on a shell on the Android emulator or device with the following steps:
1. `swift build --build-tests --swift-sdk <triple>` cross-compiles the test target as an executable (`<Package>PackageTests.xctest`)
2. Collects `.so` dependencies from three sources: build output artifacts, Swift runtime libraries from the SDK's dynamic lib path, and `libc++_shared.so` from the NDK sysroot
3. Discovers any `.resources` sidecar directories in the build output (e.g. `Module_TestModule.resources`)
4. `adb shell mkdir -p /data/local/tmp/swift-android/<package>-<uuid>/` creates a staging directory
5. `adb push` copies the executable, all `.so` files, `.resources` directories, and any `--copy` files to the staging directory
6. `adb shell cd '<staging>' && ./<Package>PackageTests.xctest` runs the tests. If the ELF binary's needed section includes `libTesting.so`, the executable is run a second time with `--testing-library swift-testing`, and exit code 69 (`EX_UNAVAILABLE` = no tests found) is treated as success
7. `adb shell rm -r` cleans up the staging directory (unless `--no-cleanup`)
Because the executable runs from a flat directory on the filesystem, `Bundle.module` resource lookup works normally: the `.resources` bundles are right there alongside the binary. The tradeoff is that there is no Android application context, no JVM, and no JNI environment, so tests that need Android framework APIs will not work in this mode.
## Comparison
| | Default mode | APK mode (`--apk`) |
|---|---|---|
| Test binary | XCTest CLI executable | Shared library in APK |
| Execution | `adb shell` command line | NativeActivity in a real Android app |
| Test frameworks | XCTest + Swift Testing (two passes) | Swift Testing only |
| Android APIs / JNI | Not available | Full access |
| Resource bundles | Work (`.resources` dirs pushed alongside) | Not available (Foundation Bundle limitation) |
| Output | stdout/stderr over adb shell | stdout/stderr piped to logcat |
The whole process is pretty involved and useful only for Android development, so I'm skeptical about whether it makes sense to try to roll this into SwiftPM itself. But I have some vague half-baked ideas around how something like this might be included as part of a cross-compilation SDK itself, something like:
swift test --sdk aarch64-unknown-linux-android28 --sdk-testing-plugin-flags package-as-apk
How might this work? Would the cross-compilation SDK have a plugin-style shared library or command executable that swift test would delegate to at some point? Perhaps it would work like a macro, where the tooling would be built on-demand (to avoid needing to ship host-specific binaries with the Swift SDK)? I'll ponder this, along with other useful scenarios and applications like general emulator/container/external device support.
Update : I've added my thoughts to the related thread about this at Expand `swift test` to enable testing with cross-compilation target triples · Issue #9740 · swiftlang/swift-package-manager · GitHub
1 Like