pytest + arduino-cliで自動テスト

概要

pytestにはマイコン向けのpytest-embeddedプラグインがあるのですが、ビルドを事前に行う必要があったり、ESP32向けなところがあります。

そこでarduio-cliを利用して汎用的に利用できるArduino環境向けのテストプラグインを作成してみました。

pytestとは?

Python製のテストツールで、conftest.pyなどのファイルを使うことで柔軟に拡張が可能な構成になっています。

マイコン向けとしてはpytest-embeddedプラグインがあり、ESP32の販売元であるEspressifが提供しています。このプラグイン自体は汎用的に作られています。このプラグインを核にして拡張用のサービスを組み合わせる作りになっています。

サービス概要
serial汎用的なシリアル通信をする仕組み。pyserial的な動き
espesptoolを使って書き込みをする仕組み
idfESP-IDF用のターゲット指定
jtagOpenOCD/GDBを利用する仕組み
qemuqemu上のエミュレーターでESP32を再現
arduinoarduino用のターゲット指定
wokwiwokwi上のエミュレーターでESP32を再現
nuttxNuttX(RTOS)用サービス

arduinoサービスがあるので汎用的に使えそうに見えますが、書き込みはespを使うのでESP32に限定されています。そしてシリアルポートの自動選択機能があるのですがチップ名とfqbnの3項目目をesptoolでチェックしています。esp32:esp32:esp32などの標準機のみ対応で、esp32:esp32:m5stack_coreなどには使えません。

pytestはテスト用のtest_*.pyファイルを自動検索してくれるのですが、自動ビルドはしてくれませんし、arduino-cliでビルドをしていますがsketch.yamlを使わずに環境依存のある状態でビルドするのが基本となっています。

最初はconftest.pyで拡張して自動ビルドや自前転送などを実装しましたがどうも、きれいにESP32以外に分離できないので、プラグインとして最初からarduino-cliに依存するものを作ってみました。

pytest-embedded-arduino-cli

GitHub – tanakamasayuki/pytest-embedded-arduino-cli: pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記が作成物になります。

Client Challenge

今回はPyPIにも登録してありますのでuvなどでかんたんに利用が可能です。

仕組み

pytest-embeddedの汎用的な仕組みを利用しつつ、テスト前にビルドと転送フェーズを追加しています。両方ともarduino-cli compileとarduino-cli uploadを呼び出しているだけですのでsketch.yamlで細かい指定が可能です。

ビルド時の特殊処理

テスト対象のpyファイルはpytestが自動検索してくれます。そのpyファイルと同じ場所にinoファイルとsketch.yamlをおいておくことで自動ビルドしてくれます。

特殊処理として./build/<profile名>/にビルド結果を保存します。通常はtmp的な見えないフォルダでビルドされるのですが、その後の転送時に見えたほうがわかりやすいのでプロファイル名で作成をしています。

また、Wi-Fiアクセスポイントの情報を渡すときにEspressif社の場合にはテストの最初でシリアル経由で送受信をしていました。これはシンプルなのですがino側でもデータの受信ロジックが必要になりテストロジックの見通しがわかるくなります。

そこでbuild_config.tomlという設定ファイルをinoと同じ場所に入れておくことでビルド時のdefine定義に追加する仕様としました。

[defines]
TEST_WIFI_SSID = "WIFI_SSID"
TEST_WIFI_PASSWORD = "WIFI_PASSWORD"

上記のように指定すると、TEST_WIFI_SSIDという環境変数の中身を-DWIFI_SSIDでビルド時オプションに追加します。コード側ではWIFI_SSIDで文字列が利用可能です。

TEST_WIFI_SSID=your-ssid
TEST_WIFI_PASSWORD=your-password

環境変数は.envファイルなどを利用して事前に読み込んで置くか、ビルド起動時に読み込むことが可能です。CIだと事前に差し込めばいいですよね。

uv run --env-file .env pytest --profile esp32 --run-mode=build

例えばこんな感じで.envを読み込みつつ、プロファイルを指定してビルドのみ実行が可能です。

転送時の特殊処理

基本はビルドと同じような指定でarduino-cli uploadを実行するだけなのですが、シリアルポートを環境変数から取得する機能があります。

uv run --env-file .env pytest --profile esp32 --port=/dev/ttyUSB0

みたいにプロファイルに対応するポートを指定するのが本来の指定方法です。でもちょっと複数の端末あると面倒ですよね。

# Profile-specific serial ports
TEST_SERIAL_PORT_UNO=/dev/ttyACM0
TEST_SERIAL_PORT_ESP32=/dev/ttyUSB0
TEST_SERIAL_PORT_ESP32S3=/dev/ttyUSB1

そこで環境変数から取得するオプションもあります。TEST_SERIAL_PORT_<profile名>があればその値を利用します。こちらも.envに入れておくことでコミット対象じゃないローカルの設定として適応が可能です。

テスト時の特殊処理

こちらは標準のserialサービスをそのまま使っています。本当はarduino-cli monitorを使ったほうがきれいなのですが、既存のpytest側での処理を考えるとシンプルなシリアル接続だけを現状サポートしています。

実際のテスト方法

基本

void setup()
{
  Serial.begin(115200);
  delay(1000);
  Serial.println("hello from arduino");
}

void loop()
{
}

まず起動して固定文字列の「hello from arduino」を出力するだけの例です。

profiles:
  esp32:
    fqbn: esp32:esp32:esp32
    platforms:
      - platform: esp32:esp32 (3.3.8)
        platform_index_url: https://espressif.github.io/arduino-esp32/package_esp32_index.json

  uno:
    fqbn: arduino:avr:uno
    platforms:
      - platform: arduino:avr (1.8.7)

default_profile: esp32

sketch.yamlは上記みたいな感じで普通の設定になります。この例だとESP32とArduino Unoの両対応になっています。default指定してあるのでプロファイルを無指定で実行するとESP32になります。

def test_hello_from_arduino(dut):
    dut.expect_exact("hello from arduino")

テストファイルは上記だけになります。

pytest-embedded-arduino-cli/examples/01_basic at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

実ファイルは上記にあります。

uv run pytest examples/01_basic --profile uno --port=/dev/ttyACM0

上記のようにプロファイルとシリアルポートを指定して実行します。例だとexamples/01_basicの指定があるので、この1テストだけ実行されます。examplesを指定すればその中のテスト全部になります。

シリアル経由でのパラメーター確認

String readLineFromSerial()
{
  String line = "";

  while (true)
  {
    while (Serial.available() == 0)
    {
      delay(10);
    }

    char ch = static_cast<char>(Serial.read());
    if (ch == '\r')
    {
      continue;
    }
    if (ch == '\n')
    {
      return line;
    }
    line += ch;
  }
}

void setup()
{
  Serial.begin(115200);
  delay(1000);

  Serial.println("READY");
  String received = readLineFromSerial();
  Serial.print("RECEIVED ");
  Serial.println(received);
  Serial.println("OK");
}

void loop()
{
}

SSIDやAPIキーなどをビルド時に埋め込みたくない場合や、コマンド形式でテストをする例です。シリアルで送受信をするロジックを追加する必要があります。テスト用に汎用的なシリアルコマンドライブラリを使うことでもっとシンプルになるとは思います。

def test_dut_input_round_trip(dut):
    payload = "hello from pytest"

    dut.expect_exact("READY")
    dut.write(f"{payload}\n")
    dut.expect_exact(f"RECEIVED {payload}")
    dut.expect_exact("OK")

テスト側のコードです。READYを受信したら固定文字である「hello from pytest」をマイコンに送信して、その後その文字が送り返されるかを確認しています。ロジックのテスト等でパラメーターをコマンドで渡して、結果を確認するときなどはこの流れになります。

pytest-embedded-arduino-cli/examples/03_dut_input at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記が実際の例になります。

Unity利用例

#include <unity.h>

int add(int left, int right)
{
  return left + right;
}

void test_add_positive_numbers()
{
  TEST_ASSERT_EQUAL(3, add(1, 2));
}

void test_add_negative_numbers()
{
  TEST_ASSERT_EQUAL(-1, add(2, -3));
}

void setup()
{
  Serial.begin(115200);
  delay(1000);

  UNITY_BEGIN();
  RUN_TEST(test_add_positive_numbers);
  RUN_TEST(test_add_negative_numbers);
  UNITY_END();
}

void loop()
{
}

テストツールのUnityを利用した例です。ESP32は標準でunity.hが利用可能です。UNITY_BEGIN()とUNITY_END()の間にテストを並べることで、マイコン側だけでテストを実施します。テストの中身はよくある感じになっています。

def test_unity_basic_executes(dut):
    dut.expect_unity_test_output(timeout=60)

テスト側です。Unityを利用することで、シンプルに結果だけタイムアウトを設定して待つ形になります。個別の値はマイコン側のみで確認する場合にはUnityなどの薄いテストツールを使うのが便利みたいです。

pytest-embedded-arduino-cli/examples/04_unity_basic at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記に例があります。

フラッシュの状態管理

pytest-embedded-arduinoでは転送前にesptoolのeraseを実行し、さらに転送ファイルをすべてマージして4MBとかのフラッシュ全体を転送していました。全転送しているのでEraseはいらないと思うのと、毎回全容量を転送しているので転送時間が結構かかります。

--flash-mode dio --flash-freq 80m --flash-size 4MB
0x1000 unity_basic.ino.bootloader.bin
0x8000 unity_basic.ino.partitions.bin
0xe000 boot_app0.bin
0x10000 unity_basic.ino.bin

たとえばESP32の場合、上記みたいに4つのファイルを異なるアドレスに転送する必要があります。pytest-embedded-arduinoは面倒だったので、全部連結をして4MBのファイルをアドレス0に転送していました。

作成したpytest-embedded-arduino-cliでは、arduino-cli uploadコマンドを利用しているので、必要なファイルしか転送していません。そのためNVSやSPIFFS領域などは初期化されません。

  preferences.begin("pytest-demo", false);
  unsigned int bootCount = preferences.getUInt("boot_count", 0);
  bootCount += 1;
  preferences.putUInt("boot_count", bootCount);
  preferences.end();

たとえば上記のようにpreferences(NVS)を読み込んでカウントアップして保存する処理の場合、テスト実行するたびに数が増えていきます。

これを抑制するのはuploadのフラグではなくボードパラメーターになります。

profiles:
  esp32:
    fqbn: esp32:esp32:esp32:EraseFlash=all
    platforms:
      - platform: esp32:esp32 (3.3.8)
        platform_index_url: https://espressif.github.io/arduino-esp32/package_esp32_index.json

sketch.yamlでESP32の場合にはEraseFlash=allをつけることで、転送前にEraseを実施します。ちなみにArduino Unoの場合にはNVS的な機能はなく、常に全削除がされるようでした。

pytest-embedded-arduino-cli/examples/05_nvs_persistent at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記に例があります。

実際のライブラリのテスト例

pytest-embedded-arduino-cli/examples/07_arduino_library_project at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記にプロジェクト例があります。

library.properties
src/
tests/

Arduinoライブラリですので、最低限必要なのは上記のファイル構成となります。library.propertiesでライブラリの設定をして、srcにライブラリ本体があります。通常はexamplesもあるはずです。

[project]
name = "demo-add-library-tests"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "pytest>=8",
    "pytest-embedded>=2.0",
    "pytest-embedded-serial>=2.0",
    "pytest-embedded-arduino-cli>=1.1",
    "pytest-html>=4.1.1",
]

tests直下にはpyproject.tomlをおきます。これはuvの設定ファイルで、必要なツールを準備するためのものになります。必須ではありませんが、pytest-htmlでレポートファイルの追加をしています。

#include <DemoAdd.h>

void setup()
{
  Serial.begin(115200);
  delay(1000);

  DemoAdd demo;
  int result = demo.add(1, 2);

  Serial.print("ADD_RESULT ");
  Serial.println(result);
}

void loop()
{
}

テスト用のスケッチです。srcフォルダにあるDemoAdd.hを読み込んで、そのライブラリで足し算をしているテストです。

def test_basic_runner_uses_library(dut):
    dut.expect_exact("ADD_RESULT 3")

テストは1+2なので3になるのを期待しています。

profiles:
  esp32:
    fqbn: esp32:esp32:esp32
    platforms:
      - platform: esp32:esp32 (3.3.8)
        platform_index_url: https://espressif.github.io/arduino-esp32/package_esp32_index.json
    libraries:
      - dir: ../../

このテストで肝なのはsketch.yamlになります。librariesで../../を指定しているので現在のライブラリをビルド時に利用するようにしています。この指定があるのでライブラリとテストをきれいに分離することが可能になっています。

単体スケッチのテスト例

pytest-embedded-arduino-cli/examples/08_arduino_ide_project at main ?? tanakamasayuki/pytest-embedded-arduino-cli
pytest plugin for testing Arduino projects with pytest-embedded using arduino-cli – tanakamasayuki/pytest-embedded-ardui…

上記の例になります。

basic_add/basic_add.ino
tests/*

まずArduino IDEでビルドするためにはフォルダ名とinoファイル名を合わせる必要があります。このままだとテスト対象が分離できないので、ロジック部分を分離します。

basic_add/add_test.cpp
basic_add/add_test.h
basic_add/basic_add.ino
tests/*

inoの中からロジック部分をcppとhに切り出しました。

#include <Arduino.h>
#include <unity.h>
#include <add_test.h>

AddTest add_test;

void setUp(void)
{
}

void tearDown(void)
{
}

void test_add_returns_sum_for_positive_integers(void)
{
  TEST_ASSERT_EQUAL_INT(5, add_test.add(2, 3));
}

void test_add_returns_zero_for_zero_inputs(void)
{
  TEST_ASSERT_EQUAL_INT(0, add_test.add(0, 0));
}

void test_add_handles_negative_values(void)
{
  TEST_ASSERT_EQUAL_INT(-1, add_test.add(2, -3));
}

void setup()
{
  Serial.begin(115200);
  delay(1000);

  UNITY_BEGIN();
  RUN_TEST(test_add_returns_sum_for_positive_integers);
  RUN_TEST(test_add_returns_zero_for_zero_inputs);
  RUN_TEST(test_add_handles_negative_values);
  UNITY_END();
}

void loop()
{
  delay(1000);
}

テスト側のコードです。Unityを利用しているのでちょっとコードが増えていますが一見普通のテストに見えます。

#include "../../basic_add/add_test.cpp"

add_test.cppの中身を見てみると。。。上記のように本物のプロジェクトの中の同名のファイルをincludeしているラッパーファイルがtestの中に入っています。sketch.yamlでは普通のinoがいるフォルダをIncludePathに追加することができないので、苦肉の策となります。

なので、基本的に単独のArduino IDEで動かすプロジェクトをテストするのは結構辛いです。上記のようにincludeするラッパーなどを使うことでなんとか実現している事になります。

WSL対応

Windowsでビルドすると非常に時間がかかるので、普段はWSLでビルドしています。これだけで何倍も早くなるので便利なのですが、そこから転送をするとなるとちょっと面倒です。

USB/IPを利用することでWindows側のシリアルポートをWSL上で利用できるようになるのですが設定が面倒だったり、たまにファイアウォールなどで禁止されていることがあるので少しトリッキーなスクリプトを追加しています。

#!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

if [ -f .env ]; then
  ENV="--env-file .env"
else
  ENV=""
fi

uv run ${ENV} pytest --run-mode=build "$@"

set -a
[ -f .env ] && source .env
set +a

WIN_DIR=$(wslpath -w "$SCRIPT_DIR")

CMD=""
CMD+="pushd ${WIN_DIR}"
if [ -n "${UV_PROJECT_ENVIRONMENT_WIN:-}" ]; then
  CMD+="&&set UV_PROJECT_ENVIRONMENT=${UV_PROJECT_ENVIRONMENT_WIN}"
else
  CMD+="&&set UV_PROJECT_ENVIRONMENT=%TEMP%/.venv.pytest"
fi
CMD+="&&uv run ${ENV} pytest --run-mode=test $@"
CMD+="&&start report.html"
CMD+="&&timeout /t 1"

cmd.exe /C "${CMD}"

run_wsl.shという名前で入れているのですが、ビルドだけWSL上でrun-mode=buildで実行したあとにWindows上で転送とテストを行っています。

前半は.envがあればパラメーターに追加していて、その後に–run-mode=build “$@”を実行しています。$@があるので./run_wsl.sh –profile=esp32とかの任意のオプションが渡せます。

WIN_DIR=$(wslpath -w "$SCRIPT_DIR")

肝は上記でWSLのPATHをWindowsで認識でいる形式に変換します。そのままだとコマンドプロンプトで利用できないWSL側のPATHになっている場合があり使えません。

pushd ${WIN_DIR}

上記でC:などであればそのまま利用し、WSL上のPATHの場合には一時的にz:などにマウントして実行してくれます。

set UV_PROJECT_ENVIRONMENT=%TEMP%/.venv.pytest

ただし、uvが一時マウントしたz:でもWSLの本当のPATHを取得してしまい、中で利用しているライブラリがWSLのPATHを正しく利用できないのでエラーになります。そこでuvの.venvの場所をC:などに確保される%TEMP%以下に分離しています。

基本的にWSL側の.venvがカレントフォルダにあるので、UV_PROJECT_ENVIRONMENTはWindows向けに何かを指定する必要があります。

uv run ${ENV} pytest --run-mode=test $@

あとはWindows側でuv経由でpytestを実行します。

start report.html
timeout /t 1

これは不要なのですが、テスト結果をWindows側のブラウザで開くコマンドです。即終了するとブラウザで開く前にz:のマウントが解除されるのでtimeoutで1秒遅延させています。

まとめ

Arduino UnoでもESP32でも実機でテストできる環境を構築することができました。

ラズパイやミニPCにこの環境を準備して、セルフホステッドランナーとして登録すればGitHub Actionから実機でのテストが可能になるはずです。ただバッチ実行であればそこまでしないでGitからpullやcloneしてきてpytestを回すだけでも便利だとは思います。

コメント