投稿者:Tetsuo Ajima | 投稿日:2011年7月04日(月) 01:45

本エントリでは、Apexテストコードを作成する際に注意しなければならない「落とし穴」をいくつかご紹介したいと思います。


この記事の対象者

  • Apexのテストを「なんとなく」書いている人
  • Apexのテストが動かなくなって失敗した経験のある人
  • Apexのテストのコードレビューのポイントを知りたい人

Apexテストコードを実装するための基本的な知識は下記を参照してください。

Apexコードテストメソッドの概要




ガバナ制限

落とし穴:
本番の運用が進みデータが増大したため、作成したテストが半年前の運用開始時には実行できていたが、現在は失敗するようになってしまった

注意点:
データが増大した場合でもガバナ制限に抵触せず、実行可能なコードを書くこと

解説:
開発時と本番環境、初回リリース時と運用開始から数ヶ月後など、データ量の違いによりテストが動作したりしなくなったりする場合があります。
特に多く見られるのが、SOQLで取得できる件数やDML処理できる件数がガバナ制限を超えるケースです。
このようなことを防ぐために、常に最大データ量を意識しながらテストクラスを実装する必要があります。

テスト前にテーブルを空にしたいという意図から、テスト前の準備処理で全件検索・全件削除を行う実装が時折見られますが、これも当然NGです。テスト前の準備処理では、データの削除・作成は必要最低限であるべきです。

@IsTest
private class MyTestClass {
  private static testMethod void myTest() {
    // データの準備
    delete [Select Id From CustomObject__c]; // NG!
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c;
    Test.startTest();
        String result = target.methodA();
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}

また、1つのテストメソッド中で複数のテストを行ってしまうのもNGパターンです。 Apexのテストは、テストメソッド1つが1トランザクションとしてガバナ制限が計算されます。そのため、テスト対象の処理がそれのみでガバナ制限ぎりぎりで設計されている場合は複数のテストをまとめた結果、トータルで制限を超えてしまう場合もあります。 1つのテストメソッドは1つのテストしかしないように実装しましょう。テスト対象のメソッドがそれのみでガバナ制限ぎりぎりの場合は、Test.startTest()とTest.stopTest()を活用してテストの準備処理をテスト対象の処理のガバナ制限を切り離すようにしましょう。




Test.startTest() / Test.stopTest()

落とし穴:
Test.startTest() / Test.stopTest()を使ったのにガバナ制限が合計で計算されてしまった

注意点:
Test.startTest()が呼ばれてから最初のDMLまたはWeb Serviceメソッド呼び出してガバナ制限がリセットされる

解説:
Test.startTest() / Test.stopTest()を呼び出すと、その前後の処理とはガバナ制限が別計算されます。そのため、テスト対象の処理がそれのみでガバナ制限ぎりぎりの場合に有効な手段です。そうでないばあいでも習慣的に利用するようにしておくと良いでしょう。
しかし、Test.startTest()内でDMLまたはWeb Serviceメソッドが呼び出されるとそのタイミングでガバナ制限の計算がリセットされるという仕様を理解しておく必要があります。テスト対象の処理が検索のみの場合は、テストとは直接関係ないDML処理を書いておく等のテクニックも必要になる場合があります。

@IsTest
private class MyTestClass {
  private static testMethod void myTest() {
    // データの準備
    List exsists = [Select Id From CustomObject__c Where Name='hoge']; 
    if (!exsists.isEmpty()) {
        delete exsists; 
    }
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c; // テスト用データの作成
    Test.startTest();
        Account a = new Account(Name='hoge');
        insert a; // テストとは直接関係ないが、ガバナ制限をリセットさせるためのDML呼び出し
        String result = target.methodA(); // テスト対象の処理にはDMLが無い
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}




データ依存

落とし穴:
あるデータが存在することが前提となっているコードになっているため、開発環境で成功していたテストが本番移行時には失敗してしまった

注意点:
データが存在することも存在しないことも前提にした実装をしてはいけない

解説:
テストメソッド中で使いたいデータは、まずSOQLを実行してデータの存在有無を必ずチェックしましょう。そしてデータが既にあれば削除して作成し(またはそのまま使う)、データがなかったら新規に作成するというのが基本的な戦略です。
開発環境では既にテストデータが入っていることも多いため、データが存在することが前提になっているコードを書いてしまう場合があるので注意が必要です。

@IsTest
private class MyTestClass {
  private static testMethod void myTest() {
    // データの準備
    List exsists = [Select Id From CustomObject__c Where Name='hoge']; // まず検索
    if (!exsists.isEmpty()) {
        delete exsists; // 既存データがあれば削除
    }
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c; // テスト用データの作成
    Test.startTest();
        String result = target.methodA();
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}

逆に、あるデータが存在しないことが暗黙的に前提となってしまっている場合にも注意が必要です。たとえば、テスト中でデータを作成したら組織内の既存データと重複したデータができてしまったり、一意性制約違反でエラーとなったりするケースが挙げられます。

どちらの場合も環境間でデータの状況が異なると気づきにくいポイントです。




ユーザ名重複

落とし穴:
テスト中でrunAs()に渡すために'test@example.com'というユーザを作成しているが、Sandboxではテストが成功したのに本番環境ではテストが失敗した

注意点:
ユーザ名は、世界中の全組織間でユニークである必要がある

解説:
System.runAs(User){...}を使うと、そのブロック内は指定したユーザとして実行できます。そのため、権限別のテストや可視範囲別のテストを行うのに適しています。
しかしSalesforceのユーザ名は、ログイン画面が共通であることからもわかるように、全組織間でユニークである必要があります。ただし、本番/Trial/DE組織 と Sandboxでは別管理のため、Sandboxで作成できたユーザが本番では作成できない場合やその逆の場合もあります。
他組織で作成されたユーザと名前が重複しないためには次の点に留意します。

  • 誰でも思いつくような、サンプルによく使われるようなユーザ名をつけないこと
  • @以降は自社ドメインにしておく。確実な対処方法ではないが、他組織と重複する可能性は低くなる
  • ユーザ名をハードコーディングせずカスタム設定で定義し、適宜変更できるようにしておく

@IsTest
private class MyTestClass {
  private static testMethod void myTest() {
    // データの準備
    List exsists = [Select Id From CustomObject__c Where Name='hoge']; 
    if (!exsists.isEmpty()) {
        delete exsists;
    }
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c; // テスト用データの作成
    User u = new User(); // テスト用ユーザの作成
    u.Name = 'test@example.com'; // NG! よく使われそうなユーザ名
    u.Email = 'test@example.com';
    u.LastName = 'LastName';
    u.profileid = UserInfo.getProfileId();
    u.emailencodingkey='ISO-8859-1';
    u.languagelocalekey='en_US';
    u.localesidkey='en_GB';
    u.timezonesidkey='Europe/London';
    String result;
    Test.startTest();
        System.runAs(u) {
            result = target.methodA();
        }
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}




トリガのカバー

落とし穴:
開発時に組織全体でテストカバー率75%以上を満たしていることを確認しているにも関わらず、一度も実行されなかったトリガがあったため本番移行時にデプロイが失敗した

注意点:
各Apexトリガのテストカバー率は0%ではいけない

解説:
デプロイの際のApexテストでは、各Apexトリガは1度は実行されなければいけないというルールがあります。
全テストを実行したときに、組織全体のテストカバー率が75%を超えていることだけでなくテストカバー率が0%のトリガが無いことを確認しましょう。




ハードコーディング

落とし穴:
絶対URLやレコードIdをハードコーディングしていたため、開発環境で動作するテストを本番環境へ移行できなかった

注意点:
ホスト名やレコードIdは組織ごとに異なる場合がある

解説:
URLが必要な場合、通常は絶対URLではなく相対URLを利用しましょう。どうしても絶対URLが必要な場合は、絶対URLやホスト名はSystem.URLクラスから取得できます。
レコードIdはクエリで取得しましょう。以前は、画面上にレポートへのリンクを実装したい場合等に、レポートのIdはSOQLクエリで取得できなかったためにId値をハードコーディングしてしまう例がよく見られました。しかしAPIバージョン20.0よりreportオブジェクトから名前をキーにしたクエリが可能になりました。
どうしてもId値を動的に取得できない場合はカスタム設定の活用を推奨します。

@IsTest
private class MyTestClass {
  private static testMethod void myTest() {
    // データの準備
    List exsists = [Select Id From CustomObject__c Where ID='a0S000000000004XVC']; // NG!
    if (!exsists.isEmpty()) {
        delete exsists; // 既存データがあれば削除
    }
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c; // テスト用データの作成
    Test.startTest();
        String result = target.methodA();
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}




自動採番項目の欠番

落とし穴:
自動採番項目が0から順番に振られることを期待していたが、本番運用時に開始番号が違ったり欠番が出たりした

注意点:
自動採番はテスト実行後もロールバックされない

解説:
自動採番項目の値は、一般的なRDBMSのシーケンスオブジェクトと同様の考え方により、パフォーマンスとスレッドセーフを両立させるためトランザクション外で採番されます。そのため、トランザクションがロールバックされても採番値はインクリメントされたまま元に戻りません。
自動採番項目に振られる値を想定した実装・運用をしてはいけません。




Mixed DML Operation

落とし穴:
テストの中でキューを作成したら実行できなかった

注意点:
いくつかの設定系オブジェクトとトランザクションデータ系オブジェクトは同一トランザクション内でのDML実行に制限がある

解説:
下記のオブジェクトは、トランザクション系データと同一のトランザクションでのDML処理に制限があります。
Group / GroupMember
QueueSObject
User / UserRole
UserTerritory / Territory
Custom settings
デプロイ前にこれらを作成する手順を考える等の工夫が必要になります。
詳細は下記を参照してください。
“sObjects That Cannot Be Used Together in DML Operations”




Apexコード量

落とし穴:
開発したApexコード量が増えるにつれ、テストコードも増大して組織のApexコード使用量(2MB)のガバナ制限を超えてしまった

注意点:
テストはProductionコードとは別クラスに定義し、@IsTestアノテーションを付与する

解説:
組織のApexコード使用量は2MBまで(正確には、コメントを除いて200万文字。スペース、タブ、改行を含む)というガバナ制限があります。ただし、これには@IsTestアノテーションが付与されたクラスはカウントされません。
@IsTestアノテーションが付与できるのはprivateクラスのみです。そのため、複数クラスで共通に利用するロジックは記述できないことに注意してください。

public class MyTestClass { // NG!
  private static testMethod void myTest() {
    // データの準備
    List exsists = [Select Id From CustomObject__c Where Name='hoge']; // まず検索
    if (!exsists.isEmpty()) {
        delete exsists; // 既存データがあれば削除
    }
    CustomObject__c c = new CustomObject__c(Name='hoge');
    insert c; // テスト用データの作成
    Test.startTest();
        String result = target.methodA();
    Test.stopTest();
    System.assertEquals('sample', result);
  }
}

ちなみに、WebUI上でソースコードの編集を行うとタブはすべてスペース4つに変換されます。WebUI上での編集によってコードの文字数が増大する場合もあることを念頭に入れておきましょう。



まとめ

Apexのテストは移植性(ポータビリティ)が非常に重要です。
組織上のデータの状態にかかわらず、半永久的に実行できるコードを書かなければいけません。
また、本エントリでご紹介した落とし穴のいくつかはテストコードに限らず通常のProductionコードにもそのまま当てはめることのできるTipsです。
紹介した落とし穴にみなさまがはまらないよう、本エントリがお役にたてれば幸いです。