🎳

Android実機またはエミュレータでUnitTest(C++, GoogleTest)を実行する

2022/06/23に公開

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で指定します。

app/build.gradle
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にデバッグビルドタイプを追加

今回はデバッグを行わないので特に必要な操作ではないですが、一応追加しておきます。

app/build.gradle
 buildTypes {
     release {
         minifyEnabled false
         proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
     }
+    debug {
+        jniDebuggable true
+    }
 }

androidにテスト用のフレーバーを追加

通常のアプリ用とテスト用でフレーバーを分けて宣言しておきます。
defaultConfigのブロックの次辺りに以下の記述を追加します。

app/build.gradle
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/testcppフォルダを作成し、そのフォルダ内にCMakeLists.txtを作成します。

app/src/test/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.18.1)

project(my_test LANGUAGES C CXX)

MY_LIB_BUILD_TESTtrueの場合、テストを有効化します。

app/src/main/cpp/CMakeLists.txt
if (${MY_LIB_BUILD_TEST})
    enable_testing()
endif()

app/src/main/CMakeLists.txtからadd_subdirectory()で上で作成したCMakeLists.txtのあるディレクトリを追加します。

app/src/main/cpp/CMakeLists.txt
if (${MY_LIB_BUILD_TEST})
    add_subdirectory(../../test/cpp ${CMAKE_CURRENT_BINARY_DIR}/test)
endif()

もう一度ここまででビルドが通るか確認してみましょう。

GoogleTestを使えるようにする

テストコードを書くために、まずはGoogleTestを使えるようにします。
GoogleTestを使えるようにする方法はいくつかありますが、今回はNDKに含まれているソースからライブラリを作成して使用したいと思います。

app/src/test/cpp/CMakeLists.txt
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を使えるようにした後へ追記していきます。

app\src\test\cpp\CMakeLists.txt
# テスト用の実行可能ターゲット
add_executable(
    my_test
    my_test.cpp
)

実行可能ターゲットなのでmain()関数が必要になります。
上で追加したgtest_mainライブラリに含まれるのでリンクしておきましょう。

app\src\test\cpp\CMakeLists.txt
# ライブラリのリンク
target_link_libraries(
    my_test
    
    PRIVATE
    gtest_main
)

この状態だとapp\src\test\cpp\my_test.cppが無いので、GradleSyncするとエラーが出ます。

app\src\test\cppmy_test.cppを作成します。
この状態でもう一度GradleSyncするとエラーが消えていると思います。

ここまででビルドできることを確認して次へ進みます。

テストを追加する

上でテスト用のソースファイルの追加ができたので、簡単なテストを書いておきます。

app\src\test\cpp\my_test.cpp
#include <gtest/gtest.h>

TEST(MyTest, Sample)
{
    EXPECT_EQ(1, 0);
}

定数同士の比較で全く意味がないですが、テストが実行できているか確認するには十分なのでOKとします。

またまたビルドできることを確認して次へ進みます。

ctestに対応する

ctestはトップディレクトリから追加されたテストを認識してフィルタリングしたり並列実行したりできるので、対応したいと思います。

テストの追加にはgtest_discover_tests()コマンドを使用します。
ただ、ctestは実行ファイルを探し出して実行しているに過ぎず、Android実機で実行するには少し手を加えてやる必要があります。

https://cmake.org/cmake/help/latest/module/GoogleTest.html#command:gtest_discover_tests

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()の成果物のパスが渡されます。

app\src\test\cpp\CMakeLists.txt
# 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()でテスト追加します。

app/src/test/cpp/CMakeLists.txt
# テストの登録
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 

これを実機で動作するようにしていきます。
ざっくりとした手順はこんな感じです。

  1. 引数で渡された実行ファイルを端末にコピー
  2. 端末上で渡された引数とともに実行ファイルを起動
  3. テスト結果に合わせてスクリプトの終了コードを変更

スクリプトの初めに必要な変数をこんな感じで用意しておきます。(ついでにスクリプトのあるフォルダへ移動しています)

app/src/scripts/test_runner.bat
@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で作成した実行ファイルを端末に持っていきます。

app/src/scripts/test_runner.bat
rem テストの実行ファイルを端末へアップロード
adb push %TEST_EXECUTABLE_FILE% %DESTINATION_DIRECTORY%/%TARGET_TEST_FILENAME% > nul


2. 端末上で渡された引数とともに実行ファイルを起動

実行ファイルに渡すオプションはctestへによっても変わってきます。
幾つあるかわからない引数を一つの変数にまとめて実行時に渡せるようにしておきます。

app/src/scripts/test_runner.bat
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で実行ファイルを起動します。

app/src/scripts/test_runner.bat
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実行後にそのファイルが存在していればテストは成功したということになります。

app/src/scripts/test_runner.bat
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を使用してビルドする場合、ビルドディレクトリが自動で指定されてしまいます。
https://developer.android.com/ndk/guides/cmake#variables

注: 必須の引数はすべて Gradle によって自動的に渡されます。明示的に渡す必要があるのは、コマンドラインからビルドする場合のみです。

このビルドディレクトリのパスにはハッシュ値が含まれていて、その値を特定する方法がわかりませんでした。
とりあえず、ビルド時にcmake_binary_dir.txtを作成してそのファイルにCMAKE_BINARY_DIRの値を出力する方法をとりました。

app/src/test/cpp/CMakeLists.txt
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コマンドを実行するにもビルドディレクトリへ移動したり面倒なのでスクリプトを作成しておきます。

手順はこんな感じです。

  1. CMakeのビルドディレクトリへ移動
  2. ctestコマンドの実行
  3. テスト結果に合わせてスクリプトの終了コードを変更

テスト実行スクリプトと同様に、スクリプトの初めに必要な変数をこんな感じで用意しておきます。
ついでにスクリプトのあるフォルダへ移動もしておきます。

app/src/scripts/ctest.bat
echo off
setlocal

set CURRENT_DIR=%~dp0

rem 初めの引数はABIの指定
pushd %CURRENT_DIR%\%1

順番に見ていきましょう。


1. CMakeのビルドディレクトリへ移動

CMakeLists.txtで出力したcmake_binary_dir.txtにビルドディレクトリが書かれているはずなので、テキストの内容を取り出します。

app/src/scripts/ctest.bat
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%


2. ctestコマンドの実行

app/src/scripts/ctest.bat
rem 初めの引数はABIの指定に使っているのでシフトする
shift

rem 残りの引数をすべて ctest へ渡す
ctest %*


3. テスト結果に合わせてスクリプトの終了コードを変更

ERRORLEVELから結果の判定ができます。

app/src/scripts/ctest.bat
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