🍳

Sysmac Studio用 単体テストフレームワーク - STUnit

2024/11/24に公開

はじめに

本記事は、PLC(Programmable Logic Controller)向けのソフトウェア開発に従事、または関心のある方で、Structured Text(ST)による開発に興味がある方向けです。OMRON社のSysmac Studioを使用します

今回は、Sysmac Studio用のテストフレームワークです。以下よりライブラリとサンプルプロジェクトをダウンロードして試すことができます。

https://github.com/kmu2030/STUnit

テスト実行制御に関わる部分は、隠蔽しています。ユーザビリティを考慮しての判断で、最大のユーザーである私の要求です。やるべきことをやり、ダメなときはダメだと分かり、それでいて簡単に使えれば、まじまじと中身を見ようとは思いません。データ型定義丸見えなのはどうしようもありません。ユーザーがテストフレームワーク用のグローバル変数を定義しなければいけないのもマイナスポイントです。

課題

PLCソフトウェアにおいて、ソフトウェアテストに対する認識は、一部の開発環境(CODESYSなど)を除いて、一般に浸透せず、その必要性が語られることも多くありません。"モノを制御するのだから意味が無い、現合が多いから意味が無い、大規模ではない、難しい"、理由はいくらでも考えつきます。ただ、そのような状況に対して疑問を持っていたとして、ソフトウェアテストの有用性や現状の危うさを説くだけでは不十分です。実施してその有用性を示す必要があります。手段の有無を前提にしない要不要と、手段があることを前提にしたやるか否かには大きな差があります。実際に確認すれば、その有用性の観察、比較、検証を行えます。

PLCソフトウェアでのテスト実施を考えてみると、ある種の結合テスト、システムテストは、多数の制御機器から成る場合、程度は別にして実施していると考えられます。繋がったら大丈夫とはしないでしょう。そして、試運転は疑似的にE2Eテスト、受け入れテストと見なせます。そうなると不足しているのは、単体テストです。単体テストは最もお得なテストですから、勿体ないことです。

STUnit

STUnitは、単体テストを対象にしたテストフレームワークです。シミュレータでの実行を想定していますが、コントローラ上で実行することもできます。TDDのようなスタイルを可能とする機動性はありませんが、少なくとも一区切りの休息時に実行することはできます。

STUnitは、テスト実行制御FBとテスト宣言、アサーションFUNから成ります。ユーザーはテストフレームワークライブラリをプロジェクトで参照し、テストを通常のPOU(プログラム、FB、FUN)として記述します。テスト実行制御FBとテストPOUをテスト用タスク(タスクは別でもよい)に割り当て、実行することで、SDカード内にテスト結果を出力します。

STUnitは、シンプルです。複数のテストスイートがあっても一度に一つしか実行しない作りです。テスト結果の表現も決め打ちです。一般的な言語のテストフレームワークからすれば、機能は限定的です。しかしながら、アサーションFUNの評価だけはできる限り行っています。ExampleにアサーションFUNのテストを含めているため、気になるアサーションがあればアサーション実行を修正・追加して評価を確認してください。一連のFUN、FBは名前空間を有しています。使用する場合、POUの"名前空間―使用宣言"で関連FUNの名前空間を指定します。

テスト記述

テストは以下のように記述します。できる限りテスト記述で構成し、テスト実行制御に必要な記述を減らす設計にしています。

// テストスイートを定義する。
TestSuite(Name:='USINT型の算術演算の仕様確認',
		  TestTask:=iTask);

// 定型句。
// AwaitTestTaskの戻り値がTRUEであるとき、テストコードを実行してはいけない。
IF AwaitTestTask(iTask) THEN RETURN; END_IF;

// テスト
CASE iState OF
	0:
		// TestCase()でテストケースの開始を宣言し、
		// TestCaseDone()でテストケースの完了を宣言する。
		// アサーションは、TestCase()とTestCaseDone()間で実行する。
		// アサーションはテストケースごとに128回まで。
		
		TestCase(Description:='USINTどうしの和に想定外の挙動はない。',
				 Code:=iState);

		AssertUsintEq(Description:='0 + 0 = 0.',
					  Expected:=0, Value:=(USINT#10#0 + USINT#10#0));
		AssertUsintEq(Description:='0 + 1 = 1.',
					  Expected:=1, Value:=(USINT#10#0 + USINT#10#1));
		AssertUsintEq(Description:='0 + 255 = 255.',
					  Expected:=255, Value:=(USINT#10#0 + USINT#10#255));
		AssertUsintEq(Description:='1 + 0 = 1.',
					  Expected:=1, Value:=(USINT#10#1 + USINT#10#0));
		AssertUsintEq(Description:='1 + 1 = 2.',
					  Expected:=2, Value:=(USINT#10#1 + USINT#10#1));
		AssertUsintEq(Description:='1 + 255 = 0.',
					  Expected:=0, Value:=(USINT#10#1 + USINT#10#255));		
		AssertUsintEq(Description:='255 + 0 = 255.',
					  Expected:=255, Value:=(USINT#10#255 + USINT#10#0));
		AssertUsintEq(Description:='255 + 1 = 0.',
					  Expected:=0, Value:=(USINT#10#255 + USINT#10#1));
		AssertUsintEq(Description:='255 + 255 = 254.',
					  Expected:=254, Value:=(USINT#10#255 + USINT#10#255));

		TestCaseDone();

		Inc(iState);
		
	1:
		// テストケースは、複数サイクルにわたってもよい。
		
		TestCase(Description:='USINTどうしの差に想定外の挙動はない。',
				 Code:=iState);

		AssertUsintEq(Description:='0 - 0 = 0.',
					  Expected:=0, Value:=(USINT#10#0 - USINT#10#0));
		AssertUsintEq(Description:='0 - 1 = 255.',
					  Expected:=255, Value:=(USINT#10#0 - USINT#10#1));
		AssertUsintEq(Description:='0 - 255 = 1.',
					  Expected:=1, Value:=(USINT#10#0 - USINT#10#255));
		
		Inc(iState);
	2:
		AssertUsintEq(Description:='1 - 0 = 1.',
					  Expected:=1, Value:=(USINT#10#1 - USINT#10#0));
		AssertUsintEq(Description:='1 - 1 = 0.',
					  Expected:=0, Value:=(USINT#10#1 - USINT#10#1));
		AssertUsintEq(Description:='1 - 255 = 2.',
					  Expected:=2, Value:=(USINT#10#1 - USINT#10#255));
		
		Inc(iState);
	3:
		AssertUsintEq(Description:='255 - 0 = 255.',
					  Expected:=255, Value:=(USINT#10#255 - USINT#10#0));
		AssertUsintEq(Description:='255 - 1 = 254.',
					  Expected:=254, Value:=(USINT#10#255 - USINT#10#1));
		AssertUsintEq(Description:='255 - 255 = 0.',
					  Expected:=0, Value:=(USINT#10#255 - USINT#10#255));
		
		TestCaseDone();

		iState := STATE_ALL_TEST_DONE;

	// Done
	1000:
		// 全てのテストケースが完了したら実行する。
		TestSuiteDone(TestTask:=iTask);
		
		Inc(iState);
END_CASE;

テスト記述の構造はシンプルです。TestSuite FUNでテストスイートの開始を宣言し、TestSuiteDone FUNでテストスイートの完了を宣言します。この二つの記述には、テスト実行制御が漏洩していますが、定型句です。AwaitTestTaskはテスト実行制御そのものですが、やはり定型句です。テストケースは、テストスイート内で実行します。TestCase FUNでテストケースの開始を宣言し、TestCaseDone FUNでテストケースの完了を宣言します。アサーションはテストケース内で実行します。

テスト実行制御FBとテストPOUを同一タスクで実行したとして、1サイクルに1テストケースしか実行できないという制約があります。テスト実行制御FBはテストPOUと同じか短い周期のタスクで実行する必要があります。テストPOUをテスト実行制御FBより短い周期のタスクで実行すると、テスト出力を喪失する可能性があります。テストスイートのネスト、テストケースのネスト、テストケースの並列実行、テスト失敗による早期終了機能はありません。また、パラメータ化の手段も今のところありません。

テストを実装するPOUは、プログラム、FB、FUNのどれでも可能ですが、推奨はFBです。テストスイートごとにPOUを分けるとします。プログラムは、追加するたびにタスクへの追加が必要になります。FUNは、サイクルをまたぐ内部状態が無いため、テスト実装者がテスト外で部分的に実行制御を行うことになります。FBは、テストをその内部で完結でき、追加もテスト実行制御FBを実行するPOU内でインスタンス化して実行するだけです。

テスト出力

テスト記述で示した例は、SDカード内の"/USINT型の算術演算の仕様確認.txt"に以下の出力をします。ファイル名はTestSuite FUNのNameで指定した値です。

✔ [0] USINTどうしの和に想定外の挙動はない。:9 asserts.
✔ [1] USINTどうしの差に想定外の挙動はない。:9 asserts.

Failure   (❌) : 0 tests.
Success (✔) : 2 tests.
Total : 2 tests.

✔ Test suite has no failures.

全てのテストケースのアサーションが真であるとこのようなメッセージになります。試しに全てのアサーションをAssertUsintNe(一致しない)にすると以下の出力をします。

❌ [0] USINTどうしの和に想定外の挙動はない。:9 asserts.
  0 + 0 = 0. not expect 0, but 0
  0 + 1 = 1. not expect 1, but 1
  0 + 255 = 255. not expect 255, but 255
  1 + 0 = 1. not expect 1, but 1
  1 + 1 = 2. not expect 2, but 2
  1 + 255 = 0. not expect 0, but 0
  255 + 0 = 255. not expect 255, but 255
  255 + 1 = 0. not expect 0, but 0
  255 + 255 = 254. not expect 254, but 254
❌ [1] USINTどうしの差に想定外の挙動はない。:9 asserts.
  0 - 0 = 0. not expect 0, but 0
  0 - 1 = 255. not expect 255, but 255
  0 - 255 = 1. not expect 1, but 1
  1 - 0 = 1. not expect 1, but 1
  1 - 1 = 0. not expect 0, but 0
  1 - 255 = 2. not expect 2, but 2
  255 - 0 = 255. not expect 255, but 255
  255 - 1 = 254. not expect 254, but 254
  255 - 255 = 0. not expect 0, but 0

Failure   (❌) : 2 tests.
Success (✔) : 0 tests.
Total : 2 tests.
First failure  : [0] USINTどうしの和に想定外の挙動はない。

❌ Test suite has 2 failures.

アサーションが偽であるとき、指定したDescriptionを出力します。テスト出力は、末尾行とその前数行を確認すれば、失敗したテストケースが分かります。出力は、全くの独善的判断によるものですがリファクタリングによって生じるテストの失敗は思いがけず多数になることもあるため、このようにしています。一つのアサーションも行わないテストケースは失敗と判断します。そして、一つのテストケースもないテストスイートも失敗と判断します。

より軽量な手段

テストフレームワークを実装せずとも、成否の通知を行うだけであれば、組み込みのシステム命令のログ出力を使用することもできます。文字列の直接出力はできませんが、FUNでラップして軽量アサートとして使用できます。毎度指定するパラメータが手間である場合は、FBでラップします。規模と目的に応じて手段を選ぶことは可能で、他の一般的な言語同様のテスト体験は難しいですが、単体テストがもたらす利益の7割ぐらいを目指すという考えで取り組めばそれなりに手はあります。

使い方

STUnitは、以下の手順で使用します。

  1. テストを記述するプロジェクトで"STUnit.slr"を参照します。
  2. "gSTUnitSingleton : STUnitContext"をグローバル変数に定義します。
  3. STUnitController FBのインスタンスを実行するPOUを作成し、タスクに登録します。
  4. テストを記述したPOUを作成し、3で作成したPOUに追加します。
  5. プロジェクトをビルドします。
  6. シミュレータまたは、実機で実行します。
  7. "C:\OMRON\Data\SimulatorData\CARD\Memory001"または、SDカード内のテスト出力を確認します。

サンプルプロジェクトに手を加えてみましょう。使えそうな範囲が把握できます。需要があり、私に責務が発生すればドキュメントの品質も変わるでしょう。

まとめ

少なくともSysmac Studioではユーザーレベルでテストフレームワークを実装し、自動化テストを実施できる可能性は示せました。今回のテストフレームワークは質素ですが、あるのと無いのとでは大きな差があります。制約の軽減、欠けた機能の実装、カスタマイズ可能なテスト出力、ファイル以外へのテスト出力といった改良も考えられますが、テストについての発展をもたらすのは、パラメータ化テストでしょう。

CASEステートマシンを骨組みとして単体テストのテストフレームワークを手に入れ、ストリーム処理の確認もしました。これだけでも既に多くの道が開けています。ここからは、トピックの幅を広げつつ一つ一つは焦点を絞って取り組むことにしましょう。寄り道はしますが、目的地はあります。最初の一歩は、リングバッファです。

Discussion