3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C++でRcppを使ったユニットテストを書く

Last updated at Posted at 2022-04-16

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パッケージとして使えるようにします。

sample.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)
}
sample.h
#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
sample.cpp
#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-sample.R
test_that("A small test", {
  expect_equal(sample_sum_r(c(1.0, 0.5, 0.25)), 1.75)
})

C++コードのユニットテスト

テストを記述する

インクルード文とmain文をこのように書きます。

test_sample.cpp
#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;
}
  1. Rf_initEmbeddedR でRを起動します。このとき引数 argc, argv を渡します。最初の要素はおそらくプログラム名なので何でもよいでしょう、二番目以降の要素はRに渡す引数です。引数として --no-save もしくはそれに代わるオプションが必須です。
  2. ptr_R_ReadConsole はグローバル変数で、Rのプロンプトに入力する文字列を、R処理系が必要とするときに呼び出すコールバック関数へのポインタです。後で説明しますが、Rのコードを流し込むコードを返す関数 custom_r_readconsole を登録します。
  3. R_ReplDLLinit でRのREPLを初期化し、R_ReplDLLdo1 でREPLを起動します。ptr_R_ReadConsole に何も登録しなければRのREPLが起動してプロンプトが出ますので、試すとよいでしょう。
  4. Rのプロンプトに入力した処理が終了すると続きを実行します。ここではGoogle Testでユニットテストを実行します。

インクルードファイルは、Rembedded.hRinterface.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から使う方法について、他の記事をご参照ください。

CMakeLists.txt
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)
CMakeLists.txt.in
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
3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?