Lチカで始めるテスト自動化

##1. はじめに
[Lチカ](https://www.google.co.jp/search?hl=ja&as_q=Lチカ)といえば初めて作るプログラムの定番ですがテストは人力（LEDの点灯／消灯を目視確認）で行っていることと思います。そこで、スイッチのON／OFFに連動してLEDが点灯／消灯するLチカプログラムとテストベンチを製作し、テストを自動化します。

![testbench.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/32c0b575-5855-2ea7-241d-6898d1702fc8.png)

|自動化の対象        |自動化の方法
|-------------------|----
|スイッチのON／OFF操作|テスト対象のスイッチから線材を飛ばしてリレーに接続し、テスト治具でリレーを制御する
|LEDの点灯／消灯の確認|LEDの電圧をマイコンのA/Dコンバータで測定する
|Go／No-Go判定       |結果と期待値（基準値）を比較する

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">テスト実行の様子です（字幕付けました） <a href="https://t.co/LWxqawvevV">pic.twitter.com/LWxqawvevV</a></p>&mdash; ka’s (@pbjpkas) <a href="https://twitter.com/pbjpkas/status/1261885396583018496?ref_src=twsrc%5Etfw">May 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

以下の動画は[Arduino治具とオシロスコープをRaspberry Piに接続してテストベンチを組み、Jenkinsでテストを自動化する](https://qiita.com/pbjpkas/items/c33c6c55a927eccc4d85)で製作したテストベンチに組み込んでテストを実行している様子です。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Raspberry Pi(テストランナー)にArduino UNO(テスト治具)を接続してWindows10のJenkinsからテストを実行している様子です。 <a href="https://t.co/58ixi0Hddl">pic.twitter.com/58ixi0Hddl</a></p>&mdash; ka’s (@pbjpkas) <a href="https://twitter.com/pbjpkas/status/1261890945202573313?ref_src=twsrc%5Etfw">May 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

##2. テスト対象
Arduino(UNO R3やLeonardo)のDigital Pin #2に接続されたスイッチを押下すると Digital Pin #12に接続しているLEDが点灯する、というものです。LEDの電流制限抵抗は3.3kΩです。

![Lチカボード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/50d2a805-ff6e-c27e-2220-81f2c303332b.png)

```c++:ArduinoLedLight.ino
// LED
class Led
{
    public:
        Led(int pin);
        void on();
        void off();
    private:
        int m_led_pin;
};

Led::Led(int pin)
{
    m_led_pin = pin;
    pinMode(m_led_pin, OUTPUT);
    digitalWrite(m_led_pin, LOW);
}

void Led::on(void)
{
    digitalWrite(m_led_pin, HIGH);
}

void Led::off(void)
{
    digitalWrite(m_led_pin, LOW);
}

// SWITCH
class Sw
{
    public:
        Sw(int pin);
        bool isOpen();
    private:
        int m_sw_pin;
};

Sw::Sw(int pin)
{
    m_sw_pin = pin;
    pinMode(m_sw_pin, INPUT_PULLUP);
}

bool Sw::isOpen(void)
{
    if ( digitalRead(m_sw_pin) ) //HIGH:Open, LOW:Close
    {
        return true;
    }
    else
    {
        return false;
    }
}

// initialize
Led led = Led(12);
Sw  sw  = Sw(2);
void setup()
{
    // nothing
}

// logic
void loop()
{
    if( sw.isOpen() )
    {
        led.off();
    }
    else
    {
        led.on();
    }
}
```

##3. テスト治具
テスト治具はコマンドで操作します。

###3.1 コマンド
UARTの通信速度は115,200[bps]です。

|コマンド|操作内容
|-------+---
|n      |リレーをONする
|f      |リレーをOFFする
|p      |リレーがアサインされているピン番号を表示する
|v      |Analog Pin A0に入力された電圧を0[mV]～5,000[mV]の範囲で表示する
|i      |テスト治具のモデル名やバージョンを表示する
|h      |ヘルプメッセージを表示する

###3.2 プログラム
ピンアサインを以下に示します。

|Pin            |接続
|---------------+---
|Digital Pin #12|リレー駆動回路(4章)のGPIO端子
|Analog Pin A0  |テスト対象(2章)のLEDのアノード側

```c++:ArduinoLedLightTestBench.ino
// RELAY
class Relay
{
    public:
        Relay(int pin);
        void on();
        void off();
        void pin_number();
    private:
        int m_relay_pin;
};

Relay::Relay(int pin)
{
    m_relay_pin = pin;
    pinMode(m_relay_pin, OUTPUT);
    digitalWrite(m_relay_pin, LOW);
}

void Relay::on(void)
{
    digitalWrite(m_relay_pin, HIGH);
}

void Relay::off(void)
{
    digitalWrite(m_relay_pin, LOW);
}

void Relay::pin_number(void)
{
    Serial.println(m_relay_pin);
}

// ADC
class Adc
{
    public:
      Adc(int pin);
      long get_value(); // 0[mV] - 5,000[mV]
    private:
      int m_adc_pin;
};

Adc::Adc(int pin)
{
    m_adc_pin = pin;
}

long Adc::get_value(void)
{
    int val = analogRead(m_adc_pin);
    return map(val, 0, 1023, 0, 5000);
}

// initialize
Relay relay = Relay(12);
Adc   adc   = Adc(0);

void setup()
{
    Serial.begin(115200);
    //Serial.println("Arduino Test Bench");
}

// logic
void loop()
{
    while(1)
    {
        if(Serial.available())
        {
            char buf;
            buf = Serial.read();
            if(buf == 'n') // oN
            {
                relay.on();
            }
            if(buf == 'f') // oFf
            {
                relay.off();
            }
            if(buf == 'p') // Pin number
            {
                relay.pin_number();
            }
            if(buf == 'v') // get_adc_Value
            {
                long adc_value = 0;
                adc_value = adc.get_value();
                Serial.println(adc_value);
            }
            if(buf == 'i') //Information
            {
                Serial.println("Arduino Test Bench Ver.100");
            }
            if(buf == 'h') // Help
            {
                Serial.println("n : relay oN");
                Serial.println("f : relay oFf");
                Serial.println("p : relay Pin number");
                Serial.println("v : adc a0 Value(0[mV] - 5,000[mV])");
                Serial.println("i : model and version Information");
            }
        }
    }
}
```

##4. リレー駆動回路
マイコンのGPIOの出力電流はリレーを駆動するには足りないためトランジスタで駆動回路を組みます。

![リレー駆動回路](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/d9be6f08-44ce-5674-bc93-139ec917d4af.png)

|部品          |型番                                                              |数量|備考
|--------------+------------------------------------------------------------------+----+---
|リレーU1      |[G5V-1 DC5](https://www.google.co.jp/search?hl=ja&as_q=G5V-1+DC5) |1   |コイル 5V 30mA
|トランジスタQ1|[2SC-1815](https://www.google.co.jp/search?hl=ja&as_q=2SC1815)    |1   |
|抵抗R1        |2.2kΩ                                                             |1   |1kΩ～4.7kΩ
|抵抗R2        |10kΩ                                                              |1   |
|ダイオードD1  |[1S10](https://www.google.co.jp/search?hl=ja&as_q=1S10)           |1   |汎用小信号用ダイオード[1N4148](https://www.google.co.jp/search?hl=ja&as_q=1N4148)や汎用整流用ダイオード[1N4007](https://www.google.co.jp/search?hl=ja&as_q=1N4007)など、逆耐電圧が回路電圧の10倍以上かつ順方向電流が負荷電流以上のもの

|回路|接続
|----+---
|VCC |テスト治具(3章)の5V
|GND |テスト治具(3章)のGND
|GPIO|テスト治具(3章)のDigital Pin #12
|a   |テスト対象(2章)のDigital Pin #2
|c   |テスト対象(2章)のGND

##5. テストベンチの組み立て
<font color="red">重要な注意</font>

- <font color="red">テスト治具に電源を投入してからテスト対象を操作すること。</font>
- <font color="red">電源電圧よりも高い電圧を入力ポートに印加するとマイコンを破損する恐れがあります。</font>
- <font color="red">AVR ATmega328のRESETピン以外のピンの絶対最大定格電圧は-0.5V～Vcc+0.5Vです[^1]。</font>

テスト対象、テスト治具、リレー駆動回路の結線を以下に示します。

- テスト対象はUSB電源アダプターやモバイルバッテリーで給電します。
- テスト治具はテストランナー(7章)のPCに接続します。
- 4章のリレー駆動回路の抵抗R1、R2は、以下の回路図ではそれぞれR2、R3になっています。
- リレーのON／OFFが分かるように黄色のLEDを追加しています（回路図のLED2、R4)。

![led-blink-test-bench_breadboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/c4d52d1a-0df3-6dc5-509d-ae5684250596.png)
![led-blink-test-bench_circuit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/ca7f515e-9993-3b8f-0139-22990c9556cc.png)

- 実機の実装例（手持ちの都合で片方がArduino Leonardoだったりリレーが941H-2C-5Dになっています）。

![test-benchV2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/092ff7bd-2108-b8c4-ec54-7f1cdf8fef15.jpeg)

##6. テスト設計
- テスト治具のADCにはLED点灯時に約1.8V、消灯時に0Vが印加されますのである基準値(例:1.6Vを超え2.0V未満なら点灯、0.2V未満なら消灯)を決めて点灯・消灯を判定します。
- 接続しているテスト治具が意図したものかといった確認も行います。
- 不具合を検出したらそこでテストを中止します。

|手順|確認したいこと                                |操作                         |期待値
|----+----------------------------------------------+-----------------------------+---
|1   |テスト治具のモデル名などが正しいこと          |テスト治具にコマンド"i"を送る|コマンドの戻り値が期待通りの文字列であること
|2   |リレーがアサインされているピン番号が正しいこと|テスト治具にコマンド"p"を送る|コマンドの戻り値が12であること
|3   |スイッチを閉じるとLEDが点灯する               |テスト治具にコマンド"n"を送る|テスト対象のLEDが点灯すること
|    |                                              |テスト治具にコマンド"v"を送る|コマンドの戻り値が1600より大きく2000より小さいこと
|4   |スイッチを開くとLEDが消灯する                 |テスト治具にコマンド"f"を送る|テスト対象のLEDが消灯すること
|    |                                              |テスト治具にコマンド"v"を送る|コマンドの戻り値が200より小さいこと

##7. テストランナーの実装
6章のテストを実行できるようなテストランナーを実装します。Excel VBAでもPythonでも構いませんがここではPythonの例を示します。

###7.1 実行環境
実行環境はWindows10(1909)+Anaconda3 2019.07 (Python 3.6.9 64-bit)です。COMポートはCOM3が割り当てられています。

Anacondaのプロンプト画面でコマンドで実行します。

```:例
c:\hoge>python test-runner.py
```

####7.1.1 Windows10(1909)+WSL Ubuntu 18.04 LTSで動かす
Windows10(1909)+WSL Ubuntu 18.04 LTSで動かすには以下のようにします。

- COMポートの指定を"COM3"ではなく"/dev/ttyS3"にする
- teratermなどのソフトであらかじめ"COM3"で通信を行う

原因は分かっていませんが筆者の環境ではあらかじめteratermなどで通信を行うことでコマンド送受信をできるようになりました。

####7.1.2 Raspberry Piにテスト治具(Arduino UNO)を接続し、Raspberry Piで動かす
test-runner.pyの改修箇所は下記2点です。

- 改行コード→LF
- COM3→/dev/ttyACM0

###7.2 テストランナーのコマンド

|コマンド   |引数                  |機能
|-----------+----------------------+-------------------------------------------------
|#          |なし                  |#で始まる行はコメント行とみなす
|sleep      |スリープ時間(秒)      |コマンド実行の待ち時間を指定する
|open_uart  |テスト治具のポート番号|COM3、/dev/ttyS3、/dev/ttyACM0などを指定する
|send       |テスト治具のコマンド  |テスト治具にコマンドを送信する
|rcvd       |なし                  |テスト治具の応答を受信し変数valにストアする
|eval_str_eq|期待値(文字列)        |変数valが期待値の文字列と等しいか評価する
|eval_int_eq|期待値(int型)         |変数valが期待値の値(int型)と等しいか評価する
|eval_int_gt|基準値(int型)         |変数valが基準値(int型)より大きいか評価する
|eval_int_lt|基準値(int型)         |変数valが基準値(int型)より小さいか評価する

###7.3 テストランナー本体
CSV形式でscript.csvに記述されているテストスクリプトを読み、result.csvに実行結果を格納します。テスト治具のポート番号はテストスクリプトで指定します。

```python:test-runner.py
#!/usr/bin/python3

#
# This software includes the work that is distributed in the Apache License 2.0
#

from time import sleep
import serial
import codecs
import csv
import sys

UNINITIALIZED = 0xdeadbeef

def serial_write(h, string):
    if h == UNINITIALIZED:
        print("UART Not Initialized.")
        return False
    else:
        string = string + '\n'
        string = str.encode(string)
        h.write(string)
        return True

def main():
    is_passed = True
    val = str(UNINITIALIZED)
    uart = UNINITIALIZED

    with codecs.open('script.csv', 'r', 'utf-8') as file:
        script = csv.reader(file, delimiter=',', lineterminator='\r\n', quotechar='"')

        with codecs.open('result.csv', 'w', 'utf-8') as file:
            result = csv.writer(file, delimiter=',', lineterminator='\r\n', quotechar='"')

            for cmd in script:
                #print(cmd)

                if "#" in cmd[0]:
                    pass

                elif cmd[0]=="sleep":
                    sleep(float(cmd[1]))
                    cmd.append("OK")

                elif cmd[0]=="open_uart":
                    try:
                        uart = serial.Serial(cmd[1], 115200, timeout=1.0, dsrdtr=1)
                        cmd.append("OK")
                    except:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="send":
                    ret = serial_write(uart, cmd[1])
                    if ret == True:
                        cmd.append("OK")
                    else:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="rcvd":
                    try:
                        val = uart.readline().strip().decode('utf-8')
                        cmd.append(val)
                        cmd.append("OK")
                    except:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="eval_str_eq":
                    if str(val) == str(cmd[1]):
                        cmd.append("OK")
                    else:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="eval_int_eq":
                    if int(val) == int(cmd[1]):
                        cmd.append("OK")
                    else:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="eval_int_gt":
                    if int(val) > int(cmd[1]):
                        cmd.append("OK")
                    else:
                        cmd.append("NG")
                        is_passed = False

                elif cmd[0]=="eval_int_lt":
                    if int(val) < int(cmd[1]):
                        cmd.append("OK")
                    else:
                        cmd.append("NG")
                        is_passed = False

                else:
                    cmd.append("#")

                print(cmd)
                result.writerow(cmd)

                if is_passed == False:
                    print("FAIL")
                    sys.exit(1)

    if is_passed == True:
        print("PASS")
        sys.exit(0)

main()
```

###7.4 テストスクリプト
通信開始時にArduino UNOにリセットがかかることがあるためテストスクリプトの冒頭で2秒のsleepを入れています。

```sh:script.csv
# wait for if restart Arduino Uno
sleep,2
#
# Open UART
open_uart,COM3
#
# model and version check
send,i
rcvd
eval_str_eq,Arduino Test Bench Ver.100
#
# relay assigned Pin number check
send,p
rcvd
eval_int_eq,12
#
# relay ON
send,n
sleep,1
send,v
rcvd
eval_int_gt,1600
eval_int_lt,2000
#
# relay OFF
send,f
sleep,1
send,v
rcvd
eval_int_lt,200
#
# end
```

###7.5 テスト実行結果
script.csvと似ていますが2カラム目にrcvdの戻り値が、3カラム目にOKまたはNGが格納されています。

```sh:result.csv
# wait for if restart Arduino Uno
sleep,2,OK
#
# Open UART
open_uart,COM3,OK
#
# model and version check
send,i,OK
rcvd,Arduino Test Bench Ver.100,OK
eval_str_eq,Arduino Test Bench Ver.100,OK
#
# relay assigned Pin number check
send,p,OK
rcvd,12,OK
eval_int_eq,12,OK
#
# relay ON
send,n,OK
sleep,1,OK
send,v,OK
rcvd,1788,OK
eval_int_gt,1600,OK
eval_int_lt,2000,OK
#
# relay OFF
send,f,OK
sleep,1,OK
send,v,OK
rcvd,0,OK
eval_int_lt,200,OK
#
# end
```

```sh:result.csv（テスト治具のバージョン不一致）
# wait for if restart Arduino Uno
sleep,2,OK
#
# Open UART
open_uart,COM3,OK
#
# model and version check
send,i,OK
rcvd,Arduino Test Bench Ver.095,OK
eval_str_eq,Arduino Test Bench Ver.100,NG
```

```sh:result.csv（スイッチをONしたのにLEDが点灯していない）
# wait for if restart Arduino Uno
sleep,2,OK
#
# Open UART
open_uart,COM3,OK
#
# model and version check
send,i,OK
rcvd,Arduino Test Bench Ver.100,OK
eval_str_eq,Arduino Test Bench Ver.100,OK
#
# relay assigned Pin number check
send,p,OK
rcvd,12,OK
eval_int_eq,12,OK
#
# relay ON
send,n,OK
sleep,1,OK
send,v,OK
rcvd,0,OK
eval_int_gt,1600,NG
```

##8. ステップ数
[かぞえチャオ！Ver.1.68](http://ciao-ware.c.ooco.jp/)でステップ数を数えました(ファイル名はかぞえチャオで開けるよう適当に変更しています)。

| パス名                       | 総ステップ数 | 実ステップ数 | コメント[％] |
|------------------------------|--------------|--------------|--------------|
| ArduinoLedLight.cpp          | 77           | 65           | 5.6          |
| ArduinoLedLightTestBench.cpp | 107          | 92           | 7.1          |
| test-runner.py               | 113          | 86           | 3.4          |
| 全ステップ数                 | 297          | 243          | 5.0          |


##9. おわりに
- ~~以下の理由によりRaspberry Piへの移植やJenkinsでの実行をしやすいと考えられ、~~Raspberry Pi[^2]およびJenkisでの動作を確認し、Lチカでここまで出来るなら組込みやIoTデバイスもシステムレベルのCI/CDをできそうと思いました。
  - テストランナーをpythonで書いた
  - 期待結果との比較まで作り込んだ
  - テストの成功、失敗に応じてexitコードを返すようにした
  - 測定器をテストベンチに組込むのは[Arduino治具とオシロスコープをRaspberry Piに接続してテストベンチを組み、Jenkinsでテストを自動化する](https://qiita.com/pbjpkas/items/c33c6c55a927eccc4d85)で確認済み
- UTレベルのテスト自動化はTDDで、結合レベル～システムレベルのテスト自動化は本方式で、と組み合わせることでテストのカバレッジ向上を期待できます。
- [ソフトウェアテストの小ネタ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/software-testing-koneta)で書いた[ストップウォッチを使う性能テストを実ステップ300行に満たない自動テストシステムで自動化する](https://qiita.com/pbjpkas/items/d525d7d19095d1e26452)が小ネタといいつつ実ステップ数が266行あったのですが、今回作った自動テストシステムは実ステップ数が243行、平均すると1ファイル81行で、これなら「初めてのテスト自動化」を名乗っても良いかなあと。

##10. 参考資料
この記事を作成するにあたって参考にさせていただいた記事です。

- [Arduinoのオブジェクト指向プログラミング](http://monakaice88.hatenablog.com/entry/20130404/1365016051)
- [Arduino UNOでpyserialを使ったら再起動してしまった](https://qiita.com/pyu666/items/fc2e65e70dc92231ad81)
- [pythonでシリアルポート通信](https://qiita.com/niikura/items/d6d230c710a8de7e5dc1)
- [Python　§８２　: シリアル通信（エンコードとデコード）](https://ameblo.jp/hitochan007/entry-12103254519.html)
- [関係演算子](https://ja.wikipedia.org/wiki/%E9%96%A2%E4%BF%82%E6%BC%94%E7%AE%97%E5%AD%90)
- [Pythonでプログラムを終了させる：sys.exit()](https://uxmilk.jp/12929)
- [電子回路におけるリレー使用上の注意事項](https://www3.panasonic.biz/ac/j/control/relay/app_circuits/index.jsp)
- [リレーコイルのサージ保護について](https://ac-blog.panasonic.co.jp/リレ-コイルのサ-ジ保護について)
- [定格電圧と動作表示、サージ対策　仕様条件検索ガイド（一般リレー）](https://www.fa.omron.co.jp/selector/guide/03/)
- [トランジスタのスイッチ回路で適切なベース抵抗は何Ω？](https://cc.cqpub.co.jp/system/contents/1393/)

##11. 変更履歴
|日付      |変更内容
|----------+----
|2020-05-14|・テスト実行の動画のリンクを追加
|2020-05-16|・7.1.2(Raspberry Piにテスト治具(Arduino UNO)を接続し、Raspberry Piで動かす方法)を追記<br>・9(おわりに)を変更
|2020-05-17|・テスト実行の動画を差し替え<br>・Raspberry Pi(テストランナー)にArduino UNO(テスト治具)を接続してWindows10のJenkinsからテストを実行している様子を追記
|2020-05-19|・テストランナーにopen_uartコマンドを追加しテスト治具のポート番号をテストスクリプトで指定するように修正<br>・テストランナーのライセンスにApache License 2.0を指定<br>・8(ステップ数)、9(おわりに)を変更
|2020-05-21|・テストランナーの変数名を修正(err→is_passed)
|2020-06-04|・ダイオードの選定方法を補足

[^1]: [ATmega328P Datasheet](http://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf) 28.1 Absolute Maximum Ratings
[^2]: test-runner.pyの改修箇所を7.1.2に追記しました