Apex 単体テストを始める
学習の目的
この単元を完了すると、次のことができるようになります。
- Apex 単体テストの主要な利点を説明する。
- クラスとそのテストメソッドを定義する。
- クラスのすべてのテストメソッドを実行し、エラーを調べる。
- 一連のテストクラスを作成し、実行する。
Apex 単体テスト
Apex テストフレームワークでは、Lightning プラットフォーム上で Apex クラスとトリガーのテストを記述して実行できます。Apex 単体テストにより、Apex コードの品質が高まり、Apex のリリース要件を満たすことができます。
テストは、長期的な開発を成功に導く鍵であり、開発プロセスにおいて不可欠な要素です。Apex テストフレームワークを使用すれば、Apex コードは簡単にテストできます。Apex コードは、Sandbox 環境または開発者組織でのみ記述でき、本番組織では記述できません。Apex コードは、Sandbox から本番組織にリリースできます。また、アプリケーション開発者は、パッケージを Lightning Platform AppExchange にアップロードすることで、Apex コードを開発者組織から顧客に配布できます。Apex 単体テストは、品質保証にとって不可欠なだけでなく、Apex のリリースと配布の要件でもあります。
Apex 単体テストには次の利点があります。
- Apex クラスやトリガーが期待どおりに機能することを確認できる。
- 一連の回帰テストを用意することで、クラスやトリガーが更新されるたびに再実行でき、将来アプリケーションが更新されたときにも既存の機能が正常に動作することを確認できる。
- Apex を本番組織にリリースする場合、または Apex をパッケージとして顧客に配布する場合のコードカバー率要件が満たされる。
- 本番組織に高品質のアプリケーションを配信することで、本番ユーザーの生産性が向上する。
- パッケージ登録者に高品質のアプリケーションを配信することで、顧客の信頼が高まる。
リリースのコードカバー率要件
コードをリリースするまでに、または Lightning Platform AppExchange 用にパッケージ化するまでに、Apex コードの少なくとも 75% のテストが完了し、すべてのテストに合格している必要があります。また、各トリガーについても何らかのテストを行う必要があります。コードカバー率はリリースの要件ですが、この要件を満たすことのみを目的にテストを記述しないでください。必ず、正常・異常のテストケースや、一括・単一レコード処理など、アプリケーションの一般的なユースケースをテストしてください。
テストメソッドの構文
テストメソッドは @isTest
アノテーションを使用して定義し、次の構文を使用します。
@isTest static void testName() { // code_block }
@isTest
アノテーションでは、括弧で囲まれ空白で区切られた複数の修飾子を使用します。このパラメーターについては、後で説明します。
テストフレームワークでは常にテストメソッドにアクセスできるため、テストメソッドの表示は考慮されず、テストメソッドを公開または非公開のどちらとして宣言しても違いはありません。そのため、構文ではアクセス修飾子が省略されています。
テストメソッドは、テストクラス (@isTest
アノテーションが付加されているクラス) で定義されている必要があります。次のサンプルクラスは、テストメソッドが 1 つあるテストクラスの定義を示しています。
@isTest private class MyTestClass { @isTest static void myTest() { // code_block } }
テストクラスは、公開にも非公開にもできます。単体テスト専用のテストクラスを使用する場合は、非公開として宣言します。公開テストクラスは通常、テストデータファクトリクラス (後述) に使用されます。
単体テストの例: TemperatureConverter クラスのテスト
次の簡単なコードは、テストメソッドが 3 つあるテストクラスの例です。テストされるクラスメソッドは、入力値に華氏温度を取ります。この温度を摂氏温度に変換して、変換結果を返します。カスタムクラスとそのテストクラスを追加しましょう。
- 開発者コンソールで、[File (ファイル)] | [New (新規)] | [Apex Class (Apex クラス)] をクリックし、クラス名に
TemperatureConverter
と入力して [OK] をクリックします。
- デフォルトのクラス本文を次のコードで置き換えます。
public class TemperatureConverter { // Takes a Fahrenheit temperature and returns the Celsius equivalent. public static Decimal FahrenheitToCelsius(Decimal fh) { Decimal cs = (fh - 32) * 5/9; return cs.setScale(2); } }
-
Ctrl + S キーを押してクラスを保存します。
- 上記の手順を繰り返して、
TemperatureConverterTest
クラスを作成します。このクラスに次のコードを追加します。@isTest private class TemperatureConverterTest { @isTest static void testWarmTemp() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(70); System.assertEquals(21.11,celsius); } @isTest static void testFreezingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(32); System.assertEquals(0,celsius); } @isTest static void testBoilingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212); System.assertEquals(100,celsius,'Boiling point temperature is not expected.'); } @isTest static void testNegativeTemp() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(-10); System.assertEquals(-23.33,celsius); } }
TemperatureConverterTest
テストクラスは、別の華氏温度を入力としてメソッドをコールし、メソッドが期待どおりに機能することを検証します。各テストメソッドは、1 種類の入力 (常温、氷点温度、沸点温度、マイナス温度) を検証します。検証は、System.assertEquals()
メソッドをコールして行います。このメソッドは 2 つのパラメーター (1 つ目は期待値、2 つ目は実際の値) を取ります。このメソッドの別バージョンでは、3 つ目のパラメーターとして、testBoilingPoint()
で使用される、実行される比較を説明する文字列を取ります。この省略可能な文字列は、アサーションが失敗するとログに記録されます。
このクラスのメソッドを実行しましょう。
- 開発者コンソールで、[Test (テスト)] | [New Run (新規実行)] をクリックします。
-
[Test Classes (テストクラス)] の下で、[TemperatureConverterTest] をクリックします。
-
TemperatureConverterTest
クラスのすべてのテストメソッドをテスト実行に追加するには、[Add Selected (選択項目を追加)] をクリックします。
-
[実行] をクリックします。
- [Tests (テスト)] タブに、実行中のテストの状況が表示されます。テスト実行を展開し、実行された個々のテストのリストが表示されるまでさらに展開します。すべてに緑のチェックマークが付いています。
テストを実行した後、組織の Apex クラスやトリガーのコードカバー率が自動的に生成されます。コードカバー率は、開発者コンソールの [Tests (テスト)] タブで確認できます。この例では、テストした TemperatureConverter
クラスのカバー率が 100% で、次の画像のように表示されます。
1 つのテストメソッドで TemperatureConverter
クラスのカバー率が十分な結果に終わっても、別の入力をテストしてコードの品質を確保することが重要です。すべてのデータポイントを検証するのが無理であることはわかりきっていますが、一般的なデータポイントや異なる入力範囲をテストすることは可能です。たとえば、正の数と負の数、境界値、無効なパラメーター値を渡し、異常時の動作を検証することができます。TemperatureConverter
クラスのテストでは、沸点温度やマイナス温度のような一般的なデータポイントを検証します。
TemperatureConverterTest
テストクラスでは、無効な入力と境界条件は対象ではありません。境界条件とは、最小値と最大値です。この例では、温度変換メソッドが Decimal
を受け入れるため、Double
値よりも大きな数値を受け入れることができます。無効な入力については、無効な温度はありませんが、唯一の無効な入力は null です。変換メソッドはこの値をどのように処理するでしょうか。この場合、Apex ランタイムがパラメーター変数を参照解決して数式を評価するときに、System.NullPointerException
を発生させます。無効な入力をチェックして、該当する場合は null を返すように FahrenheitToCelsius()
メソッドを変更し、無効な入力に対する動作を検証するテストを追加できます。
クラスメソッドで使用されている変換数式が正しいため、ここまではすべてのテストに合格しました。でもこれだけではつまらないですよね。エラーをシミュレーションして、アサーションが失敗したときに何が起きるかを確認してみましょう。たとえば、沸点温度テストを変更し、沸点摂氏温度として誤った期待値 (100 ではなく 0) を渡します。これにより、対応するテストメソッドが失敗します。
-
testBoilingPoint()
テストメソッドを次のように変更します。@isTest static void testBoilingPoint() { Decimal celsius = TemperatureConverter.FahrenheitToCelsius(212); // Simulate failure System.assertEquals(0,celsius,'Boiling point temperature is not expected.'); }
- 同じテストを実行するには、[Tests (テスト)] タブで最新の実行をクリックし、[Test (テスト)] | [Rerun (再実行)] をクリックします。
testBoilingPoint()
のアサーションが失敗し、致命的なエラー (キャッチできないAssertException
) を発生させます。
- [Tests (テスト)] タブで最新のテスト実行を展開して結果を確認します。テスト実行で 4 つのテストのうち 1 つが失敗したとレポートされます。失敗の詳細を表示するには、テスト実行をダブルクリックします。次の画像のように、詳細な結果が別のタブに表示されます。
[代替テキスト: 開発者コンソールで失敗したテストの結果を調べる]
- テスト失敗のエラーメッセージを表示するには、失敗したテストの [Errors (エラー)] 列の内部をダブルクリックします。次のように表示されます。
Assertion Failed:
の横にある説明テキストは、System.assertEquals()
ステートメントで指定したテキストです。System.AssertException: Assertion Failed: Boiling point temperature is not expected.: Expected: 0, Actual: 100.00
これらのテストメソッドのテストデータは数値であり、Salesforce レコードではありません。Salesforce レコードのテスト方法と、データの設定方法についての詳細は、次の単元で学習します。
コードカバー率を高める
テストを記述するときは、できるだけ高いコードカバー率を達成できるようにします。Lightning プラットフォームでリリースとパッケージに求められる最低限のカバー率である 75% で良しとしないことです。テストでカバーするテストケースが多いほど、コードが堅牢になる可能性が高まります。場合によっては、すべてのクラスメソッドのテストメソッドを記述した後でも、コードカバー率が 100% にならないことがあります。一般的な原因の 1 つは、条件付きコード実行のすべてのデータ値がカバーされていないことです。たとえば、クラスメソッドに if ステートメントがあり、評価条件を満たすかどうかに基づいて異なる分岐が実行される場合、一部のデータ値が無視される傾向にあります。テストメソッドでは、こうした異なる値を考慮する必要があります。
次の例には、2 つの if
ステートメントを含む getTaskPriority()
クラスメソッドがあります。このメソッドの主なタスクは、与えられたリードの州に基づいて優先度の文字列値を返すことです。メソッドは、最初に州を検証し、州が無効な場合は null を返します。州が CA の場合、メソッドは「High」 (高) を返し、それ以外の州の値には「Normal」 (通常) を返します。
public class TaskUtil { public static String getTaskPriority(String leadState) { // Validate input if(String.isBlank(leadState) || leadState.length() > 2) { return null; } String taskPriority; if(leadState == 'CA') { taskPriority = 'High'; } else { taskPriority = 'Normal'; } return taskPriority; } }
次に、getTaskPriority()
メソッドのテストクラスを示します。このテストメソッドでは、1 つの州 ('NY') を指定して getTaskPriority()
をコールするだけです。
@isTest private class TaskUtilTest { @isTest static void testTaskPriority() { String pri = TaskUtil.getTaskPriority('NY'); System.assertEquals('Normal', pri); } }
開発者コンソールでこのテストクラス (TaskUtilTest
) を実行し、このテストでカバーされる、対応する TaskUtil
クラスのコードカバー率をチェックしましょう。テスト実行が終了した後、TaskUtil
のコードカバー率が 75% と表示されます。開発者コンソールでこのクラスを開くと、次の画像のように、6 つの青い行 (カバーされた行) と 2 つの赤い行 (カバーされていない行) が表示されます。
行 5 がカバーされないのは、無効な州パラメーターを渡すテストがテストクラスに含まれていなかったためです。同様に、行 11 は、テストメソッドが 'CA' を州として渡さなかったためカバーされませんでした。さらに 2 つのテストメソッドを追加して、これらのシナリオをカバーしましょう。testTaskHighPriority()
と testTaskPriorityInvalid()
テストメソッドを追加した後の完全なテストクラスを次に示します。[Run All (すべて実行)] または [New Run (新規実行)] を使用して、このテストクラスを返した場合、TaskUtil
のコードカバー率は 100% になります。
@isTest private class TaskUtilTest { @isTest static void testTaskPriority() { String pri = TaskUtil.getTaskPriority('NY'); System.assertEquals('Normal', pri); } @isTest static void testTaskHighPriority() { String pri = TaskUtil.getTaskPriority('CA'); System.assertEquals('High', pri); } @isTest static void testTaskPriorityInvalid() { String pri = TaskUtil.getTaskPriority('Montana'); System.assertEquals(null, pri); } }
テストスイートを作成して実行する
テストスイートとは、まとめて実行する Apex テストクラスのコレクションです。たとえば、リリースの準備をするたび、または Salesforce が新しいバージョンをリリースするたびに実行するテストスイートを作成します。開発者コンソールでテストスイートを設定し、定期的にまとめて実行するテストクラスセットを定義します。
組織のテストクラスが 2 つになりました。この 2 つのクラスは関連付けられていませんが、ここでは関連付けられているものとします。この 2 つのテストクラスを実行したいが、組織でテストを全部実行したくはないという場合があるかも知れません。そのような場合には、両方のクラスを含むテストスイートを作成して、スイートでテストを実行します。
- 開発者コンソールで、[Test (テスト)] | [New Suite (新規スイート)] を選択します。
- スイート名に
TempConverterTaskUtilSuite
と入力して、[OK] をクリックします。
-
[TaskUtilTest] を選択し、CTRL キーを押したまま [TemperatureConverterTest] を選択します。
- 選択したテストクラスをスイートに追加するには、> をクリックします。
-
[Save (保存)] をクリックします。
-
[Test (テスト)] | [New Suite Run (新規スイート実行)] を選択します。
-
[TempConverterTaskUtilSuite] を選択し、> をクリックして
TempConverterTaskUtilSuite
を [Selected Test Suites (選択されたテストスイート)] 列に移動します。
-
[Run Suites (スイートを実行)] をクリックします。
- [Tests (テスト)] タブで、実行中のテストの状況を監視します。テスト実行を展開し、実行された個々のテストのリストが表示されるまでさらに展開します。個別のテストメソッドの実行時と同様に、メソッド名をダブルクリックすれば、テストの詳しい結果を確認できます。
テストデータを作成する
テストメソッドで作成された Salesforce レコードは、データベースにコミットされません。テストの実行が終了すると、レコードはロールバックされます。テスト実行後にテストデータをクリーンアップせずに済むため、このロールバック動作はテストには便利です。
デフォルトで Apex テストには、User または Profile オブジェクトなどの設定やメタデータオブジェクトへのアクセス権を除き、組織に既存のデータへのアクセス権がありません。ですので、テスト用のテストデータを設定します。テストデータを作成すると、テストがより堅牢になり、組織のデータの欠落や変更によって発生するエラーを防止できます。テストデータは、テストメソッドで直接作成するか、後で説明するユーティリティテストクラスを使用して作成することができます。
もうひとこと...
- Visual Studio Code 向け Salesforce Apex 拡張機能を使用して Apex テストを実行し、コードの機能性を検証できます。
- 組織ごとに最大 6 MB の Apex コードを保存できます。
@isTest
アノテーションが付加されたテストクラスは、この制限に含まれません。
- テストデータはロールバックされますが、テストに別個のデータベースが使用されるわけではありません。そのため、一意制約がある項目を持つ一部の sObject では、重複する sObject レコードを挿入するとエラーになります。
- テストメソッドではメールは送信されません。
- テストメソッドでは、外部サービスへのコールアウトを実行できません。テストでは、疑似コールアウトを使用できます。
- テストで実行された SOSL 検索は、空の結果を返します。結果を予測可能にするために、
Test.setFixedSearchResults()
を使用して検索によってレコードが返されるように定義します。
リソース
-
Apex 開発者ガイド: ベストプラクティスのテスト
-
Apex 開発者ガイド: Apex の単体テスト
-
Apex 開発者ガイド: 単体テストの組織データとテストデータの分離
-
Salesforce ヘルプ: コードカバー率のチェック
ハンズオン Challenge の準備をする
この単元のハンズオン Challenge を完了するには、以下からコピーしたコードを使用して VerifyDate
という名前の新しい Apex クラスを作成する必要があります。
public class VerifyDate { //method to handle potential checks against two dates public static Date CheckDates(Date date1, Date date2) { //if date2 is within the next 30 days of date1, use date2. Otherwise use the end of the month if(DateWithin30Days(date1,date2)) { return date2; } else { return SetEndOfMonthDate(date1); } } //method to check if date2 is within the next 30 days of date1 private static Boolean DateWithin30Days(Date date1, Date date2) { //check for date2 being in the past if( date2 < date1) { return false; } //check that date2 is within (>=) 30 days of date1 Date date30Days = date1.addDays(30); //create a date 30 days away from date1 if( date2 >= date30Days ) { return false; } else { return true; } } //method to return the end of the month of a given date private static Date SetEndOfMonthDate(Date date1) { Integer totalDays = Date.daysInMonth(date1.year(), date1.month()); Date lastDay = Date.newInstance(date1.year(), date1.month(), totalDays); return lastDay; } }