遅い・重い・書きづらい Apex テストを「モック化」で作り変えた話

はじめに

こんにちは、株式会社ユーザベース スピーダ事業 Sales System Engineering Teamの村松(あだ名:MJ)です。
ユーザベースのSalesforceのアドミン/デベロッパーを担当しています。

SalesforceではApexを本番環境にデプロイするときはテストクラスが必須です…が、Apexのテストクラス書きづらくないですか?
オブジェクトの依存関係を考慮してテストデータをインサートしないといけないですし、トリガがあったらそれはもう純粋なユニットテストではなくなってしまいます。

本記事では、テストデータと DML を モック化してレガシーな Apex テストを作り変えた話を紹介します。
同じく Apex のテストの遅さ・書きづらさに悩んでいる方の参考になれば幸いです。

何が課題だったのか

1. テストが遅い

これまでテストデータを用意するために実際に insert を実行していました。1テストにつき数十レコードを毎回 DB に書き込むため、テストクラスが増えるほど実行時間は膨らみます。
こちらの記事 で私たちはソース駆動開発へ移行し、「これからCI/CDパイプラインを構築してガンガンAIを活用した開発をしていくぞ!」と考えていたわけですが、テストが遅くてCIには組み込めない状態です。

2. テストデータの準備が煩雑

ロジックを検証する前に、取引先や商談などの関連データを用意する準備コードが長くなり、本質的なアサーションが埋もれがちでした。
さらに、実際にinsertするとトリガが発火し、関連レコードの更新やバリデーションなど、本来の検証対象とは関係ない副作用まで考慮する必要があります。
その結果、検証したい状態だけを切り出した“純粋なテストデータ”を作れず、テストの可読性・保守性も下がっていました。

従来のテストはこうなっていた

言葉だけだと伝わりづらいので、まず「これまでのテスト」を見てください。
「商談を取得して、親の取引先名を商談名に反映して更新するサービス( UpdateOppNameService )」に対する、従来型のテストです。

@isTest
static void 商談名に取引先名が反映されること_従来版() {
    // 【準備】親の取引先から順番に insert が必要
    Account acc = new Account(Name = 'テスト株式会社');
    insert acc;   // ← ここで Account トリガ・入力規則が発火

    Opportunity opp = new Opportunity(
        Name = '旧商談名',
        AccountId = acc.Id,
        StageName = 'Prospecting',   // ← 検証と無関係だが必須項目なので設定が必要
        CloseDate = Date.today()     // ← 同上
    );
    insert opp;   // ← ここでも Opportunity トリガが発火

    // 【実行】
    Test.startTest();
    new UpdateOppNameService().execute();
    Test.stopTest();

    // 【検証】DB から再取得してアサート
    Opportunity updated = [SELECT Name FROM Opportunity WHERE Id = :opp.Id];
    Assert.areEqual('テスト株式会社 - 商談', updated.Name);
}

やりたいのは「商談名が『取引先名 + 商談』になること」の確認だけなのに、

  • 依存する取引先を先に insert しないといけない
  • 検証と無関係な必須項目(StageName や CloseDate)まで埋めないといけない
  • insert のたびにトリガや入力規則が発火し、それらが失敗するとテストも落ちる
  • DB への書き込み・読み込みが発生するので遅い

という状態でした。実際のオブジェクトはもっと依存関係が深いので、準備コードはこの何倍にも膨らみます。

「DB に書かないテスト」を実現する

上記の課題を解消するために、DBにアクセスしないテストを書くようにしました。

イメージとしてはこうです。

【従来】
テストクラス → 実データを insert → トリガ発火・副作用 → DB に書き込み → 再取得して検証
                (遅い・準備が大変・無関係な要因で落ちる)

【モック化後】
テストクラス → メモリ上で偽データを組み立て → サービスに渡す → 「何を更新しようとしたか」を検証
                (速い・準備は数行・検証対象だけを純粋にテストできる)

ポイントは、プロダクトコード側で SOQL / DML を抽象化レイヤー経由で書いておくことです。
生の [SELECT ...]update records を直接書くことはしません。
そしてこれを実現するために、Apex Fluently を導入しました。
使用するのは、 SOQL Lib / DML Lib、そしてテストデータ生成を担う Test lib です。
これらを使うだけで、テストクラスからその入出力をモックに差し替えられるようになります。

先ほどと同じ UpdateOppNameService を、SOQL Lib / DML Lib を使って書くと下記のようになります。

// ① SOQL:クエリは mockId を割り振って、テストクラスからモックを差し込めるようにする
List<Opportunity> opps = btcdev.SOQL.of(Opportunity.SObjectType)
    .with(Opportunity.Id, Opportunity.Name)
    .with('Account.Name')
    .mockId('OppQuery')
    .toList();

for (Opportunity opp : opps) {
    opp.Name = opp.Account.Name + ' - 商談';
}

// ② DML:こちらも、identifier を割り振って、テストクラスからモックを差し込めるようにする
new btcdev.DML()
    .identifier('UpdateOpp')
    .toUpdate(opps)
    .commitWork();

上記に対するテストが以下です。DBには一切アクセスしていません。

// test-lib の Mocker で、モックデータを作成する。
Opportunity mockOpp = (Opportunity) OpportunityTestModule.Mocker()
    .setFakeId()                         // 偽 Id をセット
    .set(Opportunity.Name, '旧商談名')
    .set('Account.Name', 'テスト株式会社') // 親 Account.Name をセット
    .build();

// プロダクトコードの mockId('OppQuery') に、モックデータを返させる
btcdev.SOQL.mock('OppQuery').thenReturn(new List<Opportunity>{ mockOpp });

// identifier('UpdateOpp') の更新を DB に流さず、メモリ上で記録させる
btcdev.DML.mock('UpdateOpp').allUpdates();

Test.startTest();
new UpdateOppNameService().execute();
Test.stopTest();

// 検証:実際に「何を更新しようとしたか」を取り出してアサート
List<Opportunity> updatedList = btcdev.DML
    .retrieveResultFor('UpdateOpp')
    .updatesOf(Opportunity.SObjectType)
    .successes();

Assert.areEqual(1, updatedList.size());
Assert.areEqual('テスト株式会社 - 商談', updatedList[0].Name);

従来版と見比べてみてください。

  • 取引先の insert が不要になり、set('Account.Name', ...) の1行で「親を持つ商談」を表現できる
  • StageName や CloseDate のような、検証と無関係な必須項目を埋める必要がない
  • insert しないのでトリガ・入力規則が一切発火しない
  • 「何を更新しようとしたか」を直接取り出せるので、DB から再取得する必要もない

なぜ Apex Fluently を選んだのか

Apex のモック/テスト系 OSSを導入するにあたってApex Fluently以外にもfflibApexEloquentが選択肢に上がっていて、3つで比較検討していました。
やりたいことを実現するうえでは大きな差異はありませんでしたが、私たちが Apex Fluently を選んだ理由は2つあります。

1. 学習コスト/導入コストが低い

fflib は強力ですが、その恩恵を受けるには Domain / Selector / Service といったレイヤー構成や DI を前提に設計を組み直す必要があり、既存コードへの段階的な導入のハードルが高いです。
ApexFluentlyであれば既存のテストから1つずつ置き換えていける点が、私たちの状況に合っていました。

2. 継続的にメンテナンスされ、サポート体制が整っている

OSSをテスト基盤にする以上、ライブラリの寿命やサポート体制は重要な選定基準です。Apex Eloquentも学習/導入コストの低さは魅力的でしたが、
個人で開発されているOSSだったため、企業が継続的にメンテナンスをしていてサポート体制も整っているApexFluentlyに軍配が上がりました。

導入してよくなったこと

1. テストが速くなった

実行時間は狙い通りかなり短くなりました。 まだすべてのテストを置き換えできていませんが、1つのクラスを例に挙げると、実行時間が6055ミリ秒→1152ミリ秒になっており実行時間が1/4になっています。

2. テストが「読めるもの」になった

検証したい状態だけを数行で組み立てられるので、テストを開いたときに「何を・どう検証しているのか」が一目で分かります。
今後はテストが仕様を語るドキュメントとして機能することを期待しています。

おわりに

最後に、導入時に1点だけ運用面で気をつけたことを共有します。
当初、Apex Fluently の各ライブラリをすべて Git サブモジュールでソース管理しようとしたところ、本番デプロイ時にOSS 側のテストクラスが環境差分で落ちるという問題に当たりました。
アンロックパッケージが提供されているもの(SOQL / DML )はパッケージとしてインストールし、 まだパッケージ提供のない test-lib だけサブモジュール管理を継続することで解消しました。 導入時は気を付けてください。

まだすべてのテストを置き換えられたわけではなく、導入はまだ初期段階です。
ただ、既存のテストから1つずつ移行できる手軽さもあり、初期の感触はとても良いので、ここから着実に置き換えを進めていきます。
同じようにApexテストの遅さ・書きづらさに悩んでいる方がいたら、まずは1クラスからでも試してみてください。この記事がその参考になればうれしいです。

Page top