0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SalesforceのApexにおける、DML操作のMockingについて

Last updated at Posted at 2025-04-22

はじめに

SalesforceのApexでは、DML操作(データベースに関わる操作)が伴うユニットテストを書く際に、直接データベースを使用することが一般的です。これはApex自体にこれらをMock化する方法が整備されておらず、また公式からもテストデータを作成することが推奨されているからです。

しかし、データベースが伴うテストには次のような課題があります:

  • テスト実行が遅くなる(データベースへのアクセスだけでなく、トリガーの実行も影響する)
  • テストデータの依存関係に注意しながらテストデータを作成しないといけない
  • 読み取り専用フィールドのためにアサートが書けない
  • 入力規則の変更などで「予期せぬテスト失敗」が起きる

これらの課題は、特に開発が進むにつれて積み重なり、テストが「実行しづらい」「保守できない」といった負債へと繋がります。

継続的な開発では「今すぐテストを導入すること」が鍵

よくある誤解として、「テストの整備は後からやればいい」と考えられることがあります。しかし、継続的な開発においては今すぐにモックの仕組みを導入することで、以後のテストやコードが負債にならず、健全に成長できる環境が整います。

時間があるときに導入すれば良い、という考え方も危険です。このような現場では新機能の追加や既存の不具合の修正に追われ、テストが充分に書かれないままプロジェクトが進みがちです。テストが書かれないまま放置された機能はリファクタリングや機能追加、保守が難しい状態で放置され、新たな負債となりさらに時間を奪っていきます。

そのために、今すぐの導入が必要なのです。

Apex Eloquentとは?

Apex Eloquent は、Salesforceにおけるテスト性を重視した、Apex向けのクエリビルダー + Repositoryパターン実装です。名前の通り、LaravelのEloquent ORMにインスパイアされており、次の特徴があります:

  • クエリを記述するための直感的なQueryBuilder
  • Repositoryパターンによる責務の分離
  • モック実装(Mock Repository)を標準搭載し、DML不要でテスト可能

特にこのフレームワークは、「テストを書くこと」を第一に設計されています。そのため、初めての導入でも複雑な知識は必要なく、すぐに実践に活かせる仕組みになっています。

また、Apex Eloquentの特徴の一つに「non writable」なフィールドに対してのMockingがあります。
通常であればInsertやUpdateによって値が決まる数式項目などは、オブジェクトのインスタンスに値を入れることすら不可能ですがApex Eloquentはこれを可能にしています。

このように、とにかく「テストを書くことでプロジェクトをクリーンに保つ」ことを第一の目的としてApex Eloquentは作られております。

Apex Eloquentの使い方

次のように、

  1. 商談を取得
  2. 商談名を変更
  3. 商談を更新
    するクラスがあったとします。
// execute SOQL directly pattern
public with sharing class OppNameUpdator {
  private final Id oppId;

  /**
  * constructor
  *
  * @param oppId the Id of the Opportunity to update
  */
  public OppNameUpdator(Id oppId) {
    this.oppId = oppId;
  }

  /**
  * Updates the name of the Opportunity record with the given Id.
  */
  public Opportunity execute() {
    // Get the Opportunity record
    Opportunity opp = [SELECT Id, Name FROM Opportunity WHERE Id = :oppId LIMIT 1];

    // Update the Opportunity name
    opp.Name = 'Updated Opportunity Name';

    // Update the record in the database
    update opp;

    return opp;
  }
}

これをApex Eloquentに書き換えてみます。

// execute SOQL using Apex Eloquent pattern
public with sharing class OppNameUpdatorUsingApexEloquent {
  private final Id oppId;
  private final RepositoryInterface repository;

  /**
  * constructor
  *
  * @param oppId the Id of the Opportunity to update
  */
  public OppNameUpdatorUsingApexEloquent(Id oppId) {
    this(oppId, null);
  }

  /**
  * private constructor for testing
  *
  * @param oppId the Id of the Opportunity to update
  * @param repository the repository to use for data access
  */
  @TestVisible
  private OppNameUpdatorUsingApexEloquent(Id oppId, RepositoryInterface repository) {
    this.oppId = oppId;
    this.repository = repository ?? new Repository();
  }

  /**
  * Updates the name of the Opportunity record with the given Id.
  */
  public Opportunity execute() {
    // Get the Opportunity record
    Query query = (new Query())
      .source(Opportunity.getSObjectType())
      .pick(new List<String>{'Id', 'Name'})
      .condition('Id', '=', oppId);
    Opportunity opp = (Opportunity) this.repository.first(query);

    // Update the Opportunity name
    opp.Name = 'Updated Opportunity Name';

    // Update the record in the database
    Opportunity updatedOpp = (Opportunity) this.repository.doUpdate(opp);

    return updatedOpp;
  }
}

記述量が増えましたね。果たしてApex Eloquentがどのようにテストに寄与するのでしょうか?

通常のDML操作のテストクラス

次のように、依存関係を気にしながらテストを書く必要があります。

  1. 商談の親となる取引先をinsert
  2. 商談をインサート
  3. テスト対象のクラスのexecuteを実行
  4. 結果を検証
@isTest(seeAllData=false)
public with sharing class OppNameUpdator_T {

  @isTest(seeAllData=false)
  public static void testExecute() {
      // create test Data
      Account acc = new Account(Name='Test Account');
      insert acc;
      Opportunity opp = new Opportunity();
      opp.Name = 'Test Opportunity';
      opp.StageName = 'Prospecting';
      opp.CloseDate = Date.today().addDays(30);
      opp.AccountId = acc.Id;
      insert opp;

      // create instance of the class
      OppNameUpdator updator = new OppNameUpdator(opp.Id);

      // execute the method
      Opportunity updatedOpp = updator.execute();

      // verify the result
      String exceptedName = 'Updated Opportunity Name';
      System.assertEquals(exceptedName, updatedOpp.Name);
  }
}

至って普通ですね。ただし、この処理中で挿入、取得、更新のDML操作が行われています。

Apex Eloquentを使ったクラスのテストクラス

Apex Eloquentを使ったテストクラスは次のように書きます

  1. MockOpp テスト対象のクラスで、RepositoryInterfaceを使ってデータ取得した時に得られる予定のインスタンスを作成
  2. MockRepositoryのコンストラクタにMockOppを入れておき、テスト対象クラスで扱えるようにする
  3. テスト対象のクラスのコンストラクタにMockRepositoryをコンストラクタインジェクションする。こうすることで、テスト対象のクラスで使っているfirstメソッドが、MockRepositoryのものと入れ替わる
@isTest(seeAllData=false)
public with sharing class OppNameUpdatorUsingApexEloquent_T {

  @isTest(seeAllData=false)
  public static void testExecute() {
    // create test Data
    Opportunity mockOpp = new Opportunity();
    mockOpp.Id = '006000000000001';
    mockOpp.Name = 'Test Opportunity';

    // create MockRepository
    MockRepository mockRepository = new MockRepository(mockOpp);

    // create instance of the class
    OppNameUpdatorUsingApexEloquent updator = new OppNameUpdatorUsingApexEloquent(mockOpp.Id, mockRepository);

    // execute the method
    Opportunity updatedOpp = updator.execute();

    // verify the result
    String expectedName = 'Updated Opportunity Name';
    System.assertEquals(exceptedName, updatedOpp.Name);
  }
}

見ての通り、どこにもInsertがありません。つまり、データベースへの依存がなくなっているのです。
また、ロジックの検証に必要なインスタンスだけ用意すれば良いのでAccountの準備も不要です。
ついでに言うと、テスト対象のクラスの中のupdateもデータベースにアクセスしていません。
doUpdateメソッドは次のように引数で受け取ったレコードをそのまま返しているだけです。

MockRepositoryのdoUpdateクラス
  public SObject doUpdate(SObject record) {
    return record;
  }

non writableなフィールドへの対応

商談にNameWithAccountName__cという数式項目があったと仮定します。
この数式項目は「商談名 + 取引先名」となるような数式が入っているとします。
そして、このNameWithAccountName__cに「テスト取引先」という文字列が含まれていれば、商談名の頭に【テスト】と追加するという操作を行うこととします。

Apex Eloquentで書いていきましょう。

// execute SOQL using Apex Eloquent pattern
public with sharing class OppNameUpdatorUsingApexEloquent {
  private final Id oppId;
  private final RepositoryInterface repository;

  /**
  * constructor
  *
  * @param oppId the Id of the Opportunity to update
  */
  public OppNameUpdatorUsingApexEloquent(Id oppId) {
    this(oppId, null);
  }

  /**
  * private constructor for testing
  *
  * @param oppId the Id of the Opportunity to update
  * @param repository the repository to use for data access
  */
  @TestVisible
  private OppNameUpdatorUsingApexEloquent(Id oppId, RepositoryInterface repository) {
    this.oppId = oppId;
    this.repository = repository ?? new Repository();
  }

  /**
  * Updates the name of the Opportunity record with the given Id.
  */
  public Opportunity execute() {
    // Get the Opportunity record
    Query query = (new Query())
      .source(Opportunity.getSObjectType())
      .pick(new List<String>{'Id', 'Name', 'NameWithAccountName__c'})
      .condition('Id', '=', oppId);
    Opportunity opp = (Opportunity) this.repository.first(query);

    Boolean hasTestAccountName = opp.NameWithAccountName__c.contains('テスト取引先');
    if(!hasTestAccountName) {
      return opp;
    }
    
    opp.Name = '【テスト】' + opp.Name
    Opportunity updatedOpp = (Opportunity) this.repository.doUpdate(opp);
    
    return updatedOpp;
  }
}

OKですね。ではテストクラスを書いてみましょう

@isTest(seeAllData=false)
public with sharing class OppNameUpdatorUsingApexEloquent_T {

  @isTest(seeAllData=false)
  public static void testExecute() {
    // create test Data
    Opportunity mockOpp = new Opportunity();
    mockOpp.Id = '006000000000001';
    mockOpp.Name = '商談A';
    mockOpp.NameWithAccountName__c = '商談A_テスト取引先';

    // create MockRepository
    MockRepository mockRepository = new MockRepository(mockOpp);

    // create instance of the class
    OppNameUpdatorUsingApexEloquent updator = new OppNameUpdatorUsingApexEloquent(mockOpp.Id, mockRepository);

    // execute the method
    Opportunity updatedOpp = updator.execute();

    // verify the result
    String expectedName = '【テスト】商談A_テスト取引先';
    System.assertEquals(exceptedName, updatedOpp.Name);
  }
}

完成です。ではこれを実行すると・・・Field is not writeable: Opportunity.NameWithAccountName__cというエラーが出ました。
これは、NameWithAccountName__cが数式項目のため直接の入力ができないというApexの制約です。
数式項目はオブジェクトがinsertかupdateされると再評価されて値が決まる仕組みになっています。
つまり、このままでは「データベースへアクセスしない限り、ロジックのテストができない」ということになってしまいます。

ここで登場するのが、Evaluator(評価者)を使用したモック化です。

元のクラスをEvaluatorを使って書き換える

まず、Evaluatorを使ってnon writableなフィールドのモックをするために元のクラスでEvaluatorを使用するように書き換えます。

// execute SOQL using Apex Eloquent pattern
public with sharing class OppNameUpdatorUsingApexEloquent {
  private final Id oppId;
  private final RepositoryInterface repository;

  /**
  * constructor
  *
  * @param oppId the Id of the Opportunity to update
  */
  public OppNameUpdatorUsingApexEloquent(Id oppId) {
    this(oppId, null);
  }

  /**
  * private constructor for testing
  *
  * @param oppId the Id of the Opportunity to update
  * @param repository the repository to use for data access
  */
  @TestVisible
  private OppNameUpdatorUsingApexEloquent(Id oppId, RepositoryInterface repository) {
    this.oppId = oppId;
    this.repository = repository ?? new Repository();
  }

  /**
  * Updates the name of the Opportunity record with the given Id.
  */
  public Opportunity execute() {
    // Get the Opportunity record
    Query query = (new Query())
      .source(Opportunity.getSObjectType())
      .pick(new List<String>{'Id', 'Name', 'NameWithAccountName__c'})
      .condition('Id', '=', oppId);
-   Opportunity opp = (Opportunity) this.repository.first(query);
+   EvaluatorInterface oppEvaluator = this.repository.firstAsEvaluator(query);

-   Boolean hasTestAccountName = opp.NameWithAccountName__c.contains('テスト取引先');
+   String oppNameWithAccountName = (String) oppEvaluator.get('NameWithAccountName__c');
+   Boolean hasTestAccountName = oppNameWithAccountName.contains('テスト取引先')
    if(!hasTestAccountName) {
-     return opp;
+     return oppEvaluator.getRecord();
    }

+   Opportunity opp = oppEvaluator.getRecord();
    opp.Name = '【テスト】' + opp.Name;
    Opportunity updatedOpp = (Opportunity) this.repository.doUpdate(opp);
    
    return updatedOpp;
  }
}

これで完成です。
今までoppそのままで使用していたのが、EvaluatorInterfaceでラップされた形となり、non writableなNameWithAccountName__cにはEvaluatorInterfaceのgetメソッドを通してアクセスしています。
では、次にテストクラスを書いていきましょう。

@isTest(seeAllData=false)
public with sharing class OppNameUpdatorUsingApexEloquent_T {

  @isTest(seeAllData=false)
  public static void testExecute() {
    // create test Data
    Opportunity mockOpp = new Opportunity();
    mockOpp.Id = '006000000000001';
    mockOpp.Name = '商談A';
-   mockOpp.NameWithAccountName__c = '商談A_テスト取引先';
+   Map<String, Object> fieldToValue = new Map<String, Object> {
+     'NameWithAccountName__c' => '商談A_テスト取引先'
+   }
+   MockEvaluator = evaluator = new MockEvaluator(mockOpp, fieldToValue);

    // create MockRepository
-   MockRepository mockRepository = new MockRepository(mockOpp);
+   MockRepository mockRepository = new MockRepository(mockEvaluator);

    // create instance of the class
    OppNameUpdatorUsingApexEloquent updator = new OppNameUpdatorUsingApexEloquent(mockOpp.Id, mockRepository);

    // execute the method
    Opportunity updatedOpp = updator.execute();

    // verify the result
    String expectedName = '【テスト】商談A_テスト取引先';
    System.assertEquals(exceptedName, updatedOpp.Name);
  }
}

完成です。
先ほど、non writableなフィールドであるNameWithAccountName__cにはEvaluatorInterfaceのgetメソッドを通してアクセスしているとお伝えしましたが、ここで次の記述が効いてきます。

   Map<String, Object> fieldToValue = new Map<String, Object> {
     'NameWithAccountName__c' => '商談A_テスト取引先'
   }

Evaluatorを使用するように書き換えた元のクラスにおいて、getメソッドを使って特定のフィールドの値を取得しようとした時にそのフィールド名が「NameWithAccountName__c」であれば「商談A_テスト取引先」を返すという風に書き変わっています。
そのため、non writableなフィールドにそもそもアクセスする必要がなくなり、かつテストクラスで値を自由に書き換えることができるためロジックのテストが可能になるのです。

まとめ

DMLの多用はテストの負債を招き、継続的な開発における障害になります。しかし、Apex Eloquentを活用することで、DMLに依存しない軽量で再現性の高いテストを簡単に記述できるようになります。

  • 「今あるテストに段階的に導入」することも可能
  • 既存アーキテクチャとの共存もできる柔軟性あり
  • 小さな工夫が、開発の未来を大きく変える

「テストの書きやすさ」は、開発体験そのものを変える鍵になります。あなたのSalesforceプロジェクトにも、ぜひApex Eloquentによるモックの考え方を取り入れてみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?