SureshJoshi.com ▼

Android JUnit Testing for Native Libraries


2017-11-19

Has this ever happened to you? You’re being a good little developer, writing tests for your native libraries, or even, writing tests BEFORE you write code - you press run, and BAM! java.lang.UnsatisfiedLinkError

Goddamn it… Every time…

It happened to me so often, that I finally decided to do something about it.

Stacktrace of an Android UnsatisfiedLinkError

Apparently, it has happened to other people too!

Unit test Java class that loads native library Robolectric tanks on Application objects that load JNI libraries. Can I get a workaround? Loading a native library in an Android JUnit test

Workaround it

The easy workaround is to change your unit tests to tests that run on the Android emulator (or on physical hardware). The big problem here is that those tests are pathetically slow.

The workaround we all want would be if these tests ran on Robolectric - but so far, seemingly no dice.

What’s the scoop?

Before we try to solve this, let’s figure out what the problem is…

When you run a JUnit test in Android Studio, what is actually happening is that your code is compiled for and run on your host machine (e.g. my Macbook or Surface Pro)

“This testing approach is efficient because it helps you avoid the overhead of loading the target app and unit test code onto a physical device or emulator every time your test is run. Consequently, the execution time for running your unit test is greatly reduced”

https://developer.android.com/training/testing/unit-testing/local-unit-tests.html

A caveat to running these kind of tests is that they can’t rely on the Android framework, because the Android framework doesn’t natively exist on your host computer. If your code has Android framework dependencies, you need to:

Back to Native Tests

None of what I described explains why that UnsatisfiedLinkError pops up. Not explicitly anyways.

As I said, when you run a standard JUnit test from Android Studio, your code is run on your HOST machine. When you build native libraries through the Android NDK, you’re creating libraries for Android, not Windows or OSX. So that means running a JUnit test tries to link against native libraries compiled for a different platform.

Have you ever seen Mac apps running against Windows DLLs? Yeah, just doesn’t happen. Same problem conceptually.

What do we do?

Well, for starters, you need to compile your native libraries for your host platform (Windows, Linux, Mac). The Android NDK builds libraries for the Android platform (.so files - might also work on Linux), and this is why there are no issues running instrumented tests (because it loads up an Android instance).

To get the low-level, hella fast JUnit tests running, you need to support your host JVM. On Windows, this might be building DLLs, on Apple, it means building .dylibs (assuming we’re loading shared libraries).

How do we do it?

I’m excluding non-Android Studio solutions for the sake of brevity.

I’ve got a basic sample of this in my android-ndk-swig-example repo in the junit-native branch.

Basically, in my CMakeLists, I added an Apple caveat:

# Need to create the .dylib and .jnilib files in order to run JUnit tests
if (APPLE)
    # Ensure jni.h is found
    find_package(JNI REQUIRED)
    include_directories(${JAVA_INCLUDE_PATH})
...
endif()

And then I make sure Gradle runs for unit tests, but using the Mac build system (not NDK). I hacked this together - I rarely touch Gradle, so I really need to learn what the cleaner ways of doing something like this are.

def osxDir = projectDir.absolutePath + '/.externalNativeBuild/cmake/debug/osx/'

task createBuildDir() {
    def folder = new File(osxDir)
    if (!folder.exists()) {
        folder.mkdirs()
    }
}

task runCMake(type: Exec) {
    dependsOn createBuildDir
    workingDir osxDir // Jump to future build directory
    commandLine '/usr/local/bin/cmake' // Path from HomeBrew installation
    args '../../../../' // Relative path for out-of-source builds
}

task runMake(type: Exec) {
    dependsOn runCMake
    workingDir osxDir
    commandLine 'make'
}

project.afterEvaluate {
    // Not sure how much of a hack this is - but it allows CMake/SWIG to run before Android Studio
    // complains about missing generated files
    // TODO: Probably need a release hook too?
    javaPreCompileDebug.dependsOn externalNativeBuildDebug
    if (org.gradle.internal.os.OperatingSystem.current().isMacOsX()) {
        javaPreCompileDebugAndroidTest.dependsOn runMake
    }
 }

CAVEAT TIME!!!

When you use this method, you’re ‘technically’ not testing the NDK-generated libs. You’re testing the same code, but compiled using a different compiler (msvc, xcode, gcc, clang, whatever you use on the host).

What this means practically is that most of the test results will be valid - except when you run into problems caused by each compiler’s quirks, or STL implementations, etc… This isn’t as bad as it was 10+ years ago, but you can’t say with 100% certainty that the results of JUnit testing with host libs is identical to the Android libs. You CAN say that it’s reasonably close, though.

Then again, unless you’re running your native unit tests using the Android NDK for EACH supported architecture, you also can’t say anything about certainty either… So take what you will from that.

An overkill approach (but really cool if automated) would be to write your native unit tests however you do them (Google Test, Catch, etc), then compile and run your native libraries and unit tests with the Android NDK per each architecture. This provides your C/C++ test coverage across your potential target architectures.

Block diagram showing CPP test coverage

From here, you could use the aforementioned host libs with JUnit to rapidly unit test your JNI layer interacting with your native libraries.

Block diagram showing JUnit test coverage

In your CI system, you should still probably run these same unit tests - but as Android Instrumentation tests (or something else that runs an emulated Android environment).

Block diagram showing instrumentation test coverage

As with everything, wherever you have an interface, you can create mocks - but at some point, you’ll need system/functional/integration tests too.