Unit test your NDK library integration | by Marco Signoretto | Sep 2022

Image by andrekheren from Pixabay

Have you ever been in a situation where you have some part of your application written in C/C++ and you would like to test this functionality directly in a unit test without running a slow androidTest on an emulator?

If the answer is “yes”, this article is for you.

This article was born as a reference for myself to keep a record of a piece of work I did and later on I refactored removing the native code completely.

As Android engineers, we know that native code is expensive in terms of: app size (adding any native code will increase AAB or APK size no matter how much code you add there ), and multiple manufacturers that might implement native APIs differently. However, having some piece of your code that is doing some heavy lifting written in C/C++ can squeeze some extra juice out of the target device giving to the user a better experience.

Being able to unit test such cases is important and having those tests running as fast as possible is also important to keep a fast feedback loop while working on such projects.

So what are we going to do? We will use a playground project of a simple application that converts radians to degrees, where the function for the conversion will be implemented in C++ and exposed to the app code through JNI calls.

The app will look something like:

During the reading I will guide you through the steps to build the sample app, this will then move to the testing topic where we will build the native library not only for the mobile target but also to target your development machine or CI, this will enable us to unit test the conversion function without the requirement of an emulator and all the drawbacks of it.

This article will describe the process on a Mac machine but this will work on Linux and windows with slightly different changes that we will not discuss.

First you need to have some tools installed in your machine. You need CMake installed into your machine to be able to build the library for your development machine later in the reading.

On Mac you can easily install it using brew install cmake.

Assuming you already have a sample app ready, create a new module ( you can use the Native Android Library from the wizard ), we will call it utils in our example.

The wizard will create a bunch of files and classes, your module will look similar to:

Open the CMakeList.txt file and look for the code in the snippet below:

and remove the ${log-lib} part since we are not going to use it, remove also the find_library block since we do not need to log anything from out native code:

At this point let’s add our native code to the project, we will use the code for used in PictureARAndroid to do the radian to degree conversion.

PictureARAndroid is an actual interesting project where having the native code gives that extra boost on performance needed to keep the image frame process in real-time

We will copy the function declaration into a new file called utils.h:

And we will create an implementation file for the same in utils.cpp:

And we will add the new implementation file utils.cpp in the add_library block of the CMakeLists.txt as shown in the snippet below:

Once this is done we can expose the function through a JNI call in the radiantstodegrees.cpp. To facilitate the process we can create a new kotlin object called Utils.kt and define a function as:

Do not forget to add the System.loadLibrary("radiantstodegrees") on the init of the object in order to be sure the native library is loaded before calling the function. At the end you should have something like:

And ask Android Studio to generate the weird JNI name for us. ( alt + enter on Mac ).

Note: you can generate the name manually as well but I discourage it, since for this simple method you can see that the name is Java_<snake_case_package> but if you start to add method overloads you will be asked to specialize in the native binding, take those 2 functions as an example:

external fun toDegree(radiants: Double): Double

external fun toDegree(radiants: Double, s: String): Doublethe native functions need to change toJava_com_msignoretto_radiantstodegrees_utils_Utils_toDegree__D(JNIEnv *env, jobject thiz, jdouble radiants)`Java_com_msignoretto_radiantstodegrees_utils_Utils_toDegree__DLjava_lang_String_2(JNIEnv *env, jobject thiz, jdouble radiants, jstring s)where you can see the extra __D used to specialize the function.

At this point we can jump into the JNI function created and, after we added the #include "utils.h" At the top of the file, we can implement the JNI function to delegate the logic to the to_degree function we defined above.

Now that the library is ready we can include the new module in our app build.gradle.kts adding:

implementation(project(":utils"))

And we can edit MainActivity to have something like:

I used Compose here as an example it is not relevant for the article though.

You can now build the app and check that everything work as expected, try to type 1 radian and verify that it will be converted to approx 57.29 degrees.

We all love testing if not, get out of here. Joking! So let’s assume we have this test

If you try to write the above Unit test to test that function you will get something like:

If you read the error message you can see:

no radiantstodegrees in java.library.path

Necause the test is looking for the library called radiantstodegrees as if it is a library installed on the development machine not on the Android device itself.

So here the classical approach is to switch to an instrumentation test, so let’s try to move it in the androidTest folder, now the test will run on the device. But check how long it takes to run, on my machine it took 1m 1s

(╯°□°)╯︵ ┻━┻.

Can we do better for those cases? Ohhhh yes we can.

The way we can make those tests run faster is building the native library with our development machine as the target and let the Unit tests run on our local machine instead of a real or emulated device.

First we need to run CMake on our machine to generate the make file that will be used later to generate the binary itself.

Let’s automate this within a script to make it easier, the script will have a content similar to:

To make the script more robust in case you have jenv installed you can have and extra script that runs the previous script in the jenv environment if JAVA_HOME was not found which is usually the case if you use jenv.

Here things start getting interesting, if you run the script you will get and error like the following:

The compiler is not able to find the definition for jni.hto fix that for MacOs we have to tell CMake to do some extra setup in case of apple machines, so let’s add inside our CMakeLists.txt after the project entry:

After this change you should be able to execute the script and see now the libradiantstodegrees.dylib inside your utils module, important here is to add this file to your .gitignore.

The reason why we generated the binary in that precise location is that if you check the error we got the first time we run the unit tests you can notice that the library lookup was executed in different locations and the one where we are generating the library now was one of those.

Now we finally are at the pivot point, do you remember the old poor failing Unit test that we moved to androidTest folder, it is time to get it back to the test folder and run it again.

Now the outcome is very different from the previous result:

The time to build and test it is down to 3s respect to the previous 1m 1snot bad right?.

Ideally we do not want to run the script manually so let’s add a Gradle task to automate this process.

Now that you understood how to create the binary for you local machine, and that you have a script that will generate all the things you need, it is time to create a gradle task that runs the script automatically before executing the test.

On the root build.gradle.kts add the following snippet:

And then you can make your testDebugUnitTest task depending on the native build adding:

This will rebuild the native binary every time and can be further optimized to avoid that if the binary was already build and nothing changed on the native code

In this way you will be sure that a native build of your library targeting your development machine will be ready before running the test.

Don’t forget to update your clean task to include the .dylib you created with the localNativeBuild to be able to have a proper fresh start with something similar to:

Here you can find some performance indications of average performances of the different approaches:

  • Local machine binary build time without cxx cache: 3.958s average
  • Local machine binary build time with cxx cache: 0.974s average
  • Instrumentation test without cache and emulator up and running: build+test = 32.37s average
  • Instrumentation test with cache and emulator up and running: build+test = 11.9s average
  • Unit test local machine binary pre-built: build+test = 2.6s average
  • Unit test local machine binary NOT pre-built: build+test 3.720s average

As usual there are no silver bullets. Compiling the library for your development machine or CI and unit test against this implementation can lead to unexpected issues, since the Android implementation and your host one might be slightly different and it can cause you troubles.

Another aspect that you should keep in mind is that you might not be able to use this approach at all if, for example, your native implementation is using a lot of Android specific native APIs. For those cases you can use the approach where you can define different includes based on your target machine using an approach similar to what has been done for the jni.h header file, here you can do a lot, you can even include different implementations based on the target to implement the missing functionalities with noop implementations or fakes.

Now you know you have several options to write tests to cover your native code, the right one for your case can be influenced by the factors above and the trade-off between speed and consistency between the logic you test and the logic you run in production is your call.

If you have a use case where you cannot get rid of the NDK implementation, my suggestion is to structure you code in a way where you can unit test with the machine binary most of the logic (better if this logic is extracted in pure functions) and use the expensive androidTest only for the cases where you don’t really have an option.

You might also consider playing with the CMakeList.txt and avoid compilation of the sources you are not gonna use in your unit tests to save some time, but again be conscious on what you are compromising here.

  • The Sample App used in this article is available HERE

– Marco Signoretto

Leave a Comment