Apexテストクラス チートシート

## テストクラスの基本
### テストクラス
```apex
/**
 * @description テストクラスの説明
 */
@IsTest
private class SampleTest {
    @TestSetup
    static void setup() {
        // 共通で使用するテストレコードの作成
    }

    /**
     * @description テストメソッドの説明
     */
    @IsTest
    static void myTest() {
        // テストデータの準備
        Test.startTest();
        // テスト対象処理の実行
        Test.endTest();
        // テスト結果の検証
    }
}
```

- [Apex のテスト | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_testing.htm)

### アサーションメソッド
```apex
Assert.areEqual(expected, actual, msg);
Assert.areNotEqual(notExpected, actual, msg);

Assert.isTrue(condition, msg);
Assert.isFalse(condition, msg);

Assert.isNull(value, msg);
Assert.isNotNull(value, msg);

Assert.isInstanceOfType(instance, expectedType, msg);
Assert.isNotInstanceOfType(instance, notExpectedType, msg);

Assert.fail(msg);
```

- [Assert Class | Apex Reference Guide](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_class_System_Assert.htm)

## ケース別テスト
### 例外
```apex
try {
    Calculator.divide(3, 0);
    // Assert.failを使って、例外が発生しない場合テストを失敗させる。
    Assert.fail('Exception failure is always an option');
} catch (Exception e) {
    // 発生した例外が意図した例外かをisInstanceOfTypeで検証する。
    Assert.isInstanceOfType(e, DmlException.class, e.getTypeName());
}
```

### 非同期処理
非同期処理をテストするには、非同期処理を`Test.startTest()`と`Test.stopTest()`で挟む必要がある。`Test.stopTest()`によって、この間に行われた非同期処理が同期的に実行され、結果を確認することができる。

```apex
@IsTest
static void futureTest() {
    Test.startTest();
    SampleService.doSomethingAsync('テスト');
    Test.stopTest();

    Assert.areEqual(1, Limits.getFutureCalls(), 'Futureメソッドのコール回数');
}
```

- [Futureのメソッド | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_invoking_future_methods.htm)

### メール送信
テストでメール送信処理を行ってもメールは送信されない。送信処理が実行されることを確認するには`Limits.getEmailInvocations()`を使用する。このメソッドは`Test.startTest()`と同時に使用することはできないため、非同期でメール送信する場合は非同期処理とメール送信処理で分けてテストする必要がある。

```apex
@IsTest
static void sendEmailTest() {
    EmailService.sendEmail('テスト'); // メール送信を行う同期処理
    Assert.areEqual(1, Limits.getEmailInvocations(), 'メール送信のコール回数');
}
```

- [Apexでメールを送信する - Qiita](https://qiita.com/TaaaZyyy/items/7e8114e6113193d2abcf)

### Apex REST コールアウト
モックを使用する。
レスポンスを返す値を動的にしたい場合は、Mockにインスタンス変数を持たせるとよい。

モックを使用しないと下記のエラーが発生する。

> TestMethod として定義されたメソッドは、Web サービスコールアウトをサポートしません。

```apex
@IsTest
static void testCallout() {
    Test.setMock(HttpCalloutMock.class, new SampleCalloutServiceMock());

    Test.startTest();
    HttpResponse res = SampleCalloutService.runSampleSync();
    Test.stopTest();

    Assert.areEqual(200, res.getStatusCode(), 'fake response code');
}

private class SampleCalloutService200Mock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest req) {
        Assert.areEqual('https://example.com/example/test', req.getEndpoint());
        Assert.areEqual('GET', req.getMethod());

        HttpResponse res = new HttpResponse();
        res.setStatusCode(200);
        res.setStatus('Complete');
        res.setHeader('Content-Type', 'application/json');
        res.setBody('[{"_id": "55d66226726b611100aaf741"}]');
        return res;
    }
}
```

テスト中何度もコールアウトを行う場合、下記のように複数コールアウト用のモックを用意する。

```apex
/**
 * @description 複数コールアウト用モック
 */
public class MultiCalloutMock implements HttpCalloutMock {
    private List<HttpCalloutMock> mocks = new List<HttpCalloutMock>();
    private Integer index = 0;

    public void add(HttpCalloutMock mock) {
        this.mocks.add(mock);
    }

    /**
     * @description コールアウトのモックメソッド
     * @param req リクエストデータ
     * @return レスポンスのモック
     */
    public HttpResponse respond(HttpRequest req) {
        HttpCalloutMock mock = mocks.get(index);
        index++;
        return mock.respond(req);
    }
}
```

- [HttpCalloutMock インターフェースの実装による HTTP コールアウトのテスト | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_classes_restful_http_testing_httpcalloutmock.htm)
- [静的リソースを使用した HTTP コールアウトのテスト | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_classes_restful_http_testing_static.htm)


### Apex REST サービス
RestContextを使用する。Restサービスクラスで戻り値の逐次化をしている場合は戻り値で検証可能。逐次化していない場合、例のように`RestContext.response.responseBody`を使用して検証する。

```apex
// リクエストの作成
RestRequest request = new RestRequest();
request.requestUri = '/applications';
request.httpMethod = 'POST';
request.addHeader('Content-Type', 'application/json');
request.requestBody = Blob.valueOf(createTestDataJson());

RestContext.request = request;
RestContext.response = new RestResponse();

Test.startTest();
APP_RestApplicationsResource.doPost();
Test.stopTest();

RestResponse response = RestContext.response;
System.debug(LoggingLevel.DEBUG, response.responseBody?.toString());

// レスポンスの検証
Assert.areEqual(201, response.statusCode, 'ステータスコード');

Map<String, Object> responseBody = (Map<String, Object>) JSON.deserializeUntyped(response.responseBody.toString());
Assert.isNotNull(responseBody.applicationNumber, '申請番号');
```

- [RestRequest クラス | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_restrequest.htm)
- [RestResponse クラス | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_restresponse.htm)


### Visualforceコントローラ
```apex
@IsTest
static void doSomethingTest() {
    // コントローラを利用するVisualforceページをテスト実行時の画面として設定
    PageReference pageRef = Page.SamplePage;
    pageRef.getParameters().put('key', 'value');
    Test.setCurrentPage(pageRef);

    // テスト対象のメソッドを実行
    Test.startTest();
    SamplePageController ctrl = new SamplePageController();
    PageReference nextPageRef = ctrl.doSomething();
    Test.stopTest();

    // テスト結果の検証
    Assert.areEqual('/apex/success', nextPageRef.getUrl());
}
```

- [カスタム コントローラーとコントローラー拡張機能のテスト | Visualforce 開発者ガイド](https://developer.salesforce.com/docs/atlas.en-us.pages.meta/pages/pages_controller_error_handling.htm?q=test)

### 変更データキャプチャ
`Test.enableChangeDataCapture`で変更イベント通知の生成を有効化し、DML操作を行い、`Test.getEventBus().deliver()`で変更イベントを配信する。

`Test.startTest()`を併用する場合、`Test.enableChangeDataCapture()`よりあとに`Test.startTest()`が来るようにしないとエラーになる。


```apex
// 変更イベント通知の生成を有効にする
Test.enableChangeDataCapture();
insert TestDataFactory.createAccount();
Test.startTest();
// テスト変更イベントの配信
Test.getEventBus().deliver();
Test.stopTest();
```

- [Test Change Event Triggers | Change Data Capture Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.change_data_capture.meta/change_data_capture/cdc_trigger_testing.htm)

### Big Objects
Big Objectsをテストで扱うことはできない。そのため、スタブを使用する。
スタブはTrailhead Sample AppのApexRecipeの[TestDouble]((https://github.com/trailheadapps/apex-recipes/blob/main/force-app/main/default/classes/Testing%20Recipes/TestDouble.cls))を使用すると簡単に作成できる。

```apex
// Big Objectsを操作するクラスを指定してTestDoubleインスタンスを作成する。
TestDouble stub = new TestDouble(BigObjectSampleDmlService.class);

// スタブにメソッドを登録する。
TestDouble.Method methodToTrack = new TestDouble.Method('insertBigObjectSample');
stub.track(methodToTrack);

// テスト対象のクラスに、スタブを渡す。
SampleConsumer.sampleDmlService = (BigObjectSampleDmlService) stub.generate();

Test.startTest();
SampleConsumer.run();
Test.stopTest();

// スタブのメソッド実行回数を検証で使用することができる。
Assert.areEqual(1, methodToTrack.hasBeenCalledXTimes, 'メソッド実行回数');
```

例えばトリガハンドラでBig Objectsを使用する場合は、下記のようになる。

```apex
public with sharing class AccountChangeEventTriggerHandler extends TriggerHandler {
    @TestVisible
    private static RecordChangeLogService changeLogService = new RecordChangeLogService();
    // 略
}
```

```apex
@IsTest
static void insertRecordChangeLogTest() {
    TestDouble stub = new TestDouble(RecordChangeLogService.class);
    TestDouble.Method methodToTrack = new TestDouble.Method('insertRecordChangeLog');
    stub.track(methodToTrack);
    AccountChangeEventTriggerHandler.changeLogService = (RecordChangeLogService) stub.generate();
    // 略
}
```

- [Apex を使用してカスタム Big Object を設定する | Big Objects 実装ガイド](https://developer.salesforce.com/docs/atlas.en-us.bigobjects.meta/bigobjects/big_object_populate_apex.htm)
- [スタブ API を使用してモッキング フレームワークを構築する | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_stub_api.htm)
- [TestDouble.cls at main · trailheadapps/apex-recipes](https://github.com/trailheadapps/apex-recipes/blob/main/force-app/main/default/classes/Testing%20Recipes/TestDouble.cls)

### SOSLを含む処理のテスト
テストではSOSLの結果が空のリストになる。SOSLの結果に特定の値を返すようにするためには、`Test.setFixedSearchResults`メソッドを使用する。

```apex
@IsTest
static void searchUserTest() {
    Test.setFixedSearchResults(new List<Id>{ UserInfo.getUserId() });

    Test.startTest();
    List<User> results = SampleClass.searchUser(UserInfo.getName());
    Test.stopTest();

    Assert.areEqual(UserInfo.getId(), results[0].Id);
}
```

- [SOSL クエリの単体テストへの追加 | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_testing_SOSL.htm)
- [Test クラス | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_test.htm#apex_System_Test_setFixedSearchResults)

### 特定のプロファイルでのテスト実施
特定のプロファイルでテストを実施する場合は`System.runAs`を使用する。
DML操作をrunAsブロックで囲むことで、混合DMLエラーを回避することができる。
指定するユーザーをinsertする必要はなく、インスタンスを渡すだけで良い。ユーザーのinsertは時間がかかるので可能なら避ける。
`System.runAs`はユーザーライセンスの制限を無視するため、ユーザライセンスが足らない場合でも使用できる。

```apex
@IsTest
public class TestDataFactory {
    public static User createSalesUser() {
        // 乱数作成は後述
        String randToken = generateRandomString(5);
    
        User obj = new User();
        obj.Email = randToken + '@my-company-name.example.com';
        // 仮にinsertが必要な時、すでに存在するユーザー名は失敗になるため、乱数を使用する。
        obj.UserName = obj.Email + generateRandomString(10);
        obj.LastName = randToken;
        obj.Alias = randToken;
        obj.ProfileId = querySalesProfileId();
        obj.EmailEncodingKey = 'UTF-8';
        obj.LanguageLocaleKey = 'ja';
        obj.LocaleSidKey = 'ja_JP';
        obj.TimeZoneSidKey = 'Asia/Tokyo';
        return obj;
    }
}
```

```apex    
@IsTest
static void querySettingTest() {
    User salesUser = TestDataFactory.createSalesUser();

    // runAsの引数はinsert不要
    System.runAs(salesUser) {
        Test.startTest();
        String result = SampleService.doSomething();
        Test.stopTest();
        Assert.areEqual('sample', result, 'テスト結果');
    }
}
```

- [runAs メソッドの使用 | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm?q=runas)

<!--
### Experienceユーザーでのテスト実施
TODO
-->

## テストユーティル
### ダミーのレコードID
DML操作はコストが高い。テスト対象のメソッドの関心事がDMLでない場合、ダミーレコードを使用してテストに高速するとよい。

```apex
private static Integer fakeIdNum = 1;

/**
 * @description ダミーのSalesforceIDを生成する。
 * @param token 生成するIDのオブジェクトタイプ
 * @return ダミーのSalesforceID
 */
public static Id generateFakeId(SObjectType token) {
    String index = String.valueOf(fakeIdNum++);
    String prefix = token.getDescribe().getKeyPrefix();
    String padding = '0'.repeat(12 - index.length());
    return (Id) (prefix + padding + index);
}
```

```apex
Case c = new Case(Id = TestUtil.generateFakeId, Status = 'New');
```

### 乱数
```apex
/**
 * @description 乱数生成メソッド（半角英数）
 * @param len 文字列の長さ
 * @return 乱数
 */
public static String generateRandomString(Integer len) {
    String chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789' + 'abcdefghijklmnopqrstuvwxyz';
    String randStr = '';
    while (randStr.length() < len) {
        Integer idx = Math.mod(Math.abs(Crypto.getRandomInteger()), chars.length());
        randStr += chars.substring(idx, idx + 1);
    }
    return randStr;
}
```

### カスタム設定登録
テストクラスから既存のカスタム設定にはアクセスできないため、カスタム設定を参照する処理をテストする場合、カスタム設定を登録する必要がある。

```apex
SampleSetting__c setting = SampleSetting__c.getOrgDefaults();
setting.BoxUserName__c = 'qiita@example.com';
upsert setting;
```

### 権限セット付与
テストクラスで作成したユーザーに権限セットを付与してrunAsする場合に使用する。
PermissionSetAssignmentオブジェクトは[設定オブジェクト](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_dml_non_mix_sobjects.htm)なので注意が必要。

```apex
/** 既存の権限セットを付与する */
public static void assignPermissionset(Id userId, String permissionSetName) {
    PermissionSet ps = [SELECT Id FROM PermissionSet WHERE Name = :permissionSetName];
    insert new PermissionSetAssignment(AssigneeId = userId, PermissionSetId = ps.Id);
}
```

条件ベース共有ルールはApexテストだと使用できない。そのため、全て参照権限を付与してアクセスする場合は下記のようにする（他にSharingレコードを作成する方法もある）。

```apex
/** テストクラスで権限セットを作成して付与する */
public static void assignViewAllPermission(Id userId, String objectName) {
    String name = 'ApexTest' + generateRandomString(20);
    PermissionSet ps = new PermissionSet(Name = name, Label = name);
    insert ps;

    insert new ObjectPermissions(
        ParentId = ps.Id,
        SobjectType = objectName,
        PermissionsRead = true,
        PermissionsViewAllRecords = true // すべて参照
    );
    insert new PermissionSetAssignment(AssigneeId = userId, PermissionSetId = ps.Id);
}
```

## 参考
- [Apex のテスト | Apex 開発者ガイド](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_testing.htm)