2022/05/06

Raspberry Pi Pico + Arduino + GoogleTest

ファームウェアの単体テスト

Arduino 開発環境で真面目に単体テストするためにGoogleTestを導入する.

サンプルリポジトリは以下.


toms74209200/arduino_gtest_sample: C++ GoogleTest sample for Arduino farmware

環境

何でもかんでも VScode devcontainer で開発するのが個人的な流行り. 上記サンプルリポジトリでは docker と VScode だけで動く. devcontainer の使い方は調べれば出てくるので割愛.

コンテナ内の実際の開発環境では以下を使う.

  • CMake
  • clang
  • clang-format
  • clang-tidy
  • arduino-cli
  • GoogleTest

CMake, clang は GoogleTest を使うために利用する. clang-format はフォーマッター, clang-tidy はリンター(使ってない). フォーマッターとリンターは軽率に入れよう. ライブラリ等の導入のために arduino-cli も入れておく. うまくテスト対象部分を分離できていれば Arduino 用の開発環境はなくてもいいかもしれない.

詳しい導入方法は Dockerfile に書いてある.

RUN apt-get update && apt-get install -y \
    git \
    make \
    cmake \
    curl \
    clang \
    clang-format \
    clang-tidy \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# install arduino-cli
RUN curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | BINDIR=/usr/local/bin sh -s 0.21.1

COPY arduino-cli.yaml /root/.arduino15/arduino-cli.yaml

RUN arduino-cli core update-index \
    && arduino-cli core install arduino:avr

# install GoogleTest
RUN git clone https://github.com/google/googletest.git -b release-1.11.0 \
    && cd googletest \
    && mkdir build \
    && cd build \
    && cmake .. \
    && make \
    && make install

単体テスト手法

単体テストができなくなってしまう原因は例えば Serial.print() のような依存性や副作用だ. そのためこのような依存性をDIを使って分離してしまえば良い.

ディレクトリ構成

実装コードとテストコードを分離しつつ, Arduino できちんと読み込まれるようなディレクトリ構成にする. ちなみにサンプルリポジトリでは Google のC++スタイルガイドに則っているが, Arduino では .cc ファイルが認識されないため, .cpp としている.

arduino_gtest_sample/
├── CMakeLists.txt
├── arduino_gtest_sample.ino
├── build/
├── src/
│   ├── host_communication/
│   │   ├── host_communication.cpp
│   │   ├── host_communication.h
│   │   └── host_uart.h
│   ├── impl/
│   │   ├── host_uart_impl.cpp
│   │   ├── host_uart_impl.h
│   │   ├── sensor_uart_impl.cpp
│   │   └── sensor_uart_impl.h
│   └── sensor_communication/
│       ├── sensor_communication.cpp
│       ├── sensor_communication.h
│       └── sensor_uart.h
└── test/
    ├── host_communication/
    │   ├── host_communication_test.cpp
    │   └── host_uart_mock.h
    └── sensor_communication/
        ├── sensor_communication_test.cpp
        └── sensor_uart_mock.h

ディレクトリトップにメインファイルとなる .ino ファイルと CmakeLists.txt を配置する. テスト対象となる実装コードは src/ ディレクトリ下に配置する. 副作用を持っていてテストから除外するコードも src/ 以下に置くが impl/ ディレクトリにまとめておき impl をサフィックスとする. テストコードは test/ ディレクトリ以下に src/ と同じ構成で配置する. わかりやすいように test をサフィックスとしてつける.

CmakeLists.txt

テストコードのビルドのため CMake を使用する. これを使うことで

  1. CMakeList.txt 読み込み
  2. ビルド
  3. テスト実行

の3ステップでテストできるようになる.

CMake なんもわからんが, テストコードのビルドのためにしか使わないのでそこまで考えずにテスト用にビルドプロジェクトを作れば良い. CMake でファームウェアのビルドするようなところはもうちょっとちゃんとした秘伝のタレがあるはず.

テスト対象の実装コードとテストコードは file コマンドで探索する. このときテストから除外するコードは EXCLUDE で除外する.

file(GLOB_RECURSE SRC_FILES RELATIVE /workspace src/*/*.cpp)
list(FILTER SRC_FILES EXCLUDE REGEX ".*impl.cpp")
file(GLOB_RECURSE TEST_FILES RELATIVE /workspace test/*/*_test.cpp)

テスト対象コードとテストコードは add_executable で追加する. プロジェクト名は google_test にしてある.

project(google_test CXX)
add_executable(
    google_test
    ${SRC_FILES}
    ${TEST_FILES}
)

ヘッダファイルはディレクトリを指定する.

target_include_directories(google_test PUBLIC
    src/host_communication
    src/sensor_communication
    test/host_communication
    test/sensor_communication
)

それ以外の GoogleTest に必要な設定.

find_package(GTest)
target_link_libraries(
    google_test
    ${GTEST_BOTH_LIBRARIES}
    gmock
    pthread
    GTest::Main
    )
target_include_directories(google_test PUBLIC ${GTEST_INCLUDE_DIRS})

テストコードの実行

上述の通りCMakeファイルを読み込んでビルド, テストコードを実行すれば良い.

VScode の拡張機能を使えばIDEと同じように利用できる.

$ code --install-extension \
 ms-vscode.cmake-tools \
 matepek.vscode-catch2-test-adapter

devcontainer を使う場合は devcontainer.json に記載することで利用できる(サンプルリポジトリでは導入済み).

テストコードの記述

普通にモックすればいいと思うよ.

テストできない副作用を持つコードをうまく分離することが重要. サンプルリポジトリではシリアル通信部分である HostUart をモックオブジェクトとしてテスト対象の HostCommunication にDIする. モックにする HostUart は抽象クラスとする.

class HostCommunication {
 public:
  HostCommunication(HostUart* host_uart);

 private:
  HostUart* host_uart_;
};
class HostUart {
 public:
  virtual ~HostUart(){};
  virtual std::vector<uint8_t> RecvData() = 0;
  virtual bool SendString(const std::string s) = 0;
};

モックオブジェクトは Google Mock の通りに作れば良い.

class HostUartMock : public HostUart {
 public:
  MOCK_METHOD0(RecvData, std::vector<uint8_t>());
  MOCK_METHOD1(SendString, bool(const std::string s));
};

実装コードでは抽象クラスを普通に継承して普通に実装する. ヘッダのインクルードが気持ち悪いがこれで実際に利用できる.

#include "../host_communication/host_uart.h"

class HostUartImpl : public HostUart {
 public:
  HostUartImpl();
  HostUartImpl(const uint16_t baudrate);
  std::vector<uint8_t> RecvData();
  bool SendString(const std::string data);
};

HostUartImpl::HostUartImpl(const uint16_t baudrate) {
  Serial.begin(baudrate);
}
HostUartImpl host_uart(HOST_BAUDRATE);
HostCommunication host(&host_uart);

Refs.

0 件のコメント:

コメントを投稿