Salesforce Apex テストクラスをマスターする:開発者向け完全ガイド

執筆者:Salesforce 開発者


背景と適用シナリオ

Salesforce プラットフォームでの開発において、Apex テストクラス (Apex Test Classes) は単なる「推奨事項」ではなく、堅牢で信頼性の高いアプリケーションを構築するための「必須要件」です。Salesforce は、本番環境に Apex コードをデプロイする際に、少なくとも 75% のコードカバレッジ (Code Coverage) を達成することを義務付けています。しかし、テストクラスの本当の価値は、この数値を満たすことだけではありません。

テストクラスは、開発したコードが期待どおりに動作することを保証するためのセーフティネットです。適切に設計されたテストは、以下のような多くのシナリオでその真価を発揮します。

  • 品質保証: ビジネスロジックが正しく実装されているか、エッジケースや例外的な状況でも適切に処理されるかを確認します。
  • リグレッション防止: 既存のコードを修正したり、新機能を追加したりする際に、意図しない副作用で既存の機能が壊れていないか(リグレッション)を自動的に検出します。
  • 安全なリファクタリング: コードの内部構造を改善(リファクタリング)する際に、外部から見た振る舞いが変わらないことをテストで保証できます。
  • デプロイメント要件の充足: 前述の通り、本番環境や一部の Sandbox へのデプロイには 75% のコードカバレッジが必須です。

この記事では、Salesforce 開発者として、Apex テストクラスの基本原理から、実践的なサンプルコード、注意点、そしてベストプラクティスまでを包括的に解説します。

原理説明

Apex テストクラスは、本番データを汚染することなく、安全な環境でコードの動作を検証するためのフレームワークです。その中心的な概念を理解することが、効果的なテストを書くための第一歩です。

@isTest アノテーション

テストクラスやテストメソッドを定義するには、`@isTest` アノテーションを使用します。

  • `@isTest` をクラスに付与すると、そのクラスはテストクラスとして扱われ、組織の Apex コードサイズの制限から除外されます。
  • `@isTest` をメソッドに付与すると、そのメソッドはテストメソッドとして実行されます。テストメソッドは必ず `void` 型で、引数を取りません。

データの分離とトランザクション制御

テストメソッドは、実行時に独自のトランザクション内で動作します。テスト内で作成されたレコード(DML 操作)は、データベースにコミットされません。テストメソッドの実行が完了すると、すべての変更は自動的にロールバックされます。これにより、テストが組織の実際のデータに影響を与えることはありません。例外として `(SeeAllData=true)` アノテーションがありますが、これの使用は強く非推奨です(後述)。

テストデータの作成

テストは独立して実行可能でなければならないため、必要なデータはすべてテストクラス内で作成するのが原則です。テストデータを効率的に作成するために、`@TestSetup` アノテーションが付与されたメソッドを利用できます。このメソッドは、クラス内の各テストメソッドが実行される前に一度だけ実行され、共通のテストデータを準備します。これにより、テスト全体の実行時間を短縮できます。

アサーション (Assertion)

テストの目的は、コードを実行するだけでなく、「結果が期待通りであること」を検証することです。そのために、`System` クラスが提供するアサーションメソッド(例:`System.assertEquals(expected, actual)`、`System.assertNotEquals(unexpected, actual)`)を使用します。アサーションが失敗すると、テストは失敗としてマークされ、どこで期待と異なる結果になったかが明確になります。

ガバナ制限 (Governor Limits) と Test.startTest() / Test.stopTest()

テストメソッドも通常の Apex と同様にガバナ制限の対象となります。`Test.startTest()` と `Test.stopTest()` のペアは、テストの特定の部分に対して、新しいガバナ制限のセットを提供します。これは特に、非同期処理(Future メソッドや Queueable Apex など)をテストする際に重要です。`Test.stopTest()` が呼び出されると、その間に行われた非同期処理が同期的に実行され、結果を検証できます。


示例代码

ここでは、Salesforce の公式ドキュメントに記載されているコードを基に、いくつかの典型的なシナリオにおけるテストクラスの実装方法を解説します。

1. 基本的な Apex クラスとテストクラス

華氏を摂氏に変換するシンプルなユーティリティクラスを例に、基本的なテストクラスの構造を見てみましょう。

テスト対象のクラス: TemperatureConverter.cls

public class TemperatureConverter {
    // 華氏から摂氏へ変換するメソッド
    public static Decimal FahrenheitToCelsius(Decimal fahrenheit) {
        Decimal celsius = (fahrenheit - 32) * 5/9;
        return celsius.setScale(2);
    }
}

テストクラス: TemperatureConverterTest.cls

@isTest
private class TemperatureConverterTest {

    @isTest static void testWarmTemp() {
        // 正の値(暖かい温度)をテスト
        Decimal fahrenheit = 70;
        // メソッドを呼び出す
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // 結果が期待通りであることをアサーションで検証
        // 第3引数は、比較する際に許容される誤差
        System.assertEquals(21.11, celsius, 0.01);
    }

    @isTest static void testFreezingPoint() {
        // 凝固点 (32°F) をテスト
        Decimal fahrenheit = 32;
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // 32°Fは0°Cであるはず
        System.assertEquals(0, celsius);
    }



    @isTest static void testBoilingPoint() {
        // 沸点 (212°F) をテスト
        Decimal fahrenheit = 212;
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // 212°Fは100°Cであるはず
        System.assertEquals(100, celsius);
    }

    @isTest static void testNegativeTemp() {
        // 負の値(寒い温度)をテスト
        Decimal fahrenheit = -10;
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        System.assertEquals(-23.33, celsius, 0.01);
    }
}

2. @TestSetup を用いたテストデータ作成

複数のテストメソッドで共通のレコードを使用する場合、`@TestSetup` を使うと効率的です。

テストクラスの例: TestDataFactory.cls

@isTest
private class CommonTestSetup {
    @TestSetup static void makeData(){
        // このメソッドは、このクラス内の各テストメソッドが実行される前に一度だけ実行される
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 5; i++) {
            accounts.add(new Account(Name = 'Test Account ' + i));
        }
        insert accounts;

        List<Contact> contacts = new List<Contact>();
        for (Integer i = 0; i < 10; i++) {
            contacts.add(new Contact(LastName = 'Test Contact ' + i, AccountId = accounts[0].Id));
        }
        insert contacts;
    }

    @isTest static void testAccountRelatedLogic() {
        // @TestSetupで作成された取引先レコードにアクセスできる
        Account acc = [SELECT Id FROM Account WHERE Name = 'Test Account 0' LIMIT 1];
        // ここで取引先に関連するロジックをテストする
        System.assertNotEquals(null, acc);
    }

    @isTest static void testContactRelatedLogic() {
        // @TestSetupで作成された取引先責任者レコードにアクセスできる
        List<Contact> cons = [SELECT Id FROM Contact];
        // 10件の取引先責任者が作成されたことを確認
        System.assertEquals(10, cons.size());
    }
}

3. 非同期 Apex (Future メソッド) のテスト

`Test.startTest()` と `Test.stopTest()` を使用して、非同期処理を同期的にテストします。

テスト対象のクラス: MyFutureClass.cls

public class MyFutureClass {
    @future
    public static void updateAccounts(List<Id> accountIds) {
        List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
        for (Account acc : accounts) {
            acc.Name = acc.Name + ' (Updated)';
        }
        update accounts;
    }
}

テストクラス: MyFutureClassTest.cls

@isTest
private class MyFutureClassTest {
    @isTest static void testUpdateAccounts() {
        // テストデータを準備
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 5; i++) {
            accounts.add(new Account(Name = 'Future Test Account ' + i));
        }
        insert accounts;
        
        List<Id> accountIds = new List<Id>();
        for (Account acc : accounts) {
            accountIds.add(acc.Id);
        }

        // 非同期処理のテストを開始
        Test.startTest();
        // future メソッドを呼び出す
        MyFutureClass.updateAccounts(accountIds);
        // 非同期処理を同期的に実行させる
        Test.stopTest();

        // 結果を検証
        List<Account> updatedAccounts = [SELECT Name FROM Account WHERE Id IN :accountIds];
        for(Account acc : updatedAccounts) {
            // 名前が更新されていることを確認
            System.assert(acc.Name.endsWith('(Updated)'));
        }
    }
}

4. HTTP コールアウトのモック

外部システムへの実際のコールアウトを避け、擬似的なレスポンスを返すために `HttpCalloutMock` インターフェースを実装します。

モッククラス: AnimalLocatorMock.cls

@isTest
global class AnimalLocatorMock implements HttpCalloutMock {
    // 擬似的なレスポンスを返す callout メソッドを実装
    global HTTPResponse respond(HTTPRequest req) {
        // HttpResponse オブジェクトを作成
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        // 擬似的なレスポンスボディを設定
        res.setBody('{"animal":{"id":1,"name":"chicken","eats":"insects","says":"cluck"}}');
        res.setStatusCode(200);
        return res;
    }
}

テストクラスの例: AnimalLocatorTest.cls

@isTest
private class AnimalLocatorTest {
    @isTest static void testGetAnimalNameById() {
        // テストコンテキストにモックを設定
        Test.setMock(HttpCalloutMock.class, new AnimalLocatorMock());

        // Test.startTest() と Test.stopTest() の間でコールアウトを実行
        Test.startTest();
        // コールアウトを含むメソッドを呼び出す(この例ではAnimalLocatorクラスのメソッドを想定)
        // String animalName = AnimalLocator.getAnimalNameById(1);
        Test.stopTest();

        // 結果を検証
        // System.assertEquals('chicken', animalName);
        // 注: 上記2行は、テスト対象の`AnimalLocator`クラスが存在することを前提とした架空のコードです。
        // 実際のテストでは、自分のコールアウトを行うクラスを呼び出します。
        System.assert(true); // 実際の検証ロジックに置き換えてください
    }
}

注意事項

コードカバレッジの罠

75% という数値は最低限の基準であり、品質を保証するものではありません。カバレッジが高いコードでも、アサーションがなければバグを見逃す可能性があります。重要なのは、「コードの行を実行した」ことではなく、「コードが正しく動作することを検証した」ことです。クリティカルなビジネスロジックについては、100% のカバレッジと、ポジティブ・ネガティブ両方のシナリオに対する厳密なアサーションを目指すべきです。

(SeeAllData=true) の回避

`@isTest(SeeAllData=true)` を使用すると、テストクラスが組織の既存データにアクセスできるようになります。しかし、これはテストの独立性を損ない、実行する組織(開発環境、ステージング、本番)によって結果が変わる可能性があるため、原則として使用すべきではありません。テストに必要なデータは、すべてテストクラス内(またはテストユーティリティクラス)で作成してください。

一括処理(Bulkification)のテスト

トリガーや Apex クラスは、常に複数のレコード(最大200件)を一度に処理できるように設計する必要があります。テストも同様に、1件のレコードだけでなく、200件のレコードを処理するシナリオを必ず含めるべきです。これにより、ガバナ制限に抵触しないか、ロジックが正しくスケールするかを確認できます。

ユーザコンテキストと権限のテスト

`System.runAs(user)` ブロックを使用することで、特定のプロファイルや権限セットを持つユーザとしてコードを実行できます。これにより、レコードの共有ルールや項目レベルセキュリティが意図したとおりに機能するかをテストできます。異なる権限を持つユーザのコンテキストでテストを実行することは、セキュリティ上非常に重要です。

例外処理のテスト

「ハッピーパス」(正常系)だけでなく、予期されるエラーが発生する「ネガティブパス」(異常系)のテストも不可欠です。`try-catch` ブロックを使用して、特定の例外がスローされることを確認し、`System.fail('期待した例外が発生しませんでした。')` を使って、例外が発生しなかった場合にテストを失敗させることができます。


まとめとベストプラクティス

Apex テストクラスは、Salesforce 開発の品質、保守性、信頼性を支える基盤です。単なるデプロイの障壁と捉えるのではなく、自身のコードを守り、将来の変更に自信を持つための強力なツールとして活用しましょう。

以下に、効果的なテストクラスを作成するためのベストプラクティスをまとめます。

  1. 1つの Apex クラスに、1つのテストクラスを作成する: テスト対象との対応関係を明確にします。
  2. 説明的なメソッド名を使用する: `testMethod_Condition_ExpectedResult` のような命名規則は、テストの目的を明確にします。
  3. アサーションを必ず記述する: コードを実行するだけでなく、`System.assertEquals()` などで結果を必ず検証します。アサーションのないテストは無意味です。
  4. テストデータはテスト内で作成する: `@TestSetup` やテストユーティリティクラスを活用し、`@isTest(SeeAllData=true)` は避けます。
  5. 一括処理をテストする: 常に200件のレコードリストでテストし、バルク対応できていることを確認します。
  6. ポジティブ、ネガティブ、境界値の各シナリオをテストする: 正常系の動作だけでなく、必須項目が空の場合や、不正な値が入力された場合の動作も検証します。
  7. `System.runAs` でユーザコンテキストをテストする: 異なるプロファイルのユーザでコードが正しく動作するか確認します。
  8. `Test.startTest()` と `Test.stopTest()` を適切に使用する: 非同期処理のテストや、ガバナ制限が厳しい処理のテストで活用します。
  9. 各テストメソッドを独立させる: あるテストの実行結果が、他のテストに影響を与えないように設計します。

これらのベストプラクティスを実践することで、Salesforce プラットフォーム上で高品質かつ持続可能なアプリケーションを構築するための強固な土台を築くことができるでしょう。

コメント