RからC++を使うためにRcppがありますが、ユニットテストを書くためにC++のコードからRcppを使う必要があったので、できるようにしました。
追記: Rcppを用いたC++コードをテストするには、 testthat::use_catch() が最善の方法だと思います。それとは別に、下記のようなこともできるという紹介です。
動機
Rcppを使ってC++のコードを書くときは、Rのデータ構造をC++のデータ構造として扱います。例えばRのコードから数値のベクトルを、 Rcpp::NumericVector
としてC++のコードに渡します。
Rcppを用いて書いたC++のコードをRパッケージにするとき、C++のコードをC++のユニットテストフレームワーク(例えばGoogle Test)でテストしたいです。ですが単に #include <Rcpp.h>
してコンパイル、リンクして実行ファイルを作っても動きません。他にも必要なことがあるので調べました。
本文中で、テスト対象のパッケージ名を anRcppSample
とします。全コードは こちらのレポジトリ にあります。
概要
C++のコードをコンパイル、リンクしてできる実行ファイルに libR.so
をリンクして、Rを起動すればよいです。起動するとRのプロンプトが出るので、library(anRcppSample)
と改行を入力して、テスト対象のパッケージ anRcppSample
をロードします。R の REPL から抜けてC++コードに制御が戻るので、C++のユニットテストができます。ですがプロンプトに手作業で入力するとテストを自動化できないので、R処理系に自動的にコードを流し込むようにします。
作成するパッケージ
ファイル構成
sum関数相当の処理をC++で行うRパッケージを作ります。エラー処理などは本記事の目的には必要ないので省略しています。
ディレクトリ | ファイル名 | 内容 |
---|---|---|
R/ | sample.R | パッケージの実装(R) |
src/ | sample.h, sample.cpp | パッケージの実装(C++) |
tests/testthat/ | test-sample.R | パッケージのテスト(R) |
tests/ | test_sample.cpp | パッケージのテスト(C++) |
tests/ | CMakeLists.txt, CMakeLists.txt.in | C++のユニットテストをビルドする設定 |
パッケージの実装
Rのsum関数に相当する処理をC++で実装して、Rパッケージとして使えるようにします。
#' Sum in C++
#'
#' @param xs A numeric vector to sum
#' @return The sum of xs
#'
#' @export
#' @useDynLib anRcppSample, .registration=TRUE
#' @importFrom Rcpp sourceCpp
sample_sum_r <- function(xs) {
sample_sum_cpp(xs)
}
#ifndef SRC_SAMPLE_H
#define SRC_SAMPLE_H
#include <Rcpp.h>
//' Sum in C++
//'
//' @param xs A numeric vector to sum
//' @return The sum of xs
// [[Rcpp::export]]
extern double sample_sum_cpp(const Rcpp::NumericVector& xs);
#endif // SRC_SAMPLE_H
#include "sample.h"
#include <numeric>
double sample_sum_cpp(const Rcpp::NumericVector& xs) {
constexpr double init = 0.0;
return std::accumulate(xs.begin(), xs.end(), init);
}
Rのコードは、このようにテストを書きます。
test_that("A small test", {
expect_equal(sample_sum_r(c(1.0, 0.5, 0.25)), 1.75)
})
C++コードのユニットテスト
テストを記述する
インクルード文とmain文をこのように書きます。
#include "sample.h"
#include <cstring>
#include <gtest/gtest.h>
#define R_INTERFACE_PTRS
#include <Rembedded.h>
#include <Rinterface.h>
int main(int argc, char *argv[]) {
char name[] = "test_sample";
char arg1[] = "--no-save";
char *args[]{name, arg1, nullptr};
Rf_initEmbeddedR((sizeof(args) / sizeof(args[0])) - 1, args);
ptr_R_ReadConsole = custom_r_readconsole;
R_ReplDLLinit();
R_ReplDLLdo1();
::testing::InitGoogleTest(&argc, argv);
auto result = RUN_ALL_TESTS();
Rf_endEmbeddedR(0);
return result;
}
-
Rf_initEmbeddedR
でRを起動します。このとき引数argc
,argv
を渡します。最初の要素はおそらくプログラム名なので何でもよいでしょう、二番目以降の要素はRに渡す引数です。引数として--no-save
もしくはそれに代わるオプションが必須です。 -
ptr_R_ReadConsole
はグローバル変数で、Rのプロンプトに入力する文字列を、R処理系が必要とするときに呼び出すコールバック関数へのポインタです。後で説明しますが、Rのコードを流し込むコードを返す関数custom_r_readconsole
を登録します。 -
R_ReplDLLinit
でRのREPLを初期化し、R_ReplDLLdo1
でREPLを起動します。ptr_R_ReadConsole
に何も登録しなければRのREPLが起動してプロンプトが出ますので、試すとよいでしょう。 - Rのプロンプトに入力した処理が終了すると続きを実行します。ここではGoogle Testでユニットテストを実行します。
インクルードファイルは、Rembedded.h
と Rinterface.h
が必要です。インクルードする前に #define R_INTERFACE_PTRS
が必要です。このマクロがないと、コールバック関数を設定できません。
次に、Rのコードを流し込むコールバック関数 custom_r_readconsole
を定義します。
namespace {
int custom_r_readconsole(const char *prompt, unsigned char *buf, int buflen, int hist) {
constexpr int return_code = 1;
const std::string r_code{"library(anRcppSample)\n"};
const auto r_code_buf_size = r_code.size() + 1;
if (!buf || !buflen) {
return return_code;
}
*buf = '\0';
if (static_cast<decltype(r_code_buf_size)>(buflen) < r_code_buf_size) {
return return_code;
}
std::strncpy(reinterpret_cast<char*>(buf), r_code.c_str(), r_code_buf_size);
return return_code;
}
} // namespace
Writing R Extensions に従って、 R_ReadConsole
の代わりになる関数を実装します。
-
buf
が指す領域に、最大 buflen 文字 (bytes)のRコードを設定します。設定するコードは終端を"\n\0"
にします。buf
にコピーするのはC文字列なので、std::string
の文字数 + 終端1文字をコピーします。 - prompt と hist は、今回無視します
- 返り値は入力が得られないときは0、得られるときは正の整数にします。ここでは1にします。
コードが buflen
以内に収まれば一まとまりのコードを1回で渡し、収まらなければ改行で区切って渡します。 buflen
の大きさは実行時に分かりますが、手元で試したところ4096でした。今回はテスト対象のパッケージをロードするコード library(anRcppSample)
だけ実行すればよいので1回で返せると想定します。
Gootle Testを使って、いつも通りユニットテストを書きます。
class TestAll : public ::testing::Test {};
TEST_F(TestAll, Small) {
const Rcpp::NumericVector arg {1.0, 0.5, 0.25};
const auto actual = sample_sum_cpp(arg);
EXPECT_EQ(1.75, actual);
}
CMakeを使う
libR.so
をリンクすることと、パスを設定する、という二点に注意します。それ以外は、Google TestをCMakeから使う方法について、他の記事をご参照ください。
cmake_minimum_required(VERSION 3.10)
configure_file(CMakeLists.txt.in googletest-download/CMakeLists.txt)
project(rCppSample CXX)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} --coverage")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --coverage")
# R_HOME should be set or the R runtime fails
set(R_ROOT_DIR "$ENV{R_HOME}")
set(RLIB "R")
find_path(R_INCLUDE_DIR R.h HINTS "${R_ROOT_DIR}" /usr/share /opt PATH_SUFFIXES include R/include)
find_library(R_LIBRARY NAMES "${RLIB}" HINTS "${R_ROOT_DIR}" PATH_SUFFIXES lib R/lib)
file(GLOB_RECURSE RCPP_INCLUDE_DIR "${R_ROOT_DIR}/*/Rcpp.h")
if(NOT RCPP_INCLUDE_DIR)
file(GLOB_RECURSE RCPP_INCLUDE_DIR "/home/*/Rcpp.h")
endif()
string(REGEX REPLACE "/Rcpp.h$" "" RCPP_INCLUDE_DIR "${RCPP_INCLUDE_DIR}")
find_path(RCPP_INCLUDE_DIR "Rcpp.h" HINTS "${RCPP_INCLUDE_DIR}")
include_directories("${R_INCLUDE_DIR}" "${RCPP_INCLUDE_DIR}")
execute_process(COMMAND ${CMAKE_COMMAND} -G "${CMAKE_GENERATOR}" . RESULT_VARIABLE result WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/googletest-download)
if(result)
message(FATAL_ERROR "CMake step for googletest failed: ${result}")
endif()
execute_process(COMMAND ${CMAKE_COMMAND} --build . RESULT_VARIABLE result WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/googletest-download)
if(result)
message(FATAL_ERROR "Build step for googletest failed: ${result}")
endif()
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
add_subdirectory(${CMAKE_CURRENT_BINARY_DIR}/googletest-src ${CMAKE_CURRENT_BINARY_DIR}/googletest-build EXCLUDE_FROM_ALL)
enable_testing()
include(GoogleTest)
# Include header files
set(BASEPATH "${CMAKE_SOURCE_DIR}")
include_directories("${BASEPATH}" "${BASEPATH}/../src")
# Executable unit tests
add_compile_options(-DSTRICT_R_HEADERS)
add_executable(test_sample ../src/sample.cpp test_sample.cpp)
target_link_libraries(test_sample "${R_LIBRARY}" gtest_main)
gtest_add_tests(TARGET test_sample)
cmake_minimum_required(VERSION 3.10)
project(googletest-download NONE)
include(ExternalProject)
ExternalProject_Add(googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG main
GIT_SHALLOW TRUE
SOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/googletest-src"
BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/googletest-build"
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
TEST_COMMAND ""
)
テストを実行する
Rパッケージのトップディレクトリから、以下を実行します。環境変数 R_HOME
を設定しないと、テストの実行に失敗します。RStudio などから実行する場合は R_HOME
は設定済だと思いますが、Dockerfile や GitHub Actions では R_HOME
を明示的に設定する必要があるでしょう。
mkdir -p tests/build
cd tests/build
cmake ..
make
make test
cd CMakeFiles/test_sample.dir
lcov -d . -c -o coverage.info
lcov -r coverage.info "/usr/*" "*/googletest/*" "/opt/boost*" -o coverageFiltered.info
genhtml -o lcovHtml --num-spaces 4 -s --legend coverageFiltered.info
cd ../../../..
make test が失敗するかいつまで経っても終了しない場合は、テストプログラム test_sample
を直接起動します。Rパッケージのロードに失敗しているかもしれません。
./test_sample --output-on-failure