テスト戦略
このドキュメントは、Apex Stem のテスト戦略を、設計判断と実践規約の両面から解説します。Handler-Usecase Architecture の 2 層を、どんな種類のテストでどう守るかを、腰を据えて把握するためのページです。
手を動かす実例 (動くテストコード) は Apex Stem 導入ガイドのステップ 4 を正典として参照してください。本ドキュメントは、そこに散らばっている判断と規約を体系化します。
このドキュメントの要点 (TL;DR)
Apex Stem のテスト戦略は、Handler-Usecase Architecture の 2 層を 「自分のロジックの責任範囲」と「プラットフォーム / 組織設定の責務」 に構造で分離するところから始まります。
- 単体テスト (
MockEloquent、DB なし) で前者を網羅 - 結合テスト (
ApexBlueprint、実 DML) で後者を代表ケースだけ検証
これにより、テスト失敗時に「自分のコードのバグ」か「アドミンによる設定変更」かが瞬時に切り分けられます。Salesforce のように本番環境がコードの外側で動的に変わる前提では、この切り分けが運用上の決定的な利点になります。
読み方ガイド
このページは哲学・規約・実用がひとつにまとまっていますが、関心ごとに必要な章だけ読めば十分です。はじめての方は順番どおりに、経験者は気になる章から飛んで構いません。
| 関心 | 読む順番 |
|---|---|
| テストの判断哲学を知りたい (なぜこういう設計なのか) | テスト戦略の全体像 → 単体テストが守るもの・守らないもの → Salesforce 特有の事情: 動的な実行環境 → テスト失敗時の判断マトリクス → なぜこの戦略が長期で効くか |
| テストをこれから書く (実装規約をすぐ使いたい) | Usecase 層: 単体テスト → Handler 層: 結合テスト (「その代表 1 本は、ガバナ IT にする」まで) → テストの書き方の規約 |
| 失敗時のデバッグ参考 | テスト失敗時の判断マトリクス → 棲み分けの判断と落とし穴 |
| チームに展開する設計判断のまとめ (なぜを伝える材料) | 単体テストが守るもの・守らないもの → Salesforce 特有の事情 → CI/CD 運用の推奨 → なぜこの戦略が長期で効くか |
テスト戦略の全体像
Apex Stem のテスト戦略は、Handler-Usecase Architecture の 2 層と、2 つのテスト種別、2 つの OSS が 1 対 1 で対応 するように設計されています。
| 層 | テスト種別 | DB アクセス | 使う OSS |
|---|---|---|---|
| Usecase 層 | 単体テスト | なし (モック) | ApexEloquent (MockEloquent / MockEntry) |
| Handler 層 | 結合テスト | あり (実 DML) | ApexBlueprint (SBlueprint / SOrchestrator) |
なぜこの規約を明示するか
1 対 1 マッピングをチームの規約として明示することで、3 つの実利があります。
- 覚えやすさ。テストを書く前の「どのテスト種別で何を使うか」の判断コストが消えます。Usecase なら MockEloquent、Handler なら ApexBlueprint、と即決できます。
- 教育性。新しくチームに加わる人に「この層にはこの種類のテストを書きます」と短く伝えるだけで、テスト設計の土台が共有できます。
- AI 連携。AI コーディングアシスタント (Claude Code など) に開発ルールを記したファイル (
CLAUDE.md) を渡す際、この 1 対 1 対応を 2 行で伝えるだけで、AI が適切なテスト種別と OSS を選んで書いてくれます。
分担の原則: 単体で網羅、結合は代表ケース
層ごとのテストには、それぞれ担う責務があります。
- Usecase の単体テスト: ロジック分岐をすべて網羅します。フェーズ別の集計、null や空入力、複数キーの組み合わせ、
TraceFlowがskip()で抜けるパスとfinish()で抜けるパスの両方を、MockEloquentで高速・隔離で書きます。 - Handler の結合テスト: 「Trigger / Batch / REST から Usecase が呼ばれて、期待通り連鎖全体が動くか」の代表ケースを 1 から 3 件に絞ります。ロジックの網羅は Usecase 側で済ませ、Handler 側で網羅的に書きません。
この分担を逆にすると、結合テストが遅く冗長になり、ロジック網羅で時間とカバレッジが破綻します。
単体テストが守るもの・守らないもの
「単体テストと結合テストは粒度の違いだ」と捉えると、Salesforce の文脈ではしばしば判断を見誤ります。Apex Stem では 責任範囲の異なるレイヤー として両者を扱います。ここをはっきりさせておくと、後段の判断 (テスト失敗時の初動 / CI/CD の運用) が一気に決まります。
守るもの: 自分が書いたロジック
Usecase の単体テストが検証する対象は、自分が書いたロジックの正しさ に限ります。
- 入力 (コンストラクタ引数) に対する出力 (
invoke()の戻り値) - DB への副作用 (
MockEloquentの spy による検証) - 変更レコードの内容 (フィールド値)
- 変更レコードの件数
- 削除レコードの件数
守らないもの: プラットフォームと組織設定の責務
逆に、以下は 意図的に 単体テストの対象外とします。
- Salesforce プラットフォームの挙動 (
Database.upsertのallOrNone、External ID upsert の細部など) - トリガー連鎖、ワークフロー、フロー、プロセスビルダーの動作
- バリデーションルール、必須項目チェック
- 権限・項目レベルセキュリティ (FLS)
- 重複ルール、割り当てルール
- レコードタイプ、ページレイアウト
これらは「自分が書いたロジック」ではなく、Salesforce プラットフォームおよび組織設定の責務です。単体テストで巻き込むと、テストの責任範囲が曖昧になり、失敗時の原因切り分けが難しくなります。
守れないもの: ガバナ消費 (自分の責任範囲なのに、単体からは見えない)
「守るもの」と「守らないもの」の 2 つに分けると、こぼれ落ちるものがあります。自分の責任範囲でありながら、単体テストからは構造的に観測できないもの — その代表がガバナ消費 (SOQL 数・DML 数) です。
MockEloquent は実 SOQL を発行しません。だから速く、隔離されるわけですが、裏返すと クエリ数・サブクエリの形・ガバナ消費に対しては原理的に盲目です。
- 階層を段階的に降りて
whereInを撃つ実装は、読む SObject の種類数だけ SOQL を積み上げます - トリガーのカスケードがあると、1 パスの SOQL 数がそのまま再入回数だけ乗算されます
- どちらも単体テストは全緑のまま通ります。本番のバルク処理で初めて
Too many SOQL queries: 101として現れます
これはプラットフォームの責務ではありません。自分が書いたクエリの効率という、完全に自分の責任範囲の問題です。にもかかわらず単体テストからは見えない。だからここだけは、後述する ガバナ結合テスト (実 DML バルク) で別途塞ぎます。
「単体で網羅、結合は代表ケース」という分担は正しいのですが、その代表ケースに何を選ぶかがここで効いてきます。詳しくは「Handler 層: 結合テスト」の「その代表 1 本は、ガバナ IT にする」を参照してください。
なぜ責任範囲を構造で分けるか (テストの診断価値)
責任範囲を構造で分けておくと、テスト失敗時に「どこに問題があるか即座に切り分けられる」ようになります。これがテストスイートの中核的な価値、すなわち 診断価値 (diagnostic value) です。
- Usecase 単体テストが落ちた → 自分のロジックに問題がある (= コードの修正が必要)
- 単体は通って結合だけ落ちた → 自分のロジックは無実、プラットフォーム / 組織設定の変化が原因
逆にトリガー連鎖や権限を単体テストに巻き込むと、失敗時に「ロジックのバグ? プラットフォームの挙動? 権限設定? 重複ルール?」と調査範囲が爆発します。「単体テストが落ちている」という情報だけでは何も特定できなくなり、テストの価値が下がります。
MockEloquent の細部挙動 (Database.upsert の部分成功、External ID upsert の動作など) が本物の Eloquent と完全一致しているか、を気にしないのも同じ理由です。そこは Salesforce プラットフォームの責務であって、自分のコードの責任範囲ではありません。プラットフォーム挙動に依存する部分は、結合テストで確認します。
Salesforce 特有の事情: 動的な実行環境
通常のソフトウェア開発では、本番環境の挙動はデプロイされたコードによって決まります。コード変更には PR レビューが入り、Git で履歴が追えます。
しかし Salesforce では:
- アドミンがフローを変更できる (デプロイ不要)
- 項目の必須化・バリデーションルールが画面から追加される
- 権限セット・プロファイルが運用中に変更される
- 重複ルール・割り当てルールが追加される
これらの変更は コードの外側 で発生し、Git に履歴が残りにくいものです。つまり Salesforce の本番環境は、開発者から見れば「いつ何が変わっているか分からない実行環境」です。
この前提から導かれる、2 つの全く異なる役割
Salesforce 環境では、テストスイートには 2 つのまったく異なる役割が求められます。
| 役割 | 内容 | 担当するテスト種別 |
|---|---|---|
| 自分のロジックが正しいことの保証 | アドミンが何をしようと、自分の書いた条件分岐・計算・データ加工は意図通り動く | Usecase 単体テスト |
| プラットフォーム環境との整合性の保証 | 現在の設定下で、自分のコードが正しく動作する | Handler 結合テスト |
この 2 つを構造的に分離することで、テスト失敗時の初動が即座に判断可能になります。詳しくは後段の「テスト失敗時の判断マトリクス」セクションで扱います。
Usecase 層: 単体テスト
なぜ DB を使わないか
Usecase は単一の業務ロジックを実装するクラスで、データアクセスは IEloquent を通じて行います。テストでは IEloquent を MockEloquent に差し替えれば、データベースに触れずに ロジックそのものだけ を検証できます。
DB を使わないことで得られるものは 3 つあります。
- テストが速い。実 DML を介さないのでミリ秒単位で完了します。
- テストが隔離される。レコードタイプや組織設定、他テストの副作用に左右されません。
- 検証対象を絞れる。「この条件のとき、この更新が起きる」を、ノイズなくアサーションできます。
MockEloquent と Eloquent はともに IEloquent インターフェースを実装し、プロダクションコードはインターフェースに依存します。つまり Usecase から見れば、本番の Eloquent と単体テストの MockEloquent は同じ契約のオブジェクトであり、細部の挙動 (Database.upsert の部分成功、External ID upsert のセマンティクスなど) を一致させる必要はありません。そこは Salesforce プラットフォームの責務であって、自分のコードの責任範囲ではないからです (詳しくは前段の「単体テストが守るもの・守らないもの」を参照)。
速さについては、補足があります。速いと生産性が上がる、という話ではありません。効くのは「テストが実行されるかどうか」です。
実 DML のテストは、組織の状態に引きずられて数十秒から数分かかります。すると開発の途中では回さなくなり、「たぶん通るだろう」のまま先に進みます。単体テストがミリ秒で終わるなら、書いた直後に回せます。フィードバックのループが途切れないことが、速さの本当の価値です。
⚠️ ただし、これは単体テストが結合テストの代わりになるという意味ではありません。速さと引き換えに見えなくなるもの (前段の「守れないもの: ガバナ消費」) があります。
ApexEloquent (MockEloquent / MockEntry) の役割
| クラス | 役割 |
|---|---|
MockEloquent | IEloquent の差し替え先。get(scribe) で返す IEntry のリストを attach(label, ...) で注入する。実行された DML は upsertedRecordsAt(label) / deletedCountAt(label) で取り出せる |
MockEntry | SObject の差し替え先。set('Field__c', value) で書き込み不可項目 (数式・ロールアップ・親リレーション) にも値を入れられる。Scribe で field() していない項目にアクセスすると例外を投げ、SELECT 漏れを検知する |
何を検証するか (単体)
Usecase の単体テストでは、次の観点を網羅します。
- 業務ロジックの分岐。
if/switchの各パス、フィールド値による振る舞いの違い、複数レコードを跨ぐ集計の正しさ - 早期 return の経路。「対象が空ならスキップ」「条件不一致なら抜ける」が、ちゃんと skip パスで終わっているか
- DML の中身。
upsertedRecordsAt(label)に何が積まれたか、どのフィールドにどの値が入っているか
TraceFlow.isLastFinish() と TraceFlow.isLastSkip() は、戻り値だけでなく どのコードパスを通ったか を区別する手段です。「対象がなくてスキップした」と「処理が正常に完了した」を、invoke() の返り値が void でも別々に検証できます。
コード抜粋 (単体テスト)
Apex Stem 導入ガイドのステップ 4 の CopyAccountIndustryToOpportunityUsecase のテストから、骨格を抜粋します。
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること');
t.start();
// Arrange
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.alias('opp').autoId(1)
.setParent('AccountId',
MockEntry.of(Account.class).set('Industry', 'Technology'));
MockEloquent mock = (new MockEloquent())
.attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
// Act
(new CopyAccountIndustryToOpportunityUsecase(
new Set<Id>{ oppEntry.getAliasId('opp') }, mock
)).invoke();
// Assert
List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE);
Assert.areEqual(1, updated.size());
Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);
Assert.isTrue(TraceFlow.isLastFinish());
t.finish();
}
注目するポイント:
- MockEntry の親レコード。
setParent('AccountId', ...)で親取引先をぶら下げ、本物の SOQL を介さずに「親取引先が業種を持っている商談」が組めます。 IEloquentをラベル多重化で 1 本にまとめる。同じmockを取得 (LBL_FETCH) と更新 (LBL_UPDATE) でラベル分けすることで、用途別にインスタンスを分けなくても DML だけをupsertedRecordsAt(LBL_UPDATE)で独立して観察できます。詳しくは Layered Constructor Pattern を参照してください。TraceFlow.isLastFinish()。戻り値でない経路 (skip/finish/abort) の検証は TraceFlow が担います。
「対象なしでスキップされる」のような分岐の検証は、TraceFlow.isLastSkip() に置き換えて同じ形で書けます。導入ガイドのステップ 4 に両方の正典が載っています。
Handler 層: 結合テスト
なぜ実 DML が必要か
Handler は Trigger / Batch / REST / Flow / Schedulable などのエントリーポイントに紐づくクラスで、エントリーポイント固有の作法を吸収して Usecase に渡すのが責務です。
「Trigger が発火して TriggerHandler が呼ばれ、想定通り Usecase が動く」という配線は、実 DML を流さないと再現できません。Trigger.new や Trigger.oldMap の値、レコードタイプの解決、他のトリガーとの相互作用は、Apex のテストランタイムで実 DML を実行した時だけ正確に再現されます。
ApexBlueprint (SBlueprint / SOrchestrator) の役割
| クラス | 役割 |
|---|---|
SBlueprint | 1 つの SObject に対するレコード定義。template() でデフォルト値、set() で個別上書き、withChildren() で子をぶら下げる、alias() で取り出し用の名前付け |
SOrchestrator | 複数 SBlueprint を集めて、依存関係をトポロジカルソートして順次 insert する。実 DML が走るのはここ |
ApexBlueprint を使うと、「業種を持つ取引先 → その子として商談」のような階層が宣言的に組み立てられ、insert 順は自動で解決されます。
何を検証するか (結合)
結合テストの観点は、単体テストとはまったく違います。
- 配線が正しいか。Trigger / Batch / REST から該当の Handler が呼ばれ、Handler が想定の Usecase を呼んでいるか
- 連鎖全体の整合性。Handler → Usecase → ApexEloquent → 実 DB の経路を通して、最終的に保存された値が期待通りか
- 代表的なシナリオ。「商談を insert したら親取引先の業種がコピーされる」のような、エントリーポイント起点の代表ケースを 1 から 3 件だけ
ロジック分岐の網羅は Usecase 単体テストの責務です。Handler 結合テストで全分岐を網羅すると、テストが極端に遅くなり、原因切り分けも難しくなります。
コード抜粋 (結合テスト)
導入ガイドのステップ 4 の Handler 結合テストから、骨格を抜粋します。
@isTest
static void testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談 insert で親取引先の業種がコピーされること');
t.start();
// Arrange: ApexBlueprint で階層を組み立てる
SOrchestrator orchestrator = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.alias('acc')
.template(Blueprints.accBasic())
.set('Industry', 'Technology')
.withChildren(
SBlueprint.of(Opportunity.class)
.alias('opp')
.template(Blueprints.oppBasic())
));
// Act: create() で取引先 → 商談の順に insert され、Trigger が発火する
Test.startTest();
orchestrator.create();
Test.stopTest();
// Assert: 商談に業種がコピーされていること
Opportunity opp = (Opportunity) orchestrator.getByAlias('opp');
Opportunity refetched = [
SELECT Id, Industry__c FROM Opportunity WHERE Id = :opp.Id
];
Assert.areEqual('Technology', refetched.Industry__c);
t.finish();
}
注目するポイント:
withChildrenの宣言がデータ階層と一致する。コードを読むだけで「取引先の下に商談」というデータ構造が見えます。Test.startTest()/Test.stopTest()で DML を囲む。これがないと非同期トリガーやガバナ制限のカウントがテスト実行時と本番で食い違うことがあります。- insert 後の refetch。
orchestrator.getByAlias('opp')で取り出せる Opportunity は insert 時点のスナップショットなので、Trigger によるフィールド更新を確認したい時は 明示的に SOQL で再取得 します。
その代表 1 本は、ガバナ IT にする
結合テストを代表ケース 1 から 3 件に絞る、という方針はそのままです。ただし、その代表のうち 1 本は「本番サイズのバルクを流してガバナ余白を測るテスト」にします。
理由は前段の「守れないもの: ガバナ消費」のとおりです。MockEloquent は実 SOQL を発行しないため、クエリ数の非効率は単体テストからは絶対に見えません。そして単一シナリオの結合テストも、レコードが数件では 100 SOQL の天井に届かないので素通りします。この穴は「量産 × 実 DML × ガバナのアサート」でしか塞げません。
@isTest
static void testCascade_WhenBulk_ThenWithinGovernorLimits() {
Trace t = Trace.of('エッジケース: 本番サイズのバルクでもガバナ制限に余白があること');
t.start();
// Arrange: times() で本番相当の件数を量産する
SOrchestrator orchestrator = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.template(Blueprints.accBasic())
.withChildren(
SBlueprint.of(Opportunity.class)
.template(Blueprints.oppBasic())
.alias('opp_{#}')
.times(201) // 201 件以上。理由は後述
));
// Act: 1 回の DML でカスケードを一括発火させる
Test.startTest();
orchestrator.create();
Integer soqlUsed = Limits.getQueries(); // ★ 必ずブロックの中で掴む
Test.stopTest();
// ① 結果の正しさ。1 件でも欠けたら合わない値で縛る (これが主眼)
Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], '201 件すべてが処理されていること');
// ② 単位ごとの消費。件数に比例して発行していないか
TraceFlow.usageOf('集金オブジェクトの再生成')
.assertInvocationsAtMost(6, '201 件 insert 時の実測は 5。増えたら配線が増えた可能性がある')
.assertSoqlQueriesAtMost(15, '件数に比例してクエリを撃っていないこと');
// ③ トランザクション全体としてもガバナに余白があること
Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2,
'バルクでも SOQL は上限の半分未満であること。実測 ' + soqlUsed);
t.finish();
}
アサートを 3 段にするのが要点です。 ①が主眼で、ガバナだけ見ていると「取りこぼしているのに消費は少ない」を見逃します。ロジックの網羅は単体側で済んでいるので、ここで分岐を増やす必要はありません。
🚨 Limits は stopTest() の後で読んではいけない
Test.stopTest() はガバナカウンタを startTest() 前の状態に戻します。そのため stopTest() の後で Limits.getQueries() を読むと、Act ではなく Arrange の値が返ります。
実測 (商談 30 件を create() で一括登録):
startTest 前 soql=0 dml=3 ← Arrange の消費
startTest 直後 soql=0 ← リセットされる
Act 後 (ブロック内) soql=7 dml=2 ← ★ これが本当のカスケード消費
stopTest 後 soql=0 dml=3 ← startTest 前に戻る
つまり Test.stopTest(); の後に Assert.isTrue(Limits.getQueries() < 上限/2) と書くと、実際に 7 本消費していても 0 < 50 を評価するだけで、何があっても通ります。必ずブロック内で変数に退避してください。
なぜ 201 件なのか
理由は 2 つあり、2 つ目のほうが重要です。
1 つ目は N+1 の可視化です。times(2) では per-record 実装でも 2 回 / 2 本にしかならず、どんなしきい値も通ってしまいます。
2 つ目は、Salesforce がトリガーを 200 件ずつに分けて呼ぶことです。Data Loader のバッチサイズとは別のプラットフォーム挙動で、純粋な Apex から insert 201件 としても起こります。
| 挿入件数 | Usecase の起動回数 (実測) |
|---|---|
| 30 件 | 2 |
| 200 件 | 3 |
| 201 件 | 5 |
⚠️ この回数は
TraceFlow.discardArrange()を置かなかった場合の値です (Arrange の起動を含みます)。置いた場合は Arrange 分だけ少なくなります (201 件の実測: 5 → 4)。詳細は TraceUsage でガバナ消費を縛る。
200 件までしか流さないテストは、「1 回の呼び出しで全件が来る」前提の実装や、取得に上限がある実装 (take(200) / LIMIT / 先頭 N 件だけ処理) を素通りさせます。実際に集計クエリへ take(200) を仕込んだところ、単体テストも代表ケースも 30 件バルクもすべて緑のまま、201 件のテストだけが落ちました (29 本中 1 本)。
⚠️ DML 行数 10,000 との綱引きに注意。 201 件に子を深くぶら下げると溢れます (
timesはネストで掛け算)。分割を見たいテストは子を最小構成にしてください。
犯人を特定する: TraceUsage (ApexTrace v1.1.0+)
Limits.getQueries() はトランザクション全体の値なので、「上限に近い」ことは分かっても どの Usecase が消費したか は分かりません。TraceUsage は各 Trace コンテキストの start() からクローズまでのガバナ消費を自動記録するので、Usecase 単位で締められます。
TraceFlow.usageOf('集金オブジェクトの再生成')
.assertSoqlQueriesAtMost(15, '件数に比例してクエリを撃っていないこと');
🚨
TraceFlow.lastUsage()は使わないでください。 ハンドラが Usecase を複数呼ぶバルク IT では、返るのは「最後に閉じた 1 本」でしかありません。v1.3.0 以降、この曖昧なケースはテスト実行時にTraceExceptionになります (候補名がそのままメッセージに出るのでusageOfへ移せます)。
記録するのは決定的な 5 指標 (SOQL 数 / SOQL 行数 / DML 文数 / DML 行数 / コールアウト数) だけです。CPU 時間やヒープは実行ごとにぶれるため、意図的に対象外にしています。
⚠️ 単体テスト (
MockEloquent) では実 SOQL が出ないので、TraceUsageの値はすべてゼロになります。ガバナのアサートは実 DML を流す結合テスト側に書きます。単体側に書いても、何も検証していないテストになります。
大きな入力の上限テストとは担当が違う
「本番相当サイズのテスト」には、まったく別種のものがあります。混同しないでください。
| 見たいもの | どこで見るか | 理由 |
|---|---|---|
| CPU 時間・ヒープ・文字列長の上限 (パース、分割、正規化など) | 純関数の単体テスト | メモリ上で組み立てられる。実 DML は不要で、遅くするだけ |
| SOQL 数・DML 数の上限 (トリガーのカスケード) | 実 DML のバルク結合テスト | Mock では実 SOQL が出ないため、原理的に観測できない |
前者を結合テストで流すと、遅いだけで得るものがありません。後者を純関数テストで見ようとすると、そもそも観測できません。両者は別の担当です。
テスト失敗時の判断マトリクス
単体テストと結合テストを責任範囲で分離しておくと、テスト失敗時の初動が下の表のように整理できます。
| 観点 | 単体 | 結合 | 意味 | 初動 |
|---|---|---|---|---|
| パターン 1 | ❌ | ❌ | コードロジックのバグ | コード修正 |
| パターン 2 | ✅ | ❌ | プラットフォーム環境の変化 (設定変更等) | 設定確認・アドミンに問い合わせ |
| パターン 3 | ✅ | ✅ | 正常 | デプロイ可 |
| パターン 4 | ❌ | ✅ | レアケース、要調査 (テスト設計ミスの可能性) | テストレビュー |
特に パターン 2 (✅ ❌) が即座に切り分けられる ことが、運用上の決定的な利点です。
- 「結合テストが落ちた、でもコードは変えていない」→ 誰かが設定をいじった ことが瞬時に分かる
- 開発者は「自分のロジックは無実」と即座に確信できる
- 調査の焦点が「最近のフロー変更」「最近の権限変更」「最近の項目変更」に絞れる
逆に単体と結合が混ざった設計だと、テスト失敗時にコードのせいか設定のせいか不明となり、調査範囲が爆発します。さらに「アドミンがフローを変えたせいで開発者の CI が落ちる」という状況が生まれ、組織的な対立を招きます。責任範囲を構造で分離した時の価値が、ここで具体的に現れます。
CI/CD 運用の推奨
責任範囲の分離は、CI/CD のスケジューリングにも自然に反映できます。
| タイミング | 実行するテスト | 目的 |
|---|---|---|
| PR ごと | 単体テストのみ | コード変更の責任範囲を高速に検証 |
| デプロイ前 | 単体 + 結合テスト | 環境整合性の最終確認 |
| 定期実行 (夜間など) | 結合テスト | 設定変更の早期検知 |
特に定期実行は、Salesforce 環境の変化を 能動的に監視する仕組み として機能します。コード変更なしに結合テストが落ちた場合、それは 設定変更の検知 であり、Salesforce 運用における早期警戒システムとして働きます。
これは Apex Stem 固有のルールというより、「動的な実行環境を持つ Salesforce」という前提から自然に導かれる運用パターンです。チームの規模や CI 環境に合わせて、PR のたびに結合まで回す / 夜間定期は週次に絞る、のような調整は自由に行ってください。
テストの書き方の規約
命名規則
テストメソッド名は test{Method}_When{Condition}_Then{Result} で統一します。
testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied()
testInvoke_WhenNoOpportunityIds_ThenSkipped()
testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied()
何を検証しているかが、メソッド名だけで読み取れる状態を保ちます。
Trace.of でテストの説明を集約
テストメソッドの冒頭に Trace.of('正常系: ...') を置き、テストが何を検証しているかを 完全な日本語文 で書きます。メソッド名は機械可読の識別子、Trace.of の引数は人間可読の説明、と役割を分けます。
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること');
t.start();
// ...
t.finish();
}
メソッド上に // 正常系: ... のような独立コメントは書きません。Trace.of と重複するからです。
Assert.areEqual を使う
Salesforce 推奨の Assert.areEqual / Assert.isTrue / Assert.isNull などを使います。レガシーな System.assertEquals 等は使いません。
.set() は検証対象の項目だけ
MockEntry.set() や SBlueprint.set() で値を入れるのは、そのテストで検証対象になる項目だけ に絞ります。他のテストから .set() を丸ごとコピーして不要な項目まで残すと、「このテストが何を検証しているのか」がノイズに埋もれます。デフォルト値は template() (ApexBlueprint) や MockEntry のデフォルトに任せます。
Spy 検証の粒度: 最終状態を見る、呼び出し順序は見ない
MockEloquent の Spy メソッド (upsertedRecordsAt / deletedCountAt 等) を使った DML 結果の検証は、最終状態 を見ます。呼び出し回数や順序を直接検証するのは原則として避けます。
⭕ 良い例: ビジネス要件レベルの検証
// 件数と内容を検証
List<SObject> upserted = mockEloquent.upsertedRecordsAt(Usecase.LBL_UPDATE);
Assert.areEqual(1, upserted.size());
Account account = (Account) upserted[0];
Assert.areEqual('高優先', account.Priority__c);
❌ 悪い例: 内部処理の順序に依存
// 呼び出し回数や順序を検証 (密結合)
Assert.areEqual(3, mockEloquent.callCount);
Assert.areEqual('upsert', mockEloquent.callHistory[0]);
Assert.areEqual('delete', mockEloquent.callHistory[1]);
ℹ️ ここで使った
callCount/callHistoryはMockEloquentには実在しない仮想プロパティ です。もしこういうものを自作 / 拡張してテストを書こうとした場合に、どんな問題が出るかを示すための仮想例として載せています。
このパターンは「2 回の upsert を 1 回にバッチ化する」のような内部リファクタで偽陽性の失敗を起こします。ロジックの正しさは保たれているのに、テストだけが落ちる状況は、テストへの信頼を下げます。最終状態を検証して、呼び出し回数や順序は原則検証しない のが安全です。
ただし「DML 効率化のため 1 回にまとめる」などが 非機能要件として明示されている場合 は、呼び出し回数の検証も妥当です。非機能要件は明示しておかないと回数依存が壊れたときの判断軸が無くなるので、そのテストの意図を Trace.of のコメントに残しておくと後から読み返したときに迷いません。
リファクタリング耐性
invoke() のみを public にし、内部実装を private で隠す Apex Stem の Usecase 構造と、上の「最終状態を見る」Spy 検証粒度の組み合わせは、リファクタリング耐性の高いテスト を構造的に強制します。
- 内部メソッドの分割・統合・名前変更は、テストから観察できないので影響しない
- DML をまとめる / 分割する変更は、最終状態が同じであればテストは通り続ける
- 結果として、「動作は変えずに構造だけ整理する」リファクタリングが安心して行える
逆に、内部実装に踏み込んだ検証 (private メソッドの単体テスト、呼び出し順序の検証) は、リファクタリングのたびに壊れます。これは「テストが本来検証したいビジネス価値」とは別の、実装ノイズに対する依存です。Apex Stem では構造でこの依存ができないようにしている、と捉えてください。
オーケストレーター Usecase のテストをどう書くか
オーケストレーター Usecase (Reader / Validator / Mapper などの部品クラスを統合する Usecase) のテストには、追加の選択肢があります。
部品クラスをモックするか本物を使うか
Layered Constructor Pattern のおかげで、部品クラスはテストごとに 任意の粒度 で差し替えられます。
AccountReaderをモック、OpportunityMapperだけ本物 → 「外部 I/O は閉じつつ、ロジックの中心は本物で動かす」- すべてモック → 「このオーケストレーター自身の組み立てだけを純粋に検証する」
- すべて本物 → 「結合テストに近い粒度で、データアクセスだけ
MockEloquentで閉じる」
絶対の正解はなく、業務とオーケストレーターの複雑度に合わせて選びます。
単体テストを書くか書かないか
「オーケストレーター自身の単体テストは書かず、Handler 結合テストで担保する」という判断も妥当です。
- 書く理由: ロジックを局所化して早期にバグを検出できる
- 書かない理由: Handler 結合テストで実質カバーされる、二重メンテになる
Apex Stem はどちらも許容します。オーケストレーターが複雑なら書く、単純に束ねるだけなら Handler 結合テストで済ませる、と現場判断に委ねます。
棲み分けの判断と落とし穴
何を単体で網羅、何を結合で代表に留めるか
| 検証したいもの | テスト種別 |
|---|---|
| 業務ロジックの全分岐 (フェーズ別の集計、null / empty、複数キーなど) | Usecase 単体テスト |
| 「Trigger 発火 → Handler → Usecase」の配線が正しいこと | Handler 結合テスト |
TraceFlow の skip / finish 経路 | Usecase 単体テスト |
| 数式項目やロールアップなど DB 側計算の結果 | (本筋ではない。必要なら結合テストで観察) |
この基準を超えると、テストが冗長・低速・低 SN 比に転落します。
落とし穴 1: 単体だけで結合がない
ロジックは全部 MockEloquent で網羅したが、Trigger 配線をテストしていないと、「想定の Usecase が実は呼ばれていない」「呼ばれているが before / after のフェーズが間違っている」といった配線バグがプロダクションで初めて見つかります。Handler ごとに代表ケース 1 件は必ず書きます。
落とし穴 2: 結合で網羅しようとする
逆に、「結合テストで全部やる」と決めると、ApexBlueprint で大量のデータパターンを組み合わせる必要が出てきて、テスト時間が爆発します。ロジックは Usecase 単体側に追いやることで、結合テストは代表ケース 1 から 3 件に収まります。
落とし穴 3: MockEloquent をラベルなしで使い回す
MockEloquent は WHERE 条件を評価せず、渡された IEntry リストをそのまま返します。つまりラベルを付けずに使い回すと、1 つの MockEloquent で「先月分のクエリ」と「今期分のクエリ」を区別できません。どちらのクエリにも同じリストが返ります。
推奨は、クエリ単位にラベルを付けて 1 本の IEloquent を多重化することです。本番側は label(LBL_FETCH) / label(LBL_UPDATE) で呼び分け、テスト側は attach(LBL_..., ...) で用途別に注入します。DI するフィールドは 1 本のままで済みます。
// 本番側 (Usecase)
List<IEntry> entries = this.eloquent.label(LBL_FETCH).get(oppScribe);
this.eloquent.label(LBL_UPDATE).doUpdate(entries);
// テスト側
MockEloquent mock = (new MockEloquent())
.attach(Usecase.LBL_FETCH, new List<IEntry>{ oppEntry });
List<SObject> updated = mock.upsertedRecordsAt(Usecase.LBL_UPDATE);
ラベルが登場する以前は、用途ごとに IEloquent を分けて DI していました。今でも動きますが、依存の数だけコンストラクタが太ります。
label()を一度でも呼ぶと、そのインスタンスは以降すべての操作でラベルが必須になります (ラベル忘れ・同一ラベルの二重消費は例外)。「うっかりラベルなしで使い回す」ことが構造的にできなくなる、というのがこの仕組みの主眼です。
落とし穴 4: attach し忘れたラベルが、静かに空を返す
これはテストが嘘をつく典型例です。ラベル名を打ち間違える、あるいは attach し忘れると、そのラベルのクエリは 0 件を返します。すると「対象が無いのでスキップ」の分岐に入り、テストは緑になります。何も検証していないのに、通ってしまうわけです。
現在の ApexEloquent は、attach していないラベルで get / first / firstOrFail を呼ぶと例外にします (テスト実行時は自動で strict)。エラーには attach 済みのラベル一覧が付くので、打ち間違えはその場で分かります。
「0 件の経路」を本当にテストしたいときは、空リストを明示的に attach して意図を宣言します。
// 「取得結果が 0 件」を意図して宣言する
MockEloquent mock = (new MockEloquent())
.attach(Usecase.LBL_FETCH, new List<IEntry>());
⚠️ 古いバージョンから上げると、この変更で赤くなるテストが出ることがあります。それは「attach 漏れで何も検証せずに緑だったテスト」です。機械的に空 attach を足して緑に戻すのではなく、本来そこに注入すべきだったデータは何かを確認してください。
落とし穴 5: Test.startTest / Test.stopTest の囲み忘れ
結合テストで DML や非同期処理を発火させる箇所を Test.startTest() / Test.stopTest() で囲み忘れると、ガバナ制限のカウントや非同期キューの flush 挙動が本番と食い違うことがあります。「実 DML を起こす Act の前後で囲む」と覚えます。
なぜこの戦略が長期で効くか
ここまでのテスト戦略は、一文に集約できます。
「自分が書いたロジックの責任範囲」を構造で明確化し、その範囲だけを単体テストで網羅的に検証する。範囲外はレイヤーを分けて結合テストで検証する。
この方針を支えているのは、Apex Stem のアーキテクチャ側の設計判断です。
- Handler-Usecase Architecture で
invoke()のみ public にする規約 → 単体テストから観察できる対象が「入力 → 出力 + 副作用」に構造的に絞られる - Layered Constructor Pattern で
IEloquentを DI で差し替え可能 にする設計 → プラットフォーム責務とコード責務がテスト時に明確に分離される
つまり Apex Stem は、個人の規律ではなく構造そのもの によってテスト品質を強制しています。内部実装に踏み込んだ検証は書こうとしても物理的に書けず、プラットフォーム挙動とコードロジックは DI 境界で必然的に切り分けられます。結果として、開発者が無意識に良いテストを書く構造、言い換えると 「悪いテストが書けない」構造 になっています。
この性質は、AI コーディングアシスタントと共に開発する時代に特に強く効きます。AI が自動生成するテストや、レビュー負荷の高い PR の中で「悪いテストが紛れ込む」リスクを、アーキテクチャの側で構造的に塞いでいるからです。規約を覚えてもらう必要なく、構造に従えば自然と良いテストになる、という長期保守可能性の核がここにあります。
実利の側面でも、この戦略は次の効果を生みます。
- テスト失敗時の原因切り分けが即座に可能 (判断マトリクスのパターン 2 で見たとおり)
- アドミンによる設定変更を能動的に検知できる (夜間定期の結合テスト)
- AI による自動生成にも耐える、「悪いテストが書けない」構造
- DB レスによる高速実行で、組み合わせ網羅のコストが実質ゼロ
- リファクタリング耐性の高い、長期保守可能なテストスイート
次に読む
- Handler-Usecase Architecture: テスト戦略が対応する 2 層の責務とアーキテクチャの位置づけ
- Layered Constructor Pattern: テスト時に依存を柔軟に差し替えるための設計パターン
- Apex Stem 導入ガイドのステップ 4: 動くテストコードの正典 (単体テスト + 結合テスト)