Salesforce開発者向け Apexテストクラス完全ガイド:堅牢なコード品質を確保する

背景と応用シーン

Salesforce 開発者として、私たちは Apex (Salesforce独自のプログラミング言語) を使用して、ビジネスロジックのカスタマイズ、プロセスの自動化、外部システムとの連携など、強力な機能を実装します。しかし、コードを書くだけでは十分ではありません。そのコードが期待通りに動作し、将来の変更によって意図せず壊れることがないように保証することが不可欠です。ここで登場するのが Apex Test Classes (Apex テストクラス) です。

Salesforce Platform では、本番環境に Apex コードをデプロイする前に、コード全体の少なくとも 75% がテストによってカバーされていることを要求します。これは Code Coverage (コードカバレッジ) と呼ばれる指標です。しかし、テストクラスの目的は、単にこの数値を満たすことだけではありません。真の目的は以下の通りです。

  • 品質保証:コードが特定の条件下で正しく動作することを確認します。
  • リグレッション防止:既存の機能を変更または新しい機能を追加した際に、古い機能が壊れていないことを保証します。
  • 一括処理(Bulkification)の検証:Salesforce はマルチテナント環境であるため、一度に大量のレコードを処理する能力が不可欠です。テストクラスは、コードが単一のレコードだけでなく、200件のレコードリストでも効率的に動作することを検証する絶好の機会です。
  • リファクタリングの促進:堅牢なテストスイートがあれば、開発者は自信を持ってコードを改善(リファクタリング)できます。テストがパスし続ける限り、外部から見た機能は維持されていることが保証されるからです。

この記事では、Salesforce 開発者の視点から、Apex テストクラスの基本原則、効果的な作成方法、そしてコード品質を最大限に高めるためのベストプラクティスについて詳しく解説します。


原理説明

Apex テストクラスは、アプリケーションのコードを検証するための Apex コードです。通常の Apex クラスとは異なり、組織の実際のデータにアクセスしたり変更したりすることなく、独立した環境で実行されます。この分離された性質が、安全なテストを可能にしています。

@isTest アノテーション

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

  • @isTest をクラス定義の前に付けると、そのクラスがテストコードのみを含み、組織のデータ制限の対象外であることを示します。
  • @isTest または古いキーワード testMethod をメソッド定義の前に付けると、そのメソッドが個別のテストケースとして実行されることを示します。

テストデータの分離

デフォルトでは、テストメソッドは組織の既存データにアクセスできません(SeeAllData=false)。これは非常に重要な原則です。テストは、それ自体が作成したデータのみに依存するべきであり、本番環境や Sandbox 環境の特定のデータに依存すべきではありません。これにより、テストはどの環境でも一貫して実行可能となり、信頼性が高まります。テスト内で必要なデータは、すべてテストメソッド自身または @testSetup メソッドで作成する必要があります。

Test.startTest() と Test.stopTest()

これは、テストコードの特定の部分に新しい、独立した Governor Limits (ガバナ制限) のセットを適用するための非常に重要なメソッドペアです。

  • Test.startTest(): このメソッドを呼び出すと、ガバナ制限のカウンターがリセットされます。テストデータの準備(DML 操作など)で使用した制限は、テスト対象のコードの実行に影響を与えません。
  • Test.stopTest(): このメソッドを呼び出すと、startTest() の後から実行された非同期処理(@future メソッド、Queueable Apex など)が同期的に実行されます。これにより、非同期コードの結果をテスト内で検証することが可能になります。
このペアの間にテスト対象のコードを配置するのが一般的です。

アサーション (Assertions)

テストの最も重要な部分は、コードが期待通りの結果を生成したかどうかを検証することです。これを行うのがアサーションです。System.assert(condition, msg), System.assertEquals(expected, actual, msg), System.assertNotEquals(unexpected, actual, msg) などのメソッドを使用します。アサーションのないテストは、コードがエラーなく実行されることを確認するだけで、その結果が正しいかどうかは検証しません。したがって、アサーションは「テストの魂」と言えます。

テストデータファクトリ

複数のテストメソッドで同じようなテストデータが必要になる場合、毎回手動でレコードを作成するのは非効率で、メンテナンスも困難です。この問題を解決するのが Test Data Factory (テストデータファクトリ) パターンです。これは、テストデータを生成するための再利用可能なユーティリティクラスを作成するアプローチです。例えば、TestDataFactory.createAccount(10) のように呼び出すだけで、10件の取引先レコードを生成・挿入してくれるようなメソッドを実装します。


示例コード

ここでは、取引先 (Account) が挿入または更新されたときに、関連するすべての取引先責任者 (Contact) の説明 (Description) 項目を更新する、というシンプルなトリガーとヘルパークラスの例を取り上げます。そして、それを検証するためのテストクラスを見ていきましょう。このコードは Salesforce の公式ドキュメントに基づいています。

1. テスト対象の Apex クラス (ヘルパー)

まず、トリガーから呼び出されるロジックを含むクラスです。

public class AccountManager {
    public static void updateRelatedContacts(List<Account> accounts, Map<Id, Account> oldAccountsMap) {
        Set<Id> accountIds = new Set<Id>();
        for(Account acc : accounts) {
            // Check if the shipping city has changed
            if (acc.ShippingCity != null && oldAccountsMap != null && acc.ShippingCity != oldAccountsMap.get(acc.Id).ShippingCity) {
                accountIds.add(acc.Id);
            }
        }

        if(!accountIds.isEmpty()){
            List<Contact> contactsToUpdate = [SELECT Id, Description, AccountId FROM Contact WHERE AccountId IN :accountIds];
            
            for(Contact con : contactsToUpdate){
                con.Description = 'Account Shipping City has been updated.';
            }
            
            if(!contactsToUpdate.isEmpty()){
                update contactsToUpdate;
            }
        }
    }
}

2. テスト対象の Apex トリガー

このトリガーが上記のヘルパークラスを呼び出します。

trigger AccountTrigger on Account (after update) {
    if (Trigger.isAfter && Trigger.isUpdate) {
        AccountManager.updateRelatedContacts(Trigger.new, Trigger.oldMap);
    }
}

3. Apex テストクラス

いよいよ、上記のロジックを検証するテストクラスです。

@isTest
private class AccountManagerTest {

    @isTest
    static void testUpdateRelatedContacts() {
        // 1. テストデータの準備 (Preparation)
        List<Account> accs = new List<Account>();
        for(Integer i = 0; i < 5; i++) {
            accs.add(new Account(Name = 'Test Account ' + i, ShippingCity = 'Tokyo'));
        }
        insert accs;
        
        List<Contact> cons = new List<Contact>();
        for (Account acc : accs) {
            cons.add(new Contact(LastName = 'Test Contact', AccountId = acc.Id));
        }
        insert cons;

        // 2. テストの実行 (Execution)
        // ShippingCity を変更してトリガーを発火させる
        for (Account acc : accs) {
            acc.ShippingCity = 'Osaka';
        }
        
        Test.startTest();
        // ここで Account の update が実行され、トリガーが発火する
        update accs;
        Test.stopTest();

        // 3. 結果の検証 (Assertion)
        // データベースから更新された取引先責任者を取得
        List<Contact> updatedContacts = [SELECT Id, Description FROM Contact WHERE AccountId IN :accs];
        
        // すべての取引先責任者の Description が更新されたことを確認
        System.assertEquals(5, updatedContacts.size(), 'Expected 5 contacts to be updated.');
        for (Contact con : updatedContacts) {
            System.assertEquals('Account Shipping City has been updated.', con.Description, 'Contact description was not updated as expected.');
        }
    }

    @isTest
    static void testNoUpdateOnUnrelatedFieldChange() {
        // 1. テストデータの準備 (Preparation)
        Account acc = new Account(Name = 'Test Account No Change', ShippingCity = 'Kyoto');
        insert acc;
        
        Contact con = new Contact(LastName = 'Test Contact', AccountId = acc.Id, Description = null);
        insert con;

        // 2. テストの実行 (Execution)
        // ShippingCity 以外の項目 (Name) を変更
        acc.Name = 'Test Account No Change Updated';

        Test.startTest();
        update acc;
        Test.stopTest();

        // 3. 結果の検証 (Assertion)
        // 取引先責任者の Description が変更されていないことを確認
        Contact resultContact = [SELECT Id, Description FROM Contact WHERE Id = :con.Id];
        System.assertNotEquals('Account Shipping City has been updated.', resultContact.Description, 'Description should not be updated when ShippingCity is unchanged.');
        System.assertEquals(null, resultContact.Description, 'Description should remain null.');
    }
}

このテストクラスは、2つのシナリオをカバーしています。

  • testUpdateRelatedContacts: 「ShippingCity」が変更された場合に、関連する取引先責任者の「Description」が正しく更新されるかというポジティブシナリオをテストします。また、一度に複数のレコードを処理するバルクシナリオも兼ねています。
  • testNoUpdateOnUnrelatedFieldChange: 関係のない項目が変更された場合には、ロジックが実行されないというネガティブシナリオをテストします。これにより、意図しない副作用がないことを保証します。


注意事項

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

テストはデフォルトでシステム管理者権限で実行されます。しかし、特定のプロファイルを持つユーザーがコードを実行した場合の動作をテストしたい場合もあります。その際は System.runAs(user) ブロックを使用します。これにより、テストの一部を特定のユーザーのコンテキストで実行でき、共有ルールや項目レベルセキュリティが正しく機能するかを検証できます。

Profile p = [SELECT Id FROM Profile WHERE Name='Standard User']; 
User u = new User(Alias = 'standt', Email='standarduser@testorg.com', 
                EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US', 
                LocaleSidKey='en_US', ProfileId = p.Id, 
                TimeZoneSidKey='America/Los_Angeles', UserName='standarduser@testorg.com');

System.runAs(u) {
    // このブロック内のコードは 'u' ユーザーとして実行されます
    System.debug('Current User: ' + UserInfo.getUserName());
    System.debug('Current Profile: ' + UserInfo.getProfileId());
}

@isTest(SeeAllData=true) の使用

このアノテーションを付けると、テストが組織の既存データにアクセスできるようになります。しかし、これは原則として避けるべきです。テストが特定の環境のデータに依存すると、ポータビリティが失われ、CI/CD パイプラインでの自動テストが失敗する原因となります。価格表 (Pricebook) のような、テスト内で作成が困難な一部の標準オブジェクトを扱う場合にのみ、最後の手段として使用を検討してください。

API 制限

テストコードもガバナ制限の対象となります。1回のテスト実行(すべてのテストメソッドを含む)で使用できる DML (Data Manipulation Language) 操作の回数や SOQL クエリの数には上限があります。Test.startTest()/stopTest() を活用して、テストの準備と実行でガバナ制限を分離することが重要です。また、テストデータファクトリを使用する際は、ループ内で DML や SOQL を実行しないように設計し、一括処理を徹底してください。


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

Apex テストクラスは、Salesforce 開発における品質の根幹をなすものです。単なるデプロイ要件ではなく、信頼性が高く、保守しやすいアプリケーションを構築するための強力なツールです。以下に、開発者として心掛けるべきベストプラクティスをまとめます。

1. 1つのメソッドで1つのことをテストする

テストメソッドには、その目的が明確にわかる名前を付けましょう (例: testDiscountCalculation_HighValueOpportunity)。1つのメソッドで、1つの特定の機能や条件を検証することに集中します。

2. ポジティブ、ネガティブ、境界値のシナリオを網羅する

コードが正しく動作する場合(ポジティブ)だけでなく、意図的にエラーを引き起こす場合(ネガティブ、例:必須項目が null)や、ユーザーが空のリストを渡した場合の動作もテストします。

3. 常に一括処理を念頭に置く

テストデータは常に単一レコードではなく、リストで作成・処理します。これにより、トリガーやクラスがガバナ制限に抵触することなく、大量のデータを扱えることが保証されます。

4. テストデータファクトリを活用する

テストデータの作成ロジックを共通化することで、テストコードの可読性と保守性が劇的に向上します。新しいテストケースの追加も迅速に行えるようになります。

5. アサーションを惜しまない

テストの最終目的は検証です。コードを実行した後、関連するレコードの項目の値、返されるリストのサイズ、特定の例外がスローされたかなど、期待される結果を System.assertEquals() などで厳密にチェックしてください。

6. @isTest(SeeAllData=true) を避ける

可能な限り、テストは自己完結型にし、外部データへの依存をなくします。これにより、テストの信頼性と再利用性が高まります。

これらの原則と実践に従うことで、あなたは単に 75% のカバレッジを達成するだけでなく、長期間にわたって安定して動作する、高品質な Salesforce アプリケーションを構築できる真のプロフェッショナルとなるでしょう。

コメント