Android実機またはエミュレータでUnitTest(C++, GoogleTest)を実行する
Android C++ でもテストしたいけどどうしたらいいのかわからなくて、いろいろ試して落ち着いたので記事にしました。
この記事では、gradle+cmakeでのビルドでテスト用の実行ファイルを作成し、ctest
コマンドで実機またはエミュレータでテストを実行できるところまでを目指します。
テストを実行できるようにするまでが目標なので、テスト手法などについては深くは触れません。
また、筆者はCI環境の構築などに精通しているわけではないので、この方法が必ずしも正しいとは限らないことをご了承ください。
本記事のプログラムはGitHubにて公開しています。
環境
筆者の動作環境です。
- Windows11
- Android Studio Chipmunk
- Google Pixel 3(Android 12)
- C++17
- CMake 3.18.1
AndroidStudioで新規プロジェクト作成
今回は「NewProject」>「Native C++」からプロジェクトを作成しました。
プロジェクトの準備
build.gradleの変更
defaultConfig
を変更する
CMakeLists.txtからC++のバージョン判定をしたいので、C++のバージョンをCMAKE_CXX_STANDARD
で指定します。
defaultConfig {
applicationId "com.suu_lab.cppunittestsample"
minSdk 21
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
- cppFlags '-std=c++17'
+ arguments += '-DCMAKE_CXX_STANDARD=17'
}
}
}
buildType
にデバッグビルドタイプを追加
今回はデバッグを行わないので特に必要な操作ではないですが、一応追加しておきます。
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
+ debug {
+ jniDebuggable true
+ }
}
android
にテスト用のフレーバーを追加
通常のアプリ用とテスト用でフレーバーを分けて宣言しておきます。
defaultConfig
のブロックの次辺りに以下の記述を追加します。
flavorDimensions "target"
productFlavors {
app {
dimension "target"
externalNativeBuild {
cmake {
arguments += '-DMY_LIB_BUILD_TEST=false'
}
}
}
appTest {
dimension "target"
externalNativeBuild {
cmake {
arguments += '-DMY_LIB_BUILD_TEST=true'
}
}
}
}
ここまでで、BuildVariantがappTestDebug
でビルドが通ることを確認します。
テスト用のCMakeLists.txtを追加
app/src/test
にcpp
フォルダを作成し、そのフォルダ内にCMakeLists.txt
を作成します。
cmake_minimum_required(VERSION 3.18.1)
project(my_test LANGUAGES C CXX)
MY_LIB_BUILD_TEST
がtrue
の場合、テストを有効化します。
if (${MY_LIB_BUILD_TEST})
enable_testing()
endif()
app/src/main/CMakeLists.txt
からadd_subdirectory()
で上で作成したCMakeLists.txt
のあるディレクトリを追加します。
if (${MY_LIB_BUILD_TEST})
add_subdirectory(../../test/cpp ${CMAKE_CURRENT_BINARY_DIR}/test)
endif()
もう一度ここまででビルドが通るか確認してみましょう。
GoogleTestを使えるようにする
テストコードを書くために、まずはGoogleTestを使えるようにします。
GoogleTestを使えるようにする方法はいくつかありますが、今回はNDKに含まれているソースからライブラリを作成して使用したいと思います。
include(GoogleTest)
# WindowsではANDROID_NDKをそのまま使用すると
# バックスラッシュがエスケープ文字と認識されてしまうので置き換える
if("${CMAKE_HOST_SYSTEM_NAME}" STREQUAL "Windows")
string(
REPLACE
"\\"
"/"
NDK_DIR
"${ANDROID_NDK}"
)
endif()
set(GOOGLETEST_ROOT
${NDK_DIR}/sources/third_party/googletest
)
add_library(
gtest_main
STATIC
${GOOGLETEST_ROOT}/src/gtest_main.cc
${GOOGLETEST_ROOT}/src/gtest-all.cc
)
target_include_directories(
gtest_main
PRIVATE
${GOOGLETEST_ROOT}
)
target_include_directories(
gtest_main
PUBLIC
${GOOGLETEST_ROOT}/include
)
テスト用実行ファイルの作成
GoogleTestが使用できる状態になったので、テスト用の実行ファイルを生成するコードを足していきます。
app\src\test\cpp\CMakeLists.txt
へ実行ファイルを生成するコードを追加しましょう。
GoogleTestを使えるようにした後へ追記していきます。
# テスト用の実行可能ターゲット
add_executable(
my_test
my_test.cpp
)
実行可能ターゲットなのでmain()
関数が必要になります。
上で追加したgtest_main
ライブラリに含まれるのでリンクしておきましょう。
# ライブラリのリンク
target_link_libraries(
my_test
PRIVATE
gtest_main
)
この状態だとapp\src\test\cpp\my_test.cpp
が無いので、GradleSyncするとエラーが出ます。
app\src\test\cpp
へmy_test.cpp
を作成します。
この状態でもう一度GradleSyncするとエラーが消えていると思います。
ここまででビルドできることを確認して次へ進みます。
テストを追加する
上でテスト用のソースファイルの追加ができたので、簡単なテストを書いておきます。
#include <gtest/gtest.h>
TEST(MyTest, Sample)
{
EXPECT_EQ(1, 0);
}
定数同士の比較で全く意味がないですが、テストが実行できているか確認するには十分なのでOKとします。
またまたビルドできることを確認して次へ進みます。
ctestに対応する
ctest
はトップディレクトリから追加されたテストを認識してフィルタリングしたり並列実行したりできるので、対応したいと思います。
テストの追加にはgtest_discover_tests()
コマンドを使用します。
ただ、ctest
は実行ファイルを探し出して実行しているに過ぎず、Android実機で実行するには少し手を加えてやる必要があります。
However, it requires that CROSSCOMPILING_EMULATOR is properly set in order to function in a cross-compiling environment.
クロスコンパイルするときはCROSSCOMPILING_EMULATOR
プロパティを適切に設定してくださいと書いてあります。
このプロパティはadd_executable()
で作成するターゲットに対して指定しておく必要があります。
CROSSCOMPILING_EMULATOR
プロパティに指定した値を実行可能ファイルとして、引数にadd_executable()
の成果物のパスが渡されます。
# CROSSCOMPILING_EMULATOR プロパティを設定
# ここで設定したスクリプトは「テストのリストアップ」、「テストの実行」時に使用される
if(${CMAKE_HOST_SYSTEM_NAME} STREQUAL "Windows")
set(MY_TEST_RUNNER
${CMAKE_SOURCE_DIR}/../../scripts/test_runner.bat #実機テスト実行用スクリプト
)
else()
set(MY_TEST_RUNNER
${CMAKE_SOURCE_DIR}/../../scripts/test_runner.sh #実機テスト実行用スクリプト
)
endif()
set_target_properties(
my_test
PROPERTIES
CROSSCOMPILING_EMULATOR
${MY_TEST_RUNNER}
)
CROSSCOMPILING_EMULATOR
に指定したtest_runner.bat
で実機への転送と実行を行います。
UNIX系の場合はシェルスクリプトを指定すればOKです。(別途作る必要があります)
gtest_discover_tests()
でテスト追加します。
# テストの登録
gtest_discover_tests(
my_test
DISCOVERY_TIMEOUT 20
DISCOVERY_MODE PRE_TEST
)
gtest_discover_tests()
でDISCOVERY_MODE PRE_TEST
を指定しているのは、テスト直前までテストのリストアップを遅らせる目的があります。
何も指定しないとビルド時にリストアップされますが、Androidの場合adb
コマンドを使用する必要があり、ビルドの段階ではそのことは意識したくないので遅らせています。
ビルドできることを確認して次へ進みます。
テストを実行するスクリプトを作る
ctest
から実行されるスクリプトを作ります。
ctest
実行時に渡される引数はこんな感じでした。
'実行ファイルパス' --gtest_filter Runtime.EngineName --gtest_also_run_disabled_tests
これを実機で動作するようにしていきます。
ざっくりとした手順はこんな感じです。
- 引数で渡された実行ファイルを端末にコピー
- 端末上で渡された引数とともに実行ファイルを起動
- テスト結果に合わせてスクリプトの終了コードを変更
スクリプトの初めに必要な変数をこんな感じで用意しておきます。(ついでにスクリプトのあるフォルダへ移動しています)
@echo off
setlocal
set CURRENT_DIRECTORY=%~dp0
pushd %CURRENT_DIRECTORY% > nul
rem テスト結果格納用のフォルダを作成
if not exist TestResult (
md TestResult
)
rem 一つ目の引数は実行ファイルのパス
set TEST_EXECUTABLE_FILE=%1
set TARGET_TEST_FILENAME=%1
rem ファイル名だけを取り出す
call :Func_GetFileName %TARGET_TEST_FILENAME%
set SUCCEEDED_FILENAME=%TARGET_TEST_FILENAME%_SUCCEEDED.txt
set DESTINATION_DIRECTORY=/data/local/tmp/my_test/%TARGET_TEST_FILENAME%
goto :TESTING
rem ファイル名だけを取り出す
:Func_GetFileName
set TARGET_TEST_FILENAME=%~nx1
exit /b
:TESTING
では順番に見ていきましょう。
1. 引数で渡された実行ファイルを端末にコピー
adb push
で作成した実行ファイルを端末に持っていきます。
rem テストの実行ファイルを端末へアップロード
adb push %TEST_EXECUTABLE_FILE% %DESTINATION_DIRECTORY%/%TARGET_TEST_FILENAME% > nul
2. 端末上で渡された引数とともに実行ファイルを起動
実行ファイルに渡すオプションはctest
へによっても変わってきます。
幾つあるかわからない引数を一つの変数にまとめて実行時に渡せるようにしておきます。
rem 一つ目の引数は実行ファイルのパスなのでシフトする
shift
rem 追加の引数がなければ終了
set SKIP_SPACE=false
rem シフト後の最初の引数を入れておく
set ARGS=%1
if "%ARGS%"=="--gtest_filter" (
set ARGS=%ARGS%=
set SKIP_SPACE=true
)
rem 更にシフト
shift
rem 残りの引数をループでシフトしながら取り出す
:ARGS_SHIFT
set ARGN=%1
if "%ARGN%"=="" goto :ARGS_END
if "%SKIP_SPACE%"=="true" (
set ARGS=%ARGS%%ARGN%
set SKIP_SPACE=false
shift
goto :ARGS_SHIFT
)
if "%ARGN%"=="--gtest_filter" (
set ARGS=%ARGS% %ARGN%=
set SKIP_SPACE=true
) else (
set ARGS=%ARGS% %ARGN%
)
shift
goto :ARGS_SHIFT
:ARGS_END
adb shell
で実行ファイルを起動します。
rem ディレクトリ移動 && 実行権限付与
adb shell "cd %DESTINATION_DIRECTORY% && chmod 775 ./%TARGET_TEST_FILENAME%" > nul
rem ディレクトリ移動 && 実行 > output.txt へ標準出力を保存 && 成否判定用のファイルを作成
adb shell "cd %DESTINATION_DIRECTORY% && ./%TARGET_TEST_FILENAME% %ARGS% > output.txt && touch %SUCCEEDED_FILENAME%" > nul
rem テスト結果を TestResult へダウンロード
adb pull %DESTINATION_DIRECTORY% TestResult > nul
rem テスト実行に使用したディレクトリを削除
adb shell rm -rf %DESTINATION_DIRECTORY% > nul
rem テストの標準出力を転送
type TestResult\%TARGET_TEST_FILENAME%\output.txt
3. テスト結果に合わせてスクリプトの終了コードを変更
2で実行したテストが成功した時のみtouch
でテキストを作成しています。
adb pull
実行後にそのファイルが存在していればテストは成功したということになります。
rem テスト成功ファイルの存在チェック
if not exist TestResult\%TARGET_TEST_FILENAME%\%SUCCEEDED_FILENAME% (
goto :TEST_FAILED
) else (
goto :TEST_SUCCEEDED
)
rem 成功
:TEST_SUCCEEDED
del /Q TestResult\%TARGET_TEST_FILENAME%
popd
endlocal
exit /b 0
rem 失敗
:TEST_FAILED
del /Q TestResult\%TARGET_TEST_FILENAME%
popd
endlocal
exit /b -1
ここまで来ればctest
でテストを実行できるようになっているはずです。
確かめてみましょう。
[hash_code]
と[ABI]
の部分は環境に合わせて変更してください。
> cd app\.cxx\Debug\[hash_code]\[ABI]
app\.cxx\Debug\[hash_code]\[ABI]> ctest -V
きちんと失敗していることが確認できればOKです。
The following tests FAILED:
1 - MyTest.Sample (Failed)
無事Android実機またはエミュレータでテスト実行ができるようになりました。
CIへ組み込む為にもう少しだけ頑張る
ctest
を実行するために、いちいちCMakeのビルドディレクトリへ移動する必要があって面倒なのでもう少しだけ頑張って簡単にしておきたいです。
CMakeのビルドディレクトリを特定する
gradle
を使用してビルドする場合、ビルドディレクトリが自動で指定されてしまいます。
注: 必須の引数はすべて Gradle によって自動的に渡されます。明示的に渡す必要があるのは、コマンドラインからビルドする場合のみです。
このビルドディレクトリのパスにはハッシュ値が含まれていて、その値を特定する方法がわかりませんでした。
とりあえず、ビルド時にcmake_binary_dir.txt
を作成してそのファイルにCMAKE_BINARY_DIR
の値を出力する方法をとりました。
if("${CMAKE_HOST_SYSTEM_NAME}" STREQUAL "Windows")
string(
REPLACE
"/"
"\\"
MY_CMAKE_BINARY_DIR
${CMAKE_BINARY_DIR}
)
else()
string(
REPLACE
"\\"
"/"
MY_CMAKE_BINARY_DIR
${CMAKE_BINARY_DIR}
)
endif()
file(
WRITE
${CMAKE_SOURCE_DIR}/../../scripts/${ANDROID_ABI}/cmake_binary_dir.txt
${MY_CMAKE_BINARY_DIR}
)
ctestを実行するスクリプトを作成する
ctest
コマンドを実行するにもビルドディレクトリへ移動したり面倒なのでスクリプトを作成しておきます。
手順はこんな感じです。
- CMakeのビルドディレクトリへ移動
-
ctest
コマンドの実行 - テスト結果に合わせてスクリプトの終了コードを変更
テスト実行スクリプトと同様に、スクリプトの初めに必要な変数をこんな感じで用意しておきます。
ついでにスクリプトのあるフォルダへ移動もしておきます。
echo off
setlocal
set CURRENT_DIR=%~dp0
rem 初めの引数はABIの指定
pushd %CURRENT_DIR%\%1
順番に見ていきましょう。
1. CMakeのビルドディレクトリへ移動
CMakeLists.txt
で出力したcmake_binary_dir.txt
にビルドディレクトリが書かれているはずなので、テキストの内容を取り出します。
rem cmake_binary_dir.txt からビルドディレクトリを読み取る
set BUILD_DIR=
for /f %%A in (cmake_binary_dir.txt) do (
set BUILD_DIR=%BUILD_DIR%%%A
)
rem ビルドディレクトリへ移動
pushd %BUILD_DIR%
ctest
コマンドの実行
2. rem 初めの引数はABIの指定に使っているのでシフトする
shift
rem 残りの引数をすべて ctest へ渡す
ctest %*
3. テスト結果に合わせてスクリプトの終了コードを変更
ERRORLEVEL
から結果の判定ができます。
rem 実行結果で分岐
if %ERRORLEVEL% equ 0 (
goto :TEST_SUCCEEDED
) else (
goto :TEST_FAILED
)
rem 成功
:TEST_SUCCEEDED
popd
popd
endlocal
exit /b 0
rem 失敗
:TEST_FAILED
popd
popd
endlocal
exit /b -1
ビルド ~ テスト実行
ではビルド&テストを実行してみましょう。
[ABI]
は環境に合わせて置き換えてください。
> gradlew.bat app:assembleAppTestDebug
> cd app\src\scripts
app\src\scripts> ctest.bat [ABI] -V
お疲れ様です!
ここまで来ればCIへ組み込むのも大分簡単になるのではないでしょうか。
こちらのサンプルではGitHub Actionsを使用してテストしているので見てみて下さい。
ありがとうございました。🙇🏻♂️
Discussion