Apex Stem · Data Access
v3.5.0·リリースノート

ApexEloquent

コーディングも、テスト作成も、AIが肩代わりする。最後に立ちはだかる物理の壁。それが、テストの実行時間。

要件定義は人間、その先はAIとツールが次々に肩代わりする。 だが「テストを走らせる」一手だけは、DMLという物理に縛られて速くならない。 モデルが何世代進んでも、1msも縮まない。ApexEloquent は、その壁を構造で外します。DI ひとつで。

PriceBand_T.cls — unit test · no DB
// DB に触れない単体テストIEntry opp = MockEntry.of(Opportunity.class)    .autoId(1)    .set('Amount', 50000000); MockEloquent mock = (new MockEloquent())    .attach('fetch', new List<IEntry>{ opp }); new PriceBandUsecase(mock).invoke(); Assert.areEqual('Large',    ((Opportunity) mock        .upsertedRecordsAt('update')[0]).PriceBand__c);

モックに差し込み、Usecase をそのまま呼び、 書かれた結果を取り出す。 この間、データベースは一度も動きません。 AI がテストを量産しても、走らせるコストが積み上がらないのはここが理由です。

The wall AI can't move

テストがレコードを作り、更新する。そのたびにトリガーやフロー、ロールアップが 連鎖して動きます。でも、それらはあくまでプラットフォーム上に組んだ仕組み。 本当に遅いのは、その裏で何度も走るデータベースへの読み書き、 つまりディスクへの物理的な往復です。モデルが賢くなっても、ここは 1ms も縮みません。 そして厄介なのは、テストを書くコストが ゼロに近づくほど、テストを走らせるコストだけが 唯一の律速として残ることです。

テスト数

機能が増えるほどテストが増える。AI はこれを加速させる側。

1 件あたりの重さ

必須項目や入力規則を 1 つ足すと、そのオブジェクトを触る既存全テストに課税される。

毎デプロイ組織全体

RunLocalTests は総テスト数 × デプロイ頻度で効いてくる。

3 つが掛かるので、実行時間は線形では済まず、組織の成長に対して超線形に伸びます。 ApexEloquent は DI で、この支配項であるデータベースへの往復を単体テストから丸ごと外し、 インメモリ実行に置き換えます。「少し速くなる」話ではありません。組織が育つほど実行時間を膨らませていた最大の要因が、まるごと消えます。

Fast, and not blind

DB-less tests are fast.They're usually blind, too. Not here.

速さは、たいてい盲目とセットで売られます。ApexEloquent は違う。 速さを主張する以上、その速いテストが本物のバグを捕まえることまで示します。

速さ

同じ 1 テストメソッドの
実行時間 (中央値)

DBレス (MockEloquent)

15ms

vs

実 DML

272〜1,062ms

実運用中の 2 組織で計測。単体層は DML を流さないので、 検証ルールや関連オブジェクト、フローが増えて伸びるのは実 DML 側だけです。

捕まえる力

埋めた 334 個のバグを
見つけられた割合

DBレス (MockEloquent)

83.4%

vs

実 DML

79.3%

ミューテーションテスト (コードにわざとバグを埋め、テストが気づけるか調べる手法) で計測。 速くした代わりに検出力が落ちる、ということは起きませんでした

いずれも実測ですが、規模は大きくありません (対象クラス 1 つ・各 1 ラン)。 「証明した」ではなく「測ってみたらこうだった」として読んでください。

とはいえ、数字が出ても腑に落ちない点が 2 つ残るはずです。 自分で引っかかったところなので、先に書いておきます。

「DB を捨てたテストは、本物のバグも一緒に捨てるのでは?」

本物のバグを捕まえる制約 = SELECT 句を、ApexEloquent はモック層に作り直しています。Scribe の設計図(フィールド・リレーション・alias)が モックに丸ごと強制され、SELECT していない項目へのアクセスは単体テストの時点で例外になる。 偽陽性は出荷前に死にます。 SELECT 漏れの安全網 →

「そもそも遅いなら、関係するテストだけ回せばいい(RunSpecifiedTests)のでは?」

成熟した組織では、何が「関係する」かはコードだけで決まりません。 レコードを作る・更新するたびにトリガーやフロー、ロールアップが連鎖し、影響範囲はコード+メタデータ+データが実行時に決める。だから「関係するテスト」の集合は原理的に列挙できない。 対象を絞る運用は、リスクを消すのではなく「遅さ(可視)」を「見逃し(不可視)」に付け替え、"緑" の意味をこっそり縮めているだけです。 ApexEloquent の答えは逆です。対象を絞らず全部走らせて、その時間を大きく下げる。 緑の意味を保ったまま、「全クラスがグリーン = 問題なし」と言い切れる状態を維持します。

Batteries included

テストを書くのに必要なものが、1 つのライブラリに丸ごと入っています。 クエリを組み立てる ORM から、テストデータの生成、実行のモック、Spy、失敗の注入まで。 足りないぶんを別のライブラリや自前のヘルパーで埋める必要がありません。

Query

型付きチェーンで SOQL を組み立てる Scribe。 SELECT・WHERE・並び替えに加え、親項目・子サブクエリ・多対多・集計・動的条件まで同じ書き方で。

Test data

MockEntry で、 数式・ロールアップ・自動採番といった本来書き込めない項目にも値を置けます。 親子関係も、集計結果 (AggregateResult) も同じ形で。

Execution mock

取得系 4 種と DML 4 種、外部 ID による upsert までMockEloquent が受けます。 呼び出しサイトにラベルを付ければ、1 本のモックで用途別に仕分けられます。

Spy

どのレコードが、どのラベルで保存・削除されたかを後から取り出して検証できます。 実 DML を流さずに「何が起きたか」を見られます。

Failure injection

例外を狙った 1 箇所だけに注入できます。 リトライして成功する経路も、レコード単位の部分保存失敗も、org を細工せずに再現できます。

False-positive guards

SELECT していない項目に触れば単体テストで例外。 モックの差し込み先を間違えても例外。何も検証していないのに緑、を構造で塞ぎます。

なお本番の実行は、v3 系から既定がユーザーモード(実行ユーザーの項目・オブジェクト権限を尊重) です。集計や焼き付けのように 「誰が起こしても完遂すべき処理」だけ、systemMode() で明示的に外します。

800+

ライブラリ自身のテスト

テストのためのライブラリが壊れていては話にならないので、ApexEloquent 自身も 800 を超えるテストで固めています。 加えて、受託開発の現場で 10 以上のプロジェクトに導入し、本番運用しています。 作り手が自分の仕事で毎日使い続けているライブラリです。

How it reads

ここまでの道具が、実際のコードではこう見えます。 本番コードと、その単体テスト。 同じ IEloquent が、 本番では実 SOQL を、テストではインメモリを実行します。 色のついた行が、両者が噛み合う継ぎ目です。

Data in

テストは、本番が読むラベル 'fetch' に モックレコードを差し込みます。

Data out

本番が 'update' に 書いた内容を spy から取り出し、そのまま検証します。

The only change

差し替わるのは eloquent の中身だけ (DI で注入)。 Usecase のコードは 1 行も変わりません。

PriceBandUsecase.clsProduction
Scribe scribe = Scribe.of(Opportunity.class)    .field('Name')    .field('Amount')    .whereEqual('StageName', 'Prospecting'); List<IEntry> opps = this.eloquent.label('fetch').get(scribe); for (IEntry opp : opps) {  Decimal amount = (Decimal) opp.get('Amount');  opp.put('PriceBand__c', amount >= 10000000 ? 'Large' : 'Standard');} this.eloquent.label('update').doUpdate(opps);
PriceBand_T.clsUnit test · no DB
IEntry opp = MockEntry.of(Opportunity.class)    .autoId(1)    .set('Name', 'Acme Renewal')    .set('Amount', 50000000); MockEloquent mock = (new MockEloquent())    .attach('fetch', new List<IEntry>{ opp }); new PriceBandUsecase(mock).invoke(); List<SObject> updated = mock.upsertedRecordsAt('update');Assert.areEqual('Large',    ((Opportunity) updated[0]).PriceBand__c);

Why it's one library

クエリビルダ、実行のモック、テストデータ生成。責務が違うのだから、 通常はそれぞれ別のライブラリに分けるべきものです。 それでも 1 つにまとめているのには、理由があります。

SELECT 漏れ検知が、3 点を貫通して初めて成立するからです。

宣言する

Scribe が「このクエリは何を SELECT したか」を組み立てる

運ぶ

Eloquent /MockEloquent が、その宣言を結果に載せて返す

照合する

IEntry がアクセスのたびに突き合わせ、 宣言に無い項目なら例外を投げる

鍵は 3 つめです。照合が走るのは項目にアクセスした瞬間で、 そこに自分のコードを挟めるのは、利用者が触れる型を自分が持っている場合だけです。IEntry を返しているからget() が挟まり、そこで突き合わせられます。 素の SObject を返すと、record.Industry は言語のプリミティブになり、 ライブラリのコードは 1 行も走りません。機能として作らなかったのではなく、挟まる隙間が無いのです。

保証は、利用者が触れる型を自分が持っている範囲でしか作れない。

素の SObject をそのまま返せば、既存コードや標準 API と 何も変換せずに繋がり、覚える型も増えません。ラッパーを返せば、その代わりに アクセスの瞬間へ保証をかけられます。どちらが優れているという話ではなく、 何を取るかのトレードオフです。ApexEloquent は後者を取り、 そのために宣言から照合までを 1 つに収めています。

Position in Apex Stem

ApexEloquent は Apex Stem を構成する 4 つの OSS のうち Data Access を担います。Handler-Usecase Architecture の Usecase 層で DB アクセスが関わるすべての場面に登場し、テスト戦略 の 「Usecase 単体テスト」側を引き受けます。

ApexEloquent
Data Access: SOQL / DML + モック
ApexBlueprint
Test Data Factory: 実 DML の結合テストデータ
ApexTrace
Lifecycle Logging: Usecase の経路追跡とテスト検証
ApexTools
Foundation: TriggerHandler 基底 + HTTP DI ラッパー
Related Documents

Start with the Developer Guide

Scribe でのクエリ構築、データ取得と DML、リレーション。 3 つの利用パスを実コードで辿ります。