Salesforce Apex テストクラス: 開発者向け完全ガイド

背景と適用シナリオ

Salesforceプラットフォーム上で堅牢かつスケーラブルなアプリケーションを構築する上で、Apex Test Class (Apex テストクラス) は不可欠な要素です。Salesforceでは、本番環境にApexコードをデプロイする際、コード全体の少なくとも75%がテストクラスによってカバーされていることを要求します。この Code Coverage (コードカバレッジ) 要件は、単なるプラットフォームのルールではありません。これは、コードの品質を保証し、将来の変更や機能追加による予期せぬ不具合(リグレッション)を防ぎ、開発者が自信を持ってリファクタリングを行えるようにするための、重要なベストプラクティスです。

適切なテストクラスは、以下のようなシナリオでその真価を発揮します:

  • デプロイメント: Sandboxから本番環境へApex TriggerやClassをデプロイする際の必須条件を満たします。
  • 品質保証: コードが期待通りに動作すること、特に境界値やエラーケースで正しく振る舞うことを検証します。
  • リファクタリング: 既存のコードを改善する際に、機能が損なわれていないことを迅速に確認できます。
  • 一括処理の検証: Salesforceのマルチテナント環境で重要な、一度に多数のレコードを処理する際の Governor Limits (ガバナ制限) にコードが抵触しないことを保証します。

本記事では、Salesforce技術アーキテクトの視点から、Apexテストクラスの基本原理から実践的なサンプルコード、注意点、そしてベストプラクティスまでを包括的に解説します。これにより、開発者は単にカバレッジを満たすだけでなく、真に価値のあるテストを記述できるようになることを目指します。


原理説明

Apexテストクラスは、アプリケーションコードのロジックを検証するために設計された特別なApexクラスです。その動作原理は、いくつかの重要な概念に基づいています。

@isTest アノテーション

テストクラスは、クラス定義の直前に @isTest アノテーションを付与することで示されます。このアノテーションにより、Salesforceプラットフォームはこのクラスをテスト専用として認識します。テストクラス内のメソッドも同様に @isTest を付与することでテストメソッドとして扱われます。

@isTest
private class MyTestClass {
    @isTest static void myTestMethod() {
        // Test logic here
    }
}

@isTest が付与されたクラスやメソッドは、組織のApexコードサイズ制限(6MB)の計算対象外となります。

データの分離 (Data Isolation)

デフォルトでは、テストクラスは組織の既存データにアクセスできません。テストを実行するたびに、クリーンな環境で、テスト内で作成されたデータのみを使用してロジックが検証されます。これにより、テストが本番データに意図せず影響を与えることを防ぎ、一貫性のある再現可能なテスト結果を保証します。この原則のため、テストメソッド内では、テスト対象のコードが必要とするすべてのデータを自前で作成する必要があります。例外的に既存データへのアクセスを許可する @isTest(SeeAllData=true) も存在しますが、これは特別な場合を除き、強く非推奨とされています。

Test コンテキスト

テストクラスは特別な「Testコンテキスト」で実行されます。このコンテキストでは、一部のガバナ制限が緩和されたり、Test クラスが提供する特殊なメソッド(Test.startTest(), Test.stopTest()など)が利用可能になったりします。

アサーション (Assertions)

テストの最も重要な部分は、コードの実行結果が期待通りであるかを確認する「アサーション」です。System.assertEquals(expected, actual)System.assertNotEquals(unexpected, actual) などのメソッドを使い、変数の値、レコードの状態、リストのサイズなどを検証します。アサーションのないテストは、コードがエラーなく実行されたことを示すだけで、ロジックの正しさを保証するものではありません。


サンプルコード

ここでは、Salesforce公式ドキュメントに基づいた具体的なコード例を通して、テストクラスの書き方を学びます。

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

華氏を摂氏に変換するシンプルなApexクラス TemperatureConverter をテストします。

テスト対象クラス: TemperatureConverter.cls
public class TemperatureConverter {
    // Takes a Fahrenheit temperature and returns the Celsius equivalent.
    public static Decimal FahrenheitToCelsius(Decimal f) {
        Decimal c = (f - 32) * 5/9;
        return c.setScale(2);
    }
}
テストクラス: TemperatureConverterTest.cls
@isTest
private class TemperatureConverterTest {
    @isTest static void testWarmTemp() {
        // 32°F (氷点) を摂氏に変換するテスト
        Decimal fahrenheit = 32;
        // メソッドを呼び出して実際の結果を取得
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // 期待値 (0°C) と実際の結果を比較してアサーション
        System.assertEquals(0, celsius, 'Expected 0°C for 32°F');
    }

    @isTest static void testFreezingPoint() {
        // 212°F (沸点) を摂氏に変換するテスト
        Decimal fahrenheit = 212;
        // メソッドを呼び出し
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // 期待値 (100°C) と実際の結果を比較
        System.assertEquals(100, celsius, 'Expected 100°C for 212°F');
    }

    @isTest static void testBoilingPoint() {
        // -40°F を摂氏に変換するテスト
        Decimal fahrenheit = -40;
        // メソッドを呼び出し
        Decimal celsius = TemperatureConverter.FahrenheitToCelsius(fahrenheit);
        // -40°F は -40°C と等しい
        System.assertEquals(-40, celsius, 'Expected -40°C for -40°F');
    }
}

この例では、正常系(温かい温度、氷点、沸点)の複数のシナリオを個別のテストメソッドで検証しています。各メソッドが独立しており、一つのテストの失敗が他のテストに影響しないようになっています。

2. テストデータ作成と @testSetup

複数のテストメソッドで共通のテストデータが必要な場合、@testSetup アノテーションを付けたメソッドを利用すると効率的です。このメソッドは、クラス内の他のテストメソッドが実行される前に一度だけ実行され、作成されたデータは各テストメソッドで利用可能になります。

@testSetup を使用したテストクラス
@isTest
private class CommonTestData_Test {

    // @testSetupメソッドはテストクラスの実行前に一度だけ呼ばれる
    @testSetup static void setup() {
        // テスト用の取引先と取引先責任者のリストを作成
        List<Account> testAccts = new List<Account>();
        for(Integer i=0; i<2; i++) {
            testAccts.add(new Account(Name = 'Test Account ' + i));
        }
        insert testAccts;

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

    @isTest static void testAccountAndContacts() {
        // @testSetupで作成されたデータは、このメソッドの実行コンテキストで利用可能
        // データをクエリして検証
        List<Account> accts = [SELECT Id, Name FROM Account WHERE Name LIKE 'Test Account%'];
        List<Contact> cons = [SELECT Id FROM Contact WHERE AccountId = :accts[0].Id];
        
        // アサーション: 2つの取引先と10の取引先責任者が存在することを確認
        System.assertEquals(2, accts.size(), 'Expected 2 test accounts');
        System.assertEquals(10, cons.size(), 'Expected 10 test contacts');
    }
}

@testSetup を使うことで、DML操作の回数を減らし、テスト全体の実行時間を短縮できます。各テストメソッドは、@testSetup が完了した直後の状態のスナップショットを受け取るため、あるテストメソッド内でのデータ変更が他のテストメソッドに影響を与えることはありません。

3. ガバナ制限と非同期処理のテスト

Test.startTest()Test.stopTest() は、テストコードの特定部分を分離するための強力なツールです。この2つのメソッドで囲まれたブロックは、独自のガバナ制限セットを持ちます。また、Test.stopTest() が実行されると、その前に呼び出された非同期処理(Future, Queueable, Batch Apex)が同期的に実行されます。

非同期メソッド(Future)のテスト
@isTest
private class FutureMethod_Test {

    @isTest static void testFutureMethod() {
        // テストデータを作成
        Account a = new Account(Name='Test Account');
        insert a;

        // Test.startTest() を呼び出し、ガバナ制限をリセット
        Test.startTest();

        // 非同期メソッドを呼び出す
        // この時点では、まだ実行はキューイングされるだけ
        MyFutureClass.myFutureMethod(a.Id);

        // Test.stopTest() を呼び出す
        // これにより、startTest()以降に呼び出された非同期処理がすべて実行される
        Test.stopTest();

        // 非同期処理が完了した後の結果を検証
        Account updatedAccount = [SELECT Id, Description FROM Account WHERE Id = :a.Id];
        System.assertEquals('Updated by Future', updatedAccount.Description, 'Description should be updated by the future method.');
    }
}

4. 外部サービスコールアウトのテスト

テストメソッドから直接外部サービスへのHTTPコールアウトを行うことはできません。これをテストするためには、HttpCalloutMock インターフェースを実装したモッククラスを作成し、テスト中に擬似的なレスポンスを返させる必要があります。

コールアウトを行うクラス: AnimalLocator.cls
public class AnimalLocator {
    public static String getAnimalNameById(Integer id) {
        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setEndpoint('https://my-animal-service.com/animals/' + id);
        request.setMethod('GET');
        HttpResponse response = http.send(request);
        // レスポンスのステータスコードを確認
        if (response.getStatusCode() == 200) {
            // レスポンスボディをJSONとしてパース
            Map<String, Object> results = (Map<String, Object>) JSON.deserializeUntyped(response.getBody());
            // 動物の名前を取得
            Map<String, Object> animal = (Map<String, Object>) results.get('animal');
            return (String)animal.get('name');
        }
        return null;
    }
}
モッククラスとテストクラス
@isTest
global class AnimalLocatorMock implements HttpCalloutMock {
    // HttpCalloutMockインターフェースのrespondメソッドを実装
    global HttpResponse respond(HttpRequest req) {
        // 擬似的なレスポンスを作成
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setBody('{"animal":{"id":1,"name":"Lion"}}');
        res.setStatusCode(200);
        return res;
    }
}

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

        // Test.startTest() と Test.stopTest() を使用してコールアウトを実行
        Test.startTest();
        String result = AnimalLocator.getAnimalNameById(1);
        Test.stopTest();
        
        // モックが返した期待通りの結果であることをアサーションで確認
        System.assertEquals('Lion', result, 'The animal name should be Lion.');
    }
}

Test.setMock() を使用することで、テスト実行中に http.send(request) が呼び出された際、実際の外部サービスではなく、指定したモッククラスの respond メソッドが呼び出されるようになります。


注意事項

高品質なテストクラスを記述するためには、いくつかの重要な点に注意する必要があります。

コードカバレッジの罠

75%という数値は最低要件であり、ゴールではありません。カバレッジ率だけを追求し、アサーションのないテストや、意味のある検証をしないテストを書いても品質は向上しません。重要なのは、ビジネスロジックの主要なパス(正常系)、例外パス(異常系)、そして一括処理シナリオを網羅的にテストすることです。

@isTest(SeeAllData=true) の回避

前述の通り、このアノテーションはテストの独立性と再現性を損なうため、原則として使用を避けるべきです。テストに必要なデータはすべてテストクラス内で作成するか、静的リソースから Test.loadData() を使ってロードするのがベストプラクティスです。これにより、どの環境でも同じ結果が得られる信頼性の高いテストが実現できます。

一括処理 (Bulkification) のテスト

トリガーやサービスクラスは、常に複数のレコード(最大200件)を一度に処理できるように設計する必要があります。テストも同様に、1件のレコードだけでなく、200件のレコードを処理するシナリオを必ず含めるべきです。これにより、SOQLクエリやDMLステートメントがループ内で実行されるといった、ガバナ制限違反の原因となる一般的な問題を早期に発見できます。

適切なアサーション

「テストはアサーションから始まる」と言っても過言ではありません。コードを実行した後、その結果として「何が」「どうなっているべきか」を明確に検証してください。System.assertEquals(), System.assertNotEquals(), System.assert(condition) などを積極的に使用し、実行後のデータの状態を厳密にチェックすることが重要です。


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

Salesforce開発においてApexテストクラスは、品質保証とプラットフォームの健全性を保つための基盤です。以下に、効果的なテストクラスを作成するためのベストプラクティスをまとめます。

  • 一つのテストメソッドでは一つの関心事をテストする: 複雑なロジックは複数のテストメソッドに分割し、各メソッドが特定のシナリオを検証するようにします。
  • メソッドの命名規則を統一する: testMethodName_Condition_ExpectedResult のような命名規則を採用すると、テストの目的が明確になります。
  • @testSetup を活用する: 共通のテストデータを効率的に作成し、テストのパフォーマンスを向上させます。
  • Test.startTest()Test.stopTest() を使用する: ガバナ制限のテストと非同期処理の検証を正確に行います。
  • 常に一括処理をテストする: 単一レコードのシナリオだけでなく、複数レコードのシナリオを必ずテストに含めます。
  • ポジティブ、ネガティブ、境界値の各シナリオをテストする: 期待通りの動作だけでなく、エラー処理や予期せぬ入力に対する振る舞いも検証します。
  • アサーションを必ず記述する: テストの最後に、結果が期待通りであることを検証するコードを必ず含めます。アサーションのないテストは無意味です。
  • @isTest(SeeAllData=true) は避ける: テストの独立性を保つため、テストデータはテスト内で完結させます。
  • コールアウトにはモックを使用する: HttpCalloutMock を活用して、外部システムへの依存がない、安定したテストを構築します。

これらの原則とベストプラクティスに従うことで、あなたは単にデプロイ要件を満たすだけでなく、メンテナンス性が高く、信頼できる、高品質なSalesforceアプリケーションを構築するための強固な基盤を築くことができるでしょう。

コメント