【IRIS/Cache】ユニットテスト(作成編)

本記事は3回に渡って、ユニットテストの作成について解説します。

※この記事は下記の方向けになります。
  • 長期間運用が想定されるシステムを構築する方
  • 不具合が多くて悩んでいる方
  • コードの品質向上を目的とした、ユニットテストの導入検討を行っている方

はじめに

ユニットテストとは?

プログラムを構成する最小単位である「関数」や「メソッド」単位で、その正しさを検証するためのテスト手法になります。

ObjectScriptでは、個々のクラスメソッドやインスタンスメソッドの入出力が、仕様通りに動作しているかどうかを確認します。

ユニットテストを導入する最大の目的は、「バグを早期に発見しやすくする事」です。
また、関数ごとにテストを定義しておくことで、将来的なコード変更によって意図しない動作を素早く検知できます。

このように、ユニットテストはコードの品質を高め、保守性を向上させるために欠かせない仕組みだといえます。

本記事は、ユニットテストについて解説を行います。

ユニットテストについて

ObjectScriptでの各役割は下記になります。

項目説明ObjectScriptの役割
テストスイート機能的に共通する複数のテストケースをまとめるフォルダ
テストケース1つの機能やクラス等において、
一連の振る舞いを検証するテストメソッドの集合体
クラス
テストメソッド1つの機能・条件を検証するための最小単位のテストメソッド

ユニットテストを実行すると、結果を専用の画面で確認する事が可能です。

■ユニットテストの結果確認画面

では、テストケースを作成していきましょう!

テストケース(テストクラス)の作成

テストケース(テストクラス)の作成を行います。

テストケースに必用な要素
  • %UnitTest.TestCaseを継承したクラスを用意する
  • 関数名「Test〇〇〇〇」のテスト用関数を用意する
  • 検証用のロジックを作成する
  • 必用があれば、環境設定用関数を用意する

では、一つずつ確認していきます。

%UnitTest.TestCaseを継承したクラスを用意する

新規クラスを作成し、Extendsに「%UnitTest.TestCase」を設定します。

これにより、下記が使用可能になります。

  • %outUnitTest.incのマクロ
  • %UnitTest.TestCase.clsの関数

これらに関しては、後程詳細に説明します。

関数名「Test〇〇〇〇」のテスト用関数を用意する

テスト用のクラスを作成したら、次は動的関数を作成します。
その際、関数名は必ず「Test〇〇〇〇」と命名します。

先ずは、サンプルとして「足し算結果を確認するためのテスト」を作成してみましょう。

名称は「TestAddition」にします。

インスペクタ > Method > 新規メソッド(M) を選択

■現在のテスト用クラスの状態
 空の関数が追加されました。

Class developer.export.UnitTest Extends %UnitTest.TestCase
{

/// ユニットテストの一つ。足し算機能のテスト用関数
Method TestAddition()
{
}

}

次は検証用のロジックを記述していきます。

検証用のロジックを作成する

検証用のロジックを記述する前に、検証に使用できるマクロを解説します。

■ユニットテスト用マクロ一覧

マクロ名説明
$$$AssertEquals(A, B, コメント)AとBが一致している事を確認
$$$AssertNotEquals(A, B, コメント)AとBが不一致である事を確認
$$$AssertStatusOK(status, コメント)ステータスが正常である事を確認
$$$AssertStatusNotOK(status, コメント)ステータスがエラーである事を確認
$$$AssertTrue([式], コメント)式がTrueである事を確認
$$$AssertNotTrue([式], コメント)式がFalseである事を確認
$$$AssertFilesSame(fileA, fileB, コメント)ファイルの内容の一致を確認
$$$AssertFilesSQLUnorderedSame(fileA, fileB, コメント)SQL クエリ結果を含む 2 つのファイルに、順序付けられていない同じ結果が含まれる事を確認
$$$LogMessage(コメント)メッセージの登録
$$$AssertSuccess(コメント)メッセージの登録(成功時)
$$$AssertFailure(コメント)メッセージの登録(失敗時)
$$$AssertSkipped(コメント)メッセージの登録(テストがスキップされた時)

これらマクロを利用して、検証ロジックを作成していきます。

$$$AssertEquals、$$$AssertNotEquals

$$$AssertEqualsは、2つの値が一致しているかを確認するマクロです。
「関数の戻り値」と「期待値」を比較します。

例えば、下記は2つの引数を足して返す関数になります。

■サンプル用の足し算関数

Class developer.test.Sample
{

ClassMethod Addition(arg1 As %Integer, arg2 As %Integer) As %Integer
{
    q arg1 + arg2
}

}

上記関数の検証用のテストメソッドを作成します。
 →サンプルなので、ログ関連がふんだんに盛り込まれています。

Class developer.export.UnitTest Extends %UnitTest.TestCase
{

/// ユニットテストの一つ。足し算機能のテスト用関数
Method TestAddition()
{
	d $$$LogMessage("足し算関数の検証")
	
	// 一致することを検証する
	s ans = ##class(developer.test.Sample).Addition(3,4)
	, chk = 7
	d $$$AssertEquals(ans, chk, $$$FormatText("足し算関数検証(一致) %1 = %2", ans, chk))
	
	// 不一致することを検証する
	s ans = ##class(developer.test.Sample).Addition(8,20)
	d $$$AssertNotEquals(ans, chk, $$$FormatText("足し算関数検証(不一致) %1 = %2", ans, chk))
	
	d $$$AssertSuccess("テスト成功")
}
}

関数の戻り値(ans)」と「期待(chk)」を比較しています。

折角のテストサンプルなので、ついでに失敗ケースも作成しておきましょう。

/// 検証エラー
Method TestAdditionError()
{
	d $$$LogMessage("足し算関数の検証(エラーケース)")
	
	// 検証エラーを確認する
	s ans = ##class(developer.test.Sample).Addition(5,1)
	, chk = 7
	d $$$AssertEquals(ans, chk, $$$FormatText("足し算関数検証(エラー) %1 = %2", ans, chk))	
	
	d $$$AssertFailure("テストエラー")
}

これで、テスト実行時に検証エラーを確認する事ができます。

$$$AssertStatusOK、$$$AssertStatusNotOK

このマクロは、ステータスの状態を検証するマクロになります。

サンプル関数は、データの「登録(%Save)」と「削除(%DeleteId)」を行います。
 → 両関数は共に戻り値が「%Status」です。

下記に新しいテストクラスを用意しました。

■サンプル用のデータクラス
 データの登録と削除用に「save」「delete」の関数を用意しています。

Class developer.test.TestData Extends %Persistent
{

Index idx On test [ PrimaryKey ];

Property test As %String;

ClassMethod save(name As %String) As %Status
{
	s o = ..%New()
	, o.test = name
	q o.%Save()
}

ClassMethod delete(name As %String) As %Integer
{
	&sql(select id into :id from developer_test.TestData where test=:name)
	q:(SQLCODE=100) $$$ERROR($$$CacheError, "データがありません name = "_name)
	q ..%DeleteId(id)
}
}

■ステータスを検証するテスト関数

Class developer.export.UnitTestObject Extends %UnitTest.TestCase
{
Parameter NAME = "サンプルテスト";

/// ユニットテスト:オブジェクトの登録
Method TestSave()
{
	// データ登録テスト
	s sts = ##class(developer.test.TestData).save(..#NAME)
	d $$$AssertStatusOK(sts, $$$FormatText("データの登録 name = %1", ..#NAME))
	
	// ユニークエラー
	s sts = ##class(developer.test.TestData).save(..#NAME)
	d $$$AssertStatusNotOK(sts, $$$FormatText("登録時エラー %1 = ", $system.Status.GetErrorText(sts)))
}

/// ユニットテスト:オブジェクトの削除
Method TestDelete()
{
	// データ削除テスト
	s sts = ##class(developer.test.TestData).delete(..#NAME)
	d $$$AssertStatusOK(sts, $$$FormatText("データの削除 name = %1", ..#NAME))
}
}

save」「delete」の戻り値「%Status」を、マクロで検証しています。

ただし、このテストには現状、下記2点の「問題」が存在しています。

【save関数】
・既存のレコードが存在したままの場合、プライマリキー重複のエラーとなる。
 → レコードの削除が必要になる

【delete関数】
・該当のレコードが存在していないと、削除エラーとなる。
 → 名称順に実行されるため、saveより先に実行されてしまう。
 → 単体で実行できない

次は、これらを解決したいと思います。

テスト環境を整備する関数を使用する

テスト実施前に、テスト環境を整備する必要があれば、下記メソッドを利用して環境を整えます。

関数名説明
OnBeforeOneTestテスト・クラスの各メソッドの直前に実行
OnBeforeAllTestsテスト・クラス実行前に1回実行
OnAfterOneTestテスト・クラスの各メソッドの直後に実行
OnAfterAllTestsテスト・クラス実行後に1回実行

今回のテストケースで言えば、レコードの削除や登録になります。

■環境整備用の関数

Class developer.export.UnitTestObject Extends %UnitTest.TestCase
{
Parameter NAME = "サンプルテスト";

/// テスト前に一度だけ実行する
Method OnBeforeAllTests() As %Status
{
	// 全レコードの削除を実施
	q ##class(developer.test.TestData).%KillExtent()
}

/// テスト クラス内の各テスト メソッドが実行される直前に <B>RunTest</B> によって実行されます。<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>実行するテストの名前。必須。
/// </dl> 
Method OnBeforeOneTest(testname As %String) As %Status
{
	i (testname="TestSave") {
		// 特になし
	}elseif(testname="TestDelete"){
		// レコードを作成して削除を実施可能に
		d ##class(developer.test.TestData).save(..#NAME)	
	}
	q $$$OK
}

/// ユニットテスト:オブジェクトの登録
Method TestSave()
{
	// データ登録テスト
	s sts = ##class(developer.test.TestData).save(..#NAME)
	d $$$AssertStatusOK(sts, $$$FormatText("データの登録 name = %1", ..#NAME))
	
	// ユニークエラー
	s sts = ##class(developer.test.TestData).save(..#NAME)
	d $$$AssertStatusNotOK(sts, $$$FormatText("登録時エラー %1 = ", $system.Status.GetErrorText(sts)))
}

/// ユニットテスト:オブジェクトの削除
Method TestDelete()
{
	// データ削除テスト
	s sts = ##class(developer.test.TestData).delete(..#NAME)
	d $$$AssertStatusOK(sts, $$$FormatText("データの削除 name = %1", ..#NAME))
}

/// テスト クラス内の各テスト メソッドが実行された直後に <B>RunTest</B> によって実行されます。<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>Name of the test to be run. Required. 
/// </dl> 
Method OnAfterOneTest(testname As %String) As %Status
{
	// テスト終了毎に、全レコードの削除を実施
	q ##class(developer.test.TestData).%KillExtent()
}
}

これで、どのテストメソッドを単独で実行しても、エラーにならないでしょう。

その他のマクロも試してみる

検証用のマクロは他にもあるので、ついでに動作を検証してみましょう。

$$$AssertTrue、$$$AssertNotTrue

値がTrueFalse(%Boolean値)を検証するマクロになります。

True or Falseなので、%Fileの操作が検証しやすそうです。
ファイル操作は、%Booleanの戻り値を持つ関数が多いですよね。

■検証用のファイル操作関数

/// ファイル削除を検証する関数
ClassMethod deleteFile(filePath As %String) As %Boolean
{
	// ファイルの存在を確認
	s flg = ##class(%File).Exists(filePath)
	q:('flg) flg
	
	// ファイルの削除
	q ##class(%File).Delete(filePath)
}

ClassMethod makeFile(filePath As %String) As %Boolean
{
	// ファイルの作成
	s flg = ##class(%File).Exists(filePath)
	q:(flg) 0
	
	// ファイルの作成
	s file=##class(%File).%New(filePath)
	d file.Open("WN")
	, file.WriteLine("サンプルテキスト出力")
	, file.%Save()
	, file.Close()
	k file
	
	q 1
}

上記関数の検証用の新規テストクラスとメソッドを作成します。

Class developer.export.UnitTestFile Extends %UnitTest.TestCase
{

Parameter FILEPATH = "D:\Temp\txt\UnitTest.txt";

/// テスト前にファイルを生成する
Method OnBeforeAllTests() As %Status
{
	d ##class(developer.test.Sample).makeFile(..#FILEPATH)
	q $$$OK
}

/// ユニットテスト:ファイルの削除
Method TestFileDelete()
{
	// ファイルの削除
	s flg = ##class(developer.test.Sample).deleteFile(..#FILEPATH)
	d $$$AssertTrue(flg, $$$FormatText("ファイルの削除 ファイル名 = %1", ..#FILEPATH))
	
	// Falseが返る
	s flg = ##class(developer.test.Sample).deleteFile(..#FILEPATH)
	d $$$AssertNotTrue(flg, $$$FormatText("ファイルの削除(エラー)", ..#FILEPATH))
}
}

環境整備として、ファイルの生成を行い、テストメソッドとしてファイルの削除を検証したいと思います。

$$$AssertFilesSame、$$$AssertFilesSQLUnorderedSame

ファイルの内容が一致しているか検証します。

ファイルの比較を行うテストメソッドを作成しました。

Class developer.export.UnitTestFileCheck Extends %UnitTest.TestCase
{

Parameter DIR = "D:\Temp\txt\";

Parameter FILENM = "UnitCopyTest.txt";

Method OnBeforeAllTests() As %Status
{
	d ##class(developer.test.Sample).makeFile(..#DIR_..#FILENM)
	q $$$OK
}

/// テスト クラス内の各テスト メソッドが実行される直前に <B>RunTest</B> によって実行されます。<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>実行するテストの名前。必須。
/// </dl> 
Method OnBeforeOneTest(testname As %String) As %Status
{
	i (testname="TestFileCheck"){
		k %zFilePath
	}elseif(testname="TestFileNoCheck"){
		k %zFilePath
	}
	q $$$OK
}

Method TestFileCheck()
{
	// ファイルのコピー
	s fileNm = $p(..#FILENM, ".", 1)
	, ext = $p(..#FILENM, ".", 2)
	, %zFilePath = ..#DIR_fileNm_"copy."_ext
	d ##class(%File).CopyFile(..#DIR_..#FILENM, %zFilePath)
	
	// ファイルのチェック
	d $$$AssertFilesSame(..#DIR_..#FILENM, %zFilePath)
}

Method TestFileNoCheck()
{
	// ファイルのコピー
	s fileNm = $p(..#FILENM, ".", 1)
	, ext = $p(..#FILENM, ".", 2)
	, %zFilePath = ..#DIR_fileNm_"copy."_ext

	s file=##class(%File).%New(%zFilePath)
	d file.Open("WN")
	, file.WriteLine("内容の不一致")
	, file.%Save()
	, file.Close()
	k file
	
	// ファイルのチェック(内容の不一致)
	d $$$AssertFilesSame(..#DIR_..#FILENM, %zFilePath)
}

Method TestQueryPlan()
{
	s fileNm1 = "queryPlan1.txt"
	, fileNm2 = "queryPlan2.txt"
	d $$$AssertFilesSQLUnorderedSame(..#DIR_fileNm1, ..#DIR_fileNm2)
}

/// Run by <B>RunTest</B> immediately after each test method in the test class is run.<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>Name of the test to be run. Required. 
/// </dl> 
Method OnAfterOneTest(testname As %String) As %Status
{
	i (testname="TestFileCheck"){
		s flg = ##class(developer.test.Sample).deleteFile(%zFilePath)
		k %zFilePath
	}elseif(testname="TestFileNoCheck"){
		s flg = ##class(developer.test.Sample).deleteFile(%zFilePath)
		k %zFilePath
	}
	q $$$OK
}

/// Run by <B>RunTest</B> once after all test methods in the test class are run. Can be used to tear down a test environment that was set up by <B>OnBeforeAllTests</B> See example in <b>OnBeforeAllTests</b>. 
Method OnAfterAllTests() As %Status
{
	d ##class(developer.test.Sample).deleteFile(..#DIR_..#FILENM)
	k %zFilePath
	q $$$OK
}
}
$$$AssertFilesSQLUnorderedSame

ファイルの内容が一致しない場合、記載内容が「クエリプラン」か「SQLクエリ」の場合、挙動が変わります。

マクロ内部で下記関数「parseSQLFile」が実行され、クエリの内容を分解しています。

s file1="D:\Temp\txt\queryPlan1.txt"
Open file1:"r":0 Else  Quit 0
d ##class(%UnitTest.TestCase).parseSQLFile(file1,.parsed,0,0)
Close file1
zw parsed

後は、両ファイルの要素を比較しているようです。

クエリプランは「<plan>~</plan>」で囲まれていて、クエリは「SQL>」で始まる必要があります。

今回は、下記2点を用意しました。

<plans>
<plan>
 <sql>
 SELECT ID,ABO血液型,RH血液型,dele,patientId,カナ氏名,コメント,性別,漢字氏名,生年月日 FROM developer_data.Patient2 where 漢字氏名 like '九重山%' & ABO血液型 = 'O' & RH血液型 = '+' & 性別 = 1 /*#OPTIONS {"DynamicSQL":1} */
 </sql>
 <cost value="2273924"/>
 Call module B, which populates bitmap temp-file A.
 Read bitmap temp-file A, looping on ID.
 For each row:
     Read master map developer_data.Patient2.IDKEY, using the given idkey value.
     Test the "=" condition on %SQLUPPER(ABO血液型), the "=" condition on %SQLUPPER(性別), the "=" condition on %SQLUPPER(RH血液型), the "NOT NULL" condition on %SQLUPPER(ABO血液型), the "NOT NULL" condition on %SQLUPPER(RH血液型), and the "NOT NULL" condition on %SQLUPPER(性別).
     Output the row.
 <module name="B" top="1">
 Read index map developer_data.Patient2.i2, looping on %SQLUPPER(漢字氏名) (with a %STARTSWITH range condition), %SQLUPPER(カナ氏名), 生年月日, and ID.
 For each row:
     Add ID bit to bitmap temp-file A.
 </module>
</plan>
</plans>
SQL>
SELECT
 ID,
 ABO血液型,
 RH血液型,
 dele,
 patientId,
 カナ氏名,
 コメント,
 ローマ字氏名,
 性別,
 新患登録日,
 死亡日時,
 漢字氏名,
 生年月日
FROM
 developer_data.Patient2
where
 漢字氏名 like '九重山%' &
 ABO血液型 = 'O' &
 RH血液型 = '+' &
 性別 = 1

クエリプランの出力は下記を参考にして下さい。

おわりに

いかがだったでしょうか。

本記事では、ユニットテストの作成を中心に解説致しました。

システムに組み込むには、かなりの開発コストがかかるとは思いますが、長期運用を行うのであれば作成して損はしないと思います。

さて、次回はユニットテストの実行編になります。
今回準備した3つのクラスを使って、ユニットテストを実施しましょう!