NDKを導入したAndroidアプリにおいてテストをどう書くか?
KotlinでもRustでもテストを書くことができるので、基本的には適切な方にテストコードを記述すればよいのですが、問題はKotlinのテストコードからRustで書いたネイティブ関数を実行しなければならない場合です。
テストコードの実行自体にネイティブコードが必要な場合はもちろんですが、テストコードが100%Kotlinで書かれていても、そのテスト対象の一部でネイティブ関数が実行される場合、ネイティブライブラリをロードする必要があります。
というわけで、テスト中にネイティブ関数を実行できる環境を作ってみます。
この記事では「Androidアプリ内の複数のモジュールにRustを導入する」で構築した開発環境を前提にしますが、ネイティブ開発言語にRustを採用するAndroidアプリなら概ね同じことが言えると思います。
この記事の内容は私が趣味の個人開発で行ったものです。
私の所属企業でこの手法を採用しているということではありませんので、あしからず。
とりあえずテストを書いてみる
以下のclassを仮定します。
package com.example.rust.featurea
class UltimateQuestion {
fun answer(): Int = answerNative()
private external fun answerNative(): Int
}
use jni::JNIEnv;
use jni::objects::JObject;
use jni::sys::jint;
#[no_mangle]
extern "C" fn Java_com_example_rust_featurea_UltimateQuestion_answerNative(
_env: JNIEnv,
_obj: JObject
) -> jint {
42
}
いつもの感覚でテストコードを書いてみます。
class UltimateQuestionTest {
@Test
fun answer_isCorrect() {
val ultimateQuestion = UltimateQuestion()
assertEquals(42, ultimateQuestion.answer())
}
}
実行するとUnsatisfiedLinkErrorがスローされます。
java.lang.UnsatisfiedLinkError: 'int com.example.rust.featurea.UltimateQuestion.answerNative()'
at com.example.rust.featurea.UltimateQuestion.answerNative(Native Method)
at com.example.rust.featurea.UltimateQuestion.answer(UltimateQuestion.kt:4)
at com.example.featurea.UltimateQuestionTest.answer_isCorrect(UltimateQuestionTest.kt:12)
慣れている方はお察しかと思いますが、テストを実行する前にネイティブライブラリをロードしなければなりません。
大袈裟な。ロードすればいいだけじゃん。と思って System.load
を書こうとしたところで気づくはずです。
ロードすべきライブラリファイルが存在しないのです
Rust Android GradleプラグインによってAndroid用の動的ライブラリは生成されていますが、テストが実行されるのは開発PC上です。開発に使用しているPC用の動的ライブラリを生成する必要があります。
テスト用ライブラリファイルを生成する
cargo build
を実行すれば開発PCをターゲットにしたライブラリファイルが生成されます。
なので、テストを実行する前に毎回 cargo build
を実行させるようにすればOKです。
+ fun isXterm() = System.getenv("TERM")?.startsWith("xterm") ?: false
+
+ subprojects {
+ tasks.register<Exec>("cargoJniTestBuild") {
+ workingDir = rootProject.projectDir
+
+ val args = mutableListOf("cargo", "build")
+
+ if (isXterm()) {
+ args += "--color=always"
+ }
+
+ commandLine(args)
+ }
+
+ tasks
+ .matching { it.name.matches(Regex("compile(Debug|Release)UnitTestKotlin")) }
+ .configureEach { dependsOn("cargoJniTestBuild") }
+ }
テスト前にライブラリファイルをロードする
テスト前に必ずcargo buildが実行されるのでテストコードが実行されるときには必ずライブラリファイルは存在します。
なのでテストのコンストラクタで System.load
を実行できるようになりました。
class UltimateQuestionTest {
+ init {
+ System.load("/home/wcaokaze/Projects/wcaokaze/rustexample/target/debug/librust_example.so")
+ }
@Test
fun answer_isCorrect() {
val ultimateQuestion = UltimateQuestion()
assertEquals(42, ultimateQuestion.answer())
}
}
これで一旦テストは通るようになるのですが、あまりよくないですね。私の作業環境のパスが直接書かれています。
他の人がリポジトリをcloneしてテストを実行すると失敗することが予想できます。CIでも失敗するでしょう。
local.propertiesにパスを記述する
作業環境によって変わる値はlocal.propertiesに記述するのが通例です。
と言っても、local.propertiesに記述したパスをどうやってテストコードから読み込むのかという問題があります。
System.load(???)
少々回りくどいですが、Gradleでlocal.propertiesを読み込み、その値をGradleでKotlinファイルに書き込み、そのKotlinファイルをコンパイル対象に加えます。
おそらく複数のモジュールで必要になるので、「テスト用の便利な関数を集めたモジュール」を用意して、そこに「テスト用の動的ライブラリをロードする関数」を作ることにします。
include(":app")
include(":modules:featureA")
include(":modules:featureB")
include(":modules:featureC")
+ include(":modules:testUtils")
import java.util.Properties
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.jetbrains.kotlin.android)
}
android {
:
:
}
dependencies {
}
val generatedSrcDir = layout.buildDirectory.file("generated/src").get().asFile
kotlin {
sourceSets {
named("main") {
kotlin.srcDir(generatedSrcDir)
}
}
}
tasks.register("generateLibPathProp") {
doFirst {
val packageName = "com.example.rust.testutils"
val packageDir = File(generatedSrcDir, packageName.replace(".", File.separator))
val file = File(packageDir, "BuildEnv.kt")
if (!packageDir.exists() && !packageDir.mkdirs()) {
throw GradleException("can not generate source: $file")
}
val properties = Properties()
properties.load(rootProject.file("local.properties").bufferedReader())
val value = properties.getProperty("rust.targetFile")
?: throw GradleException(
"No path for native lib is specified. Add rust.targetFile in local.properties " +
"(e.g. `/home/wcaokaze/Projects/wcaokaze/rustexample/target/debug/librust_example.so` " +
"on Linux)"
)
file.writeText(
"""
package $packageName
internal val testNativeLibFile = "$value"
""".trimIndent()
)
}
}
tasks.configureEach {
if (name.matches(Regex("compile(Debug|Release)Kotlin"))) {
dependsOn("generateLibPathProp")
}
}
package com.example.rust.testutils
fun loadTestNativeLib() {
System.load(testNativeLibFile)
}
コードは長いですがやっていることは難しくないので説明は割愛します。
local.propertiesにパスを記述して早速テストコードで使ってみましょう。
私はUbuntuで開発しているので動的ライブラリファイルとしてsoファイルが生成されていますが、他の環境では違う形式のファイルの可能性もあるのでお気をつけて
+ rust.targetFile=/home/wcaokaze/Projects/wcaokaze/rustexample/target/debug/librust_example.so
dependencies {
+ testImplementation(project(":modules:testUtils"))
}
+ import com.example.rust.testutils.loadTestNativeLib
class UltimateQuestionTest {
init {
- System.load("/home/wcaokaze/Projects/wcaokaze/rustexample/target/debug/librust_example.so")
+ loadTestNativeLib()
}
@Test
fun answer_isCorrect() {
val ultimateQuestion = UltimateQuestion()
assertEquals(42, ultimateQuestion.answer())
}
}
これで環境に依存するコードがリポジトリから消滅し、テストも適切に実行できるようになります。
テストの再実行を強制する
この作業が必要な理由をご理解いただくため、一度ターミナルからGradleを叩いてテストを実行します。
$ ./gradlew check
Cargoの出力とかがいろいろ出たあと、 BUILD SUCCESSFUL と表示されます。テストが通っています。
Rustコードを書き換え、テストが通らない状態にします。
#[no_mangle]
extern "C" fn Java_com_example_rust_featurea_UltimateQuestion_answerNative(
_env: JNIEnv,
_obj: JObject
) -> jint {
- 42
+ 3
}
再度テストを実行します。
$ ./gradlew check
Cargoの出力とかがいろいろ出たあと、 BUILD SUCCESSFUL と表示されます。 テストが通ってしまっています。
普通は一度テストが通ったあとKotlinコードが書き換えられていなければもう一度実行してもテストは通るので、二回目の実行は省略されているのですね。
しかしもうお分かりの通り、私達の環境ではKotlinコードが書き換えられていなくてもRustコードが書き換えられる可能性があります。
なのでテストはスキップさせず、再実行させなければなりません。
ちなみにAndroid Studio (IntelliJ IDEA)からテストを実行するとスキップされず必ず実行されます。
とはいえCIなどAndroid Studioを使わずテストを実行することはあるため、Android Studioユーザーのアナタも無関係ではないです。
理想的にはRustコードが書き換えられたときだけ再実行させればよいのですが、それはかなり難しいことなので、単純にテストはスキップさせず必ず実行するようにします。
subprojects {
tasks.register<Exec>("cargoJniTestBuild") {
workingDir = rootProject.projectDir
val args = mutableListOf("cargo", "build", "--features", "jni-test")
if (isXterm()) {
args += "--color=always"
}
commandLine(args)
}
tasks
.matching { it.name.matches(Regex("compile(Debug|Release)UnitTestKotlin")) }
.configureEach { dependsOn("cargoJniTestBuild") }
+ tasks
+ .matching { it.name.matches(Regex("test(Debug|Release)UnitTest")) }
+ .configureEach { outputs.upToDateWhen { false } }
}
upToDateWhen
には本来テストをスキップする条件を記述します (例: upToDateWhen { code.isNotModified() }
) が、スキップさせないので upToDateWhen { false }
とします。
これでテストがきちんと失敗するようになります。
$ ./gradlew check
(中略)
> Task :modules:featureA:testDebugUnitTest FAILED
com.example.rust.featurea.UltimateQuestionTest > answer_isCorrect FAILED
java.lang.AssertionError at UltimateQuestionTest.kt:17
また、テストがスキップされなくなることにより、場合によっては開発のスタイルを変えなくてはならないかもしれません。
私はcommitする前に毎回一度gradle checkを叩いているのですが、これによって毎回3分ほどかかるようになりました。
テストコード自体にネイティブコードが必要な場合
稀な話ではあると思いますが、テストコード自体にネイティブコードが必要な場合についても考えておきます。
むちゃくちゃな例ですが結果を返却せずにグローバル変数に格納する関数があったとします。
package com.example.rust.featurea
class UltimateQuestion {
external fun calcAnswer()
}
use std::sync::Mutex;
use jni::JNIEnv;
use jni::objects::JObject;
pub static ANSWER: Mutex<Option<i32>> = Mutex::new(None);
#[no_mangle]
extern "C" fn Java_com_example_rust_featurea_UltimateQuestion_calcAnswer(
env: JNIEnv,
obj: JObject
) {
let mut lock = ANSWER.lock().unwrap();
*lock = Some(42);
}
Kotlinの関数 calcAnswer
を呼ぶため、テストコードはKotlinで記述することになりますが、その結果はRustの変数 ANSWER
に格納されており、そのテストするためにはRustコードも書かないといけません。
(逆に言えばこれぐらいむちゃくちゃなことをしない限りテストコード自体にネイティブコードが必要になったりはしません)
仕方がないのでこういうテストコードを書くことになります。
class UltimateQuestionTest {
init {
loadTestNativeLib()
}
@Test
fun answer_isCorrect() {
val ultimateQuestion = UltimateQuestion()
ultimateQuestion.calcAnswer()
assertAnswer()
}
private external fun assertAnswer()
}
#[no_mangle]
extern "C" fn Java_com_example_rust_featurea_UltimateQuestionTest_assertAnswer(
_env: JNIEnv,
_obj: JObject
) {
let lock = ANSWER.lock().unwrap();
assert_eq!(Some(42), *lock);
}
テスト自体は意図通り実行できるのですが、何が嫌かと言うと、テスト用に書いたRustコードがプロダクトコードに含まれるところです。
かと言って #[cfg(test)]
は使えません。これはあくまで「Kotlinで書いたテストコードがロードするライブラリのコード」です。Rust的にはこれはテストコードではないのです。
では、どうするか?
Feature Flagを使います。
+ [features]
+ jni-test = []
subprojects {
tasks.register<Exec>("cargoJniTestBuild") {
workingDir = rootProject.projectDir
- val args = mutableListOf("cargo", "build")
+ val args = mutableListOf("cargo", "build", "--features", "jni-test")
if (isXterm()) {
args += "--color=always"
}
commandLine(args)
}
tasks
.matching { it.name.matches(Regex("compile(Debug|Release)UnitTestKotlin")) }
.configureEach { dependsOn("cargoJniTestBuild") }
}
+ #[cfg(feature="jni-test")]
+ mod jni_tests {
#[no_mangle]
extern "C" fn Java_com_example_rust_featurea_UltimateQuestionTest_assertAnswer(
_env: JNIEnv,
_obj: JObject
) {
let lock = ANSWER.lock().unwrap();
assert_eq!(Some(42), *lock);
}
+ }
これでテスト時にのみ mod jni_tests
がコンパイルされます。
おまけ
gradle checkを叩いたときにcargo testを実行するようにしておくとよいです
+ tasks.register<Exec>("cargoTest") {
+ workingDir = rootProject.projectDir
+
+ val args = mutableListOf("cargo", "test")
+
+ if (isXterm()) {
+ args += "--color=always"
+ }
+
+ commandLine(args)
+ }
+
+ task("check") {
+ dependsOn("cargoTest")
+ }
終わりに
お疲れ様でした。
「Androidアプリ内の複数のモジュールにRustを導入する」から来た方は大変な長丁場だったと思います。
AndroidアプリをRustで書こうという者が私以外にどれくらいいるのかはわかりませんが、誰かのお役に立てば幸いです。