Pythonを使ってBDDでUSB機器を自動テストする

f:id:aptpod_tech-writer:20201208173517j:plain

aptpod Advent Calender 2020、 9日目の記事です。 本日の担当は、組込み開発チームでFW開発を担当している矢部です。

はじめに

組込み機器の開発に関わって1x年ですが、入出力の自動化が難しい機器も多く、結局手作業になって無駄にボタン押下やUI操作の速度が洗練されたりします。私の場合、ゲームをよくやっていたからか、効率よく操作させることができるとちょっと嬉しかったりもします。 とはいえリグレッションテストなどを考えると極力自動化したいところなので、過去いろいろと試行錯誤してきました。 今回は、その中でも比較的簡単な、USB通信をインターフェースに持つ機器のテストを自動化した際の手法について簡単に解説します。

前提知識

今回の記事で詳しくは解説しない部分をさらっと。

BDDとは

振る舞い駆動開発(Behavior Driven Development)と呼ばれるもので、TDDの一種。 システムに期待する挙動や制約などを自然言語に近い形式で記述し、テストを実行できるようにしたもの。自然言語で書けるため、非開発者であってもシステムの仕様がわかるように書ける(らしい)。 テストファーストな開発だと「テストコードは仕様である」という考え方になるので、それを突き詰めたものがBDDかな、と思っています。

BDDのフレームワーク

主要な言語であれば、たいていBDDを実現するためのフレームワークが存在します。

私が最初に触れたのはRSpecですが、最近はPythonを利用する機会が多いため、今回はBehaveで実装します。

Pythonの開発環境

色々ありますが、最近はpipenvを使っています。設定や実行方法など、バランスが取れていて使いやすい。

USB機器をテストする

実行環境

  • Ubuntu MATE 20.04

今回はLinuxのデバイスドライバを利用する都合上、テスト環境はLinuxになります。

Python環境構築

テスト用のディレクトリに、pipenvを利用してBehaveをインストールします。

$ mkdir usbtest
$ cd usbtest
$ pipenv --python 3
$ pipenv install behave

テスト対象

今回は私が開発に関わった、aptpodのCAN-USB Interface - AP-CT2A1を対象にします。実際にAP-CT2Aではこの方法で全機能の自動テストコードを書いています。なお、2020/12 時点ではデバイスドライバなどのコードを公開していないため、細かいところはぼかしたり改変しています。

PythonでUSBデバイスを操作する

方法はいくつかあると思いますが、今回はデバイスドライバがあるため、ioctl を利用して操作します。

基本的な手順は以下になります。

  1. デバイスファイルをopenする。
  2. ファイルディスクリプタを利用して、ioctlでIO制御する。

ioctlのリクエストは汎用的なものであればPython側にありますが、今回のように特殊なものは自前で設定する必要があります。 リクエストの種別としてIO、IOR、_IOWなど2があり、C/C++であれば以下のように実装されます。

struct fw_data_s {
    int value;    
};
typedef struct fw_data_s fw_data_t;

#define CMD1 _IO('A', 0x1)
#define CMD2 _IOR('A', 0x2, fw_data_t)
#define CMD3 _IOW('A', 0x3, fw_data_t)

上記のコマンドがデバドラ側にあると仮定して、デバイス操作のためのクラスをPython上で実装すると以下のようになります。__make_xxx のメソッドが、リクエストの生成部分になります。

import ctypes
import fcntl
import logging
import os

class FwData(ctypes.Structure):
    _fields_ = [("value", ctypes.c_int32)]

class ApCt2a:
    def __init__(self, path):
        self.__path = path

    def open(self):
        self.__fd = os.open(self.__path, os.O_RDWR | os.O_NONBLOCK)
        if self.__fd == -1:
            logging.critical("[APTTRX] failed open path:{0}".format(self.__path))

    def close(self):
        os.close(self.__fd)
        self.__fd = None

    def cmd1(self):
        fcntl.ioctl(self.__fd, self.__make_io_req(0x1))

    @property
    def data(self):
        data = FwData()
        fcntl.ioctl(self.__fd, self.__make_ior_req(0x02, ctypes.sizeof(data)), data)
        return data.value

    @data.setter
    def data(self, value):
        data = FwData()
        data.value = value
        fcntl.ioctl(self.__fd, self.__make_iow_req(0x03, ctypes.sizeof(data)), data)

    def __make_io_req(self, nr):
        return ord("A") << 8 | nr

    def __make_iow_req(self, nr, size):
        return 1 << 30 | ord("A") << 8 | nr | size << 16

    def __make_ior_req(self, nr, size):
        return 2 << 30 | ord("A") << 8 | nr | size << 16

テストする仕様

AP-CT2Aのメイン機能は、入力したCAN3データに時刻情報を付与してホストに渡すというものです。さらに複数台あった場合、同期用のケーブルで機器間を接続することで、付与する時刻を同期させることができます。

f:id:aptpod_tech-writer:20201208151649j:plain
AP-CT2A接続図

上の写真で、機器間を繋いでいるのが同期ケーブルです(開発用の機材なので汚いのはご容赦を)。今回は、この同期機能を確認するためのテストを例に取ります。

テストしたいシーケンス

  1. 2台のAP-CT2Aを接続する
  2. 2台のAP-CT2Aを時刻同期する
  3. CANデータを500kbpsで受信できるよう設定する
  4. CANデータを入力する
  5. 2台のAP-CT2A受信したCANデータの時間が一致していることを確認する

実際に書いてみる

ファイルは以下のような形で構成されます。.featureにテストシナリオ、steps/以下に実際にシナリオ内で実行される処理(step)を実装します4

features/
features/everything.feature
features/steps/
features/steps/steps.py

今回の例だと、まずテストシナリオをこのように書きます5

Feature: AP-CT2A テスト

  Scenario: 複数台で時間同期する
    Given 2台のAP-CT2Aを用意する
    And AP-CT2Aを時刻同期する
    And AP-CT2Aを500kbps入力に設定する
    When AP-CT2AにCANデータを500kbpsで10個入力する
    Then AP-CT2AがCANデータを10個取得している
    And AP-CT2Aが受信したCANデータの時刻情報が一致している

そして、各stepの処理について、以下のように書きます。@~のところで対応するstepを指定し、変数として利用したい箇所は{}で記載します6

from behave import given, step, then, when

@given("AP-CT2Aを時刻同期する")
def step_impl(context):
    for d in context.devices:
        d.sync()

@when("AP-CT2AにCANデータを{baudrate:d}kbpsで{num:d}個入力する")
def step_impl(context, baudrate, num):
    send_candata(baudrate, num)

@then("AP-CT2AがCANデータを{num:d}個取得している")
def step_impl(context, num):
    for d in context.devices:
        data = d.receive()
        assert len(data) == num

実行例

上のシナリオを実行した例。

$ pipenv shell
$ behave
Feature: AP-CT2A テスト # features/ap_ct2a.feature:1

  Scenario: 複数台で時間同期する                  # features/ap_ct2a.feature:3
    Given 2台のAP-CT2Aを用意する               # features/steps/ap_ct2a_steps.py:6 0.001s
    And AP-CT2Aを時刻同期する                  # features/steps/ap_ct2a_steps.py:11 0.001s
    And AP-CT2Aを500kbps入力に設定する          # features/steps/ap_ct2a_steps.py:16 0.000s
    When AP-CT2AにCANデータを500kbpsで10個入力する # features/steps/ap_ct2a_steps.py:21 0.000s
    Then AP-CT2AがCANデータを10個取得している       # features/steps/ap_ct2a_steps.py:26 0.000s
    And AP-CT2Aが受信したCANデータの時刻情報が一致している  # features/steps/ap_ct2a_steps.py:31 0.000s

1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
6 steps passed, 

おわりに

今回はUSB機器を例に取りましたが、何らかのプロトコルで通信することで動作するものについてはほぼ自動テスト化できるものと思っています。 外部からの操作を必要とするものに関しては(例えばUSBの挿抜とか)なかなか自動化は難しいですが、aptpodには優秀なHWエンジニアも在籍しており、テスト用の治具も自作したりします。 組み込み機器であっても、既存のフレームワークをうまく使ったり、アイデア次第で煩雑なテスト業務を軽減させることができますので、トライしてみてはいかがでしょうか。