😺

Salesforce Apex コード設計(仮)

2022/01/31に公開

はじめに

前に考えた「Salesforce Apex 共通ロジックのまとめ方」を深堀しました。

現場でこのコード設計を試してないです。
このコード設計を実践した後で内容をアップデートしたいです。
(Salesforce案件に入る予定はいまのところないです...。)

目的

このコード設計は下記が目的です。

  • コードの共通化により保守性を向上する
  • 一貫性のあるコードにより可読性が向上する
  • Salesforceらしいシンプルなコード※によりキャッチアップにかかる時間を向上する
  • Apex自動テストを「ユニットテスト」「インテグレーションテスト」に分割する
    • 「テストのピラミッド」のテストケース数のバランスを取り、生産性と信頼性とを向上する

※シンプルなコードの意味

  • 開発者ガイドのサンプルコードから大きく離れないようにする
  • 入力フォームなどにdtoを定義しないでSObjectを使用するスタイルをなるべく維持する

ビジネスロジッククラス

共通化とテスト容易性を上昇するためにビジネスロジッククラスを導入する

バッチ・トリガー・コントローラーなどに直接ビジネスロジックを記述しない
コアとなるロジックはビジネスロジッククラスに記述する

※ビジネスロジックはデータの変換の意味とします

サンプルコード

ビジネスロジッククラスサンプル

「メール送信を行う」ユースケースのビジネスロジックの例

public class ContactBusinessLogic {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }

  // ワークフローメールアラートで送信用カスタムオブジェクトを作成
  public SendMail__c createSendMail() {
    return new SendMail__c(
      Name = contact.LastName + '様',
      Email__c = contact.Email
    );
  }

  // メールが送信可能かを判断するロジック
  public SendMailValidDto sendMailValid() {
    SendMailValidDto result = new SendMailValidDto();

    if (String.isBlank(contact.LastName)) {
      result.lastName = '入力してください';
    }
    if (String.isBlank(contact.Email)) {
      result.email = '入力してください';
    }
    if (contact.Birthdate == null) {
      result.birthdate = '入力してください';
    } else if (contact.Birthdate.addYears(CustomLabelUtil.sendMailAge) >= DatetimeWrapper.jstToday()) {
      result.birthdate = 'メール送信可能な年齢ではないです';
    }

    return result;
  }

  public class SendMailValidDto {
    public String lastName { get; set; }
    public String email { get; set; }
    public String birthdate { get; set; }

    public Boolean hasAnyError(){
      return !(String.isBlank(lastName) && String.isBlank(email) && String.isBlank(birthdate));
    }
  }
}

ビジネスロジッククラス使用側 単一レコード

ビジネスロジッククラスを使用する側が単一のレコードを扱う場合
Visualforce・LWC・RESTApiなど

public class HogeVisualforceController {

  public Contact contactForm { get; private set; }
  public String errorMessageLastName { get; set; }
  public String errorMessageEmail { get; set; }
  public String errorMessageBirthdate { get; set; }

  public HogeVisualforceController() {
    contactForm = new Contact();
  }

  public PageReference onSubmit() {
    contactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contactForm);

    // Visualforceのエラーメッセージ用の変数に変換する
    ContactBusinessLogic.SendMailValidDto sendMailValidDto  = contactBusinessLogic.sendMailValid();
    errorMessageLastName = sendMailValidDto.lastName;
    errorMessageEmail = sendMailValidDto.Email;
    errorMessageBirthdate = sendMailValidDto.Birthdate;
    if (sendMailValidDto.hasAnyError()) {
      return null;
    }
    insert contactBusinessLogic.createSendMail();
    return Page.Foo;
  }
}

ビジネスロジッククラス使用側 複数レコード

ビジネスロジッククラスを使用する側が複数のレコードを扱う場合
BatchやTriggerなど

public void execute(Database.BatchableContext bc, List<Contact> contacts){
  List<Log__c> logs = new List<Log__c>();
  List<SendMail__c> sendMails = new List<SendMail__c>();
  for(Contact contact : contacts) {
    ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
    ContactBusinessLogic.SendMailValidDto sendMailValidDto = contactBusinessLogic.sendMailValid();
    if (sendMailValidDto.hasAnyError()) {
      // TriggerやBatchのエラーメッセージ用に変換する
      // 今回はBatchでログ用のカスタムオブジェクトに変換することを想定
      logs.add(new Logs__c(Detail__c = 'lastName: ' + sendMailValidDto.lastName + ',email: ' + sendMailValidDto.email));
      continue;
    }
    sendMails.add(ContactBusinessLogic.createSendMail(););
  }
  insert logs;
  insert sendMails;
}

ルール

SObjectごとに一つのクラスを作成する

データ(SObject)にメソッドを追加するイメージ
(DCIアーキテクチャを参考にしています)

Account          ->  AccountBusinessLogic.cls
Contact          ->  ContactBusinessLogic.cls
CustomObject__c  ->  CustomObjectBusinessLogic.cls

実装すること・実装しないこと

  • 実装すること
    • データの変換・判定などのコアなビジネスロジック
  • 実装しないこと
    • データの取得(SELECT)と操作(UPDATE, DELETE, INSERT) ※例外あり

※例外

  • マスタオブジェクトをキャッシュを使用して取得する場合はSELECTを行う

単一のSObjectをコンストラクタ引数とする

public class ContactBusinessLogic() {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }
}

コンストラクタ引数を変更しない

戻り値がないメソッドは理解容易性が下がるため
ビジネスロジッククラスのメソッドから、クラス内の別のpublicメソッドを呼ぶ実装を避けるため

NGの例

// 使用する側
ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
contactBusinessLogic.setRichName();

public class ContactBusinessLogic {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }

  public Void setRichName() {
    // コンストラクタ引数の変更はNG
    contact.RichName__c = contact.LastName + '様';
  }
}

OKの例

// 使用する側
ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
contact.RichName__c = contactBusinessLogic.getRichName();

public class ContactBusinessLogic {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }

  public String getRichName() {
    return contact.LastName + '様';
  }
}

引数

SObjectと関係がない値

引数で渡す

// 使用する側
ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
System.debug(contactBusinessLogic.getRichName('さん'));

public class ContactBusinessLogic {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }

  public String getRichName(String suffix) {
    return contact.Name + suffix;
  }
}

SObjectの親の項目の値

コンストラクタ引数として渡すインスタンスに設定する

1.クエリ取得の場合

Contact contact = [SELECT Id, Account.NumberOfEmployees FROM Contact];
ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
contact.RichName__c = contactBusinessLogic.getRichName();

2.SObject変数に設定する場合

contact.Account = new Account(NumberOfEmployees = 200);
ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);
contact.RichName__c = contactBusinessLogic.getRichName();
public class ContactBusinessLogic() {
  private Contact contact;

  public ContactBusinessLogic(Contact contact) {
    this.contact = contact;
  }

  public String getRichName() {
    return contact.Name + contact.Account.NumberOfEmployees > 100 ? '様' : 'さん';
  }
}

SObjectの子の項目の値

引数で渡す
soqlで一度に取得する場合と、別々で取得する場合に対応するため

// 1.クエリで同時に取得する場合
Account account = [SELECT Id, Name, (SELECT Id, LastName FROM Contacts) FROM Account LIMIT 1];
AccountBusinessLogic AccountBusinessLogic = new AccountBusinessLogic(account);
accountBusinessLogic.isImportant(account.Contacts);
// 2.別々に取得する場合
List<Contact> contacts = [SELECT Id, LastName FROM Contact];
AccountBusinessLogic AccountBusinessLogic = new AccountBusinessLogic(account);
accountBusinessLogic.isImportant(contacts);
public class AccountBusinessLogic() {
  private Account account;

  public AccountBusinessLogic(Account account) {
    this.account = account;
  }

  public Boolean isImportant(List<Contact> contacts) {
    for(Contact contacts : contacts) {
      if (contact.LastName == 'hello') {
        return true;
      }
    }
    return false;
  }
}

Apex自動テスト

ユニットテストとインテグレーションテストに分割して行う
単なるテストコードから動作するドキュメントを目指す

ユニットテスト

ビジネスロジッククラスに対して行うコード単体に対するテスト
ビジネスロジッククラスのテストで依存するSalesforceのリソースは「SObjectのメタデータ」「カスタム表示ラベル(変換せずに文字列で使用するもの)」とする
依存するSalesforceリソースを減らし、壊れにくいユニットテストにする

単純なユーティリティメソッドもユニットテストとする
(例:日付を引数に渡して'月','火','水'..を返すメソッドなど)

テストコード

ビジネスロジッククラスサンプルのテストコード

※StubProviderはそのままでは使いづらいため、amossを使用する
https://github.com/bobalicious/amoss

@isTest
public class ContactBusinessLogicUnitTest {
  // sendMailValid メールが送信可能であること
  @isTest public static void sendMailValid_shouldBeSendMail() {
    // 各項目に値が入力がありと18才の誕生日となる前提条件で
    CustomLabelUtil.customLabelUtilImpl = (CustomLabelUtilImpl)(
      new Amoss_Instance(CustomLabelUtilImpl.class)
        .when('sendMailAge')
          .willReturn(18)
        .also().getDouble()
    );
    DatetimeWrapper.datetimeWrapperImpl = (DatetimeWrapperImpl)(
      new Amoss_Instance(DatetimeWrapperImpl.class)
        .when('jstToday')
          .willReturn(Date.valueOf('2018-01-01'))
        .also().getDouble()
    );

    Contact contact = new Contact(LastName = 'TestName', Email = 'hoge@example.com', Birthdate = Date.valueOf('2000-01-01'));
    ContactBusinessLogic contactBusinessLogic = new ContactBusinessLogic(contact);

    // メール送信可能かを判定した場合は
    Test.startTest();
    ContactBusinessLogic.SendMailValidDto result = contactBusinessLogic.sendMailValid();
    Test.stopTest();

    // メール送信可能であること
    System.assertEquals(false, result.hasAnyError());
  }
}

ファイル名

{元のクラス名}UniTest.clsとする

インテグレーションテスト

BatchやTriggerなどのクラスに対するテスト
一般的なApex自動テストと同じです

このテストはコードが依存するSalesforceのリソースすべてに依存したテストを行う
(現在日時はスタブで固定する)

テストコード

ビジネスロジッククラス使用側 単一レコードのテストコード

@isTest
public class HogeVisualforceControllerIntegrationTest {
  // onSubmit メール送信カスタムオブジェクトが作成すること
  @isTest public static void onSubmit_shouldBeSendMail() {
    // 画面上で項目を入力し18才の誕生日の前提条件で
    DatetimeWrapper.datetimeWrapperImpl = (DatetimeWrapperImpl)(
      new Amoss_Instance(DatetimeWrapperImpl.class)
        .when('jstToday')
          .willReturn(Date.valueOf('2018-01-01'))
        .also().getDouble()
    );
    Test.setCurrentPage(Page.Hoge);
    HogeVisualforceController hogeVisualforceController = new HogeVisualforceController();
    hogeVisualforceController.contactForm.LastName = 'TestName';
    hogeVisualforceController.contactForm.Email = 'hoge@example.com';
    hogeVisualforceController.contactForm.Birthdate = Date.valueOf('2000-01-01');
    hogeVisualforceController.errorMessageLastName = '以前のエラーメッセージ';
    hogeVisualforceController.errorMessageEmail = '以前のエラーメッセージ';
    hogeVisualforceController.errorMessageBirthdate = '以前のエラーメッセージ';

    User standardUser = new User(
      Alias = 'standt',
      Email='standard@example.com',
      EmailEncodingKey='UTF-8',
      LastName='Testing',
      LanguageLocaleKey='ja',
      LocaleSidKey='ja_JP',
      ProfileId = ProfileUtil.standardProfile.Id,
      TimeZoneSidKey='Asia/Tokyo',
      UserName= 'standard' + DateTime.now().getTime() + '@example.com'
    );

    // Submitボタンを押下した場合は
    PageReference result;
    Test.startTest();
    System.runAs(standardUser) {
      result = hogeVisualforceController.onSubmit();
    }
    Test.stopTest();

    // Foo画面に遷移しメール送信カスタムオブジェクトが作成すること
    System.assertEquals(Page.Foo.getUrl(), result.getUrl());
    System.assertEquals(1, [SELECT Id FROM SendMail__c].size());
    System.assertEquals('', hogeVisualforceController.errorMessageLastName);
    System.assertEquals('', hogeVisualforceController.errorMessageEmail);
    System.assertEquals('', hogeVisualforceController.errorMessageBirthDate);
  }
}

ファイル名

{元のクラス名}IntegrationTest.clsとする

ルール

コメント

Apexはテストの機能が乏しい

  • JUnitの@nestedなどのネストする機能はない
  • 日本語でテストメソッドを定義できない

機能がないため理解しやすいコメントを書くことにより理解容易性を高める

  • テストメソッド内にコメントでgiven(~前提条件で),when(~の場合は),then(~こと)を記述する
  • テストメソッド自身にgiven,when,thenを要約したコメントを記述する

テストケース

各テストケースのコメントを読むことで仕様が理解可能になるように複数のテストケースを作成する

このようにテストケースを作成した結果、ガバレッジが100%に近いこと
(テストが難しい場合などは100%を目標としない)

テストケースの粒度

メソッドの詳細に対してテストを実施しない
メソッドの挙動(振る舞い)に対してテストを実施する

学習

ユニットテストとインテグレーションテストの実装前に下記を学習する
(自動テストのスキルがない状態で実装を行うのは難しいため)

テストコードのリファクタリングが目指すもの
https://dxd2021.cto-a.org/program/time-table/a-1

Googleのソフトウェアエンジニアリングの12章を読む
https://www.oreilly.co.jp/books/9784873119656/

Salesforceリソースにアクセスするクラス

Describe

Apex標準のdescribeをより扱いやすくすることを目的としたクラス

  • ビジネスロジッククラスから参照する
  • ユニットテストでスタブ使用しない
  • インテグレーションテストでスタブ使用しない

レコードタイプ

取引先にemployeeとpartnerの2つのレコードタイプがある場合

// 使用する側
System.debug(ContactDescribe.employeeRecordTypeInfo.getRecordTypeId());
System.debug(ContactDescribe.partnerRecordTypeInfo.getName());

// Describeクラス
public class ContactDescribe {
  public static RecordTypeInfo employeeRecordTypeInfo { get {
    return getMapNameToRecordTypeInfo().get('employee');
  }}

  public static RecordTypeInfo partnerRecordTypeInfo { get {
    return getMapNameToRecordTypeInfo().get('partner');
  }}

  private static Map<String, RecordTypeInfo> mapNameToRecordTypeInfo;
  private static Map<String, RecordTypeInfo> getMapNameToRecordTypeInfo() {
    if (mapNameToRecordTypeInfo == null) {
      mapNameToRecordTypeInfo = Contact.SObjectType.getDescribe().getRecordTypeInfosByName();
    }
    return mapNameToRecordTypeInfo;
  }
}

選択リスト

選択リスト項目ごとにプロパティを追加する

// 使用する側
System.debug(AccountDescribe.industry.agriculture.getLabel());
System.debug(AccountDescribe.industry.apparel.getValue());

// Describeクラス
public class AccountDescribe {

  private static IndustryPickList industryPickList;
  public static IndustryPickList industry { get {
    if (industryPickList == null) {
      industryPickList = new IndustryPickList();
    }
    return industryPickList;
  }}

  public class IndustryPickList {
    private Map<String, Schema.PicklistEntry> mapValueToPicklistEntry { get; private set; }
    public Schema.PicklistEntry agriculture { get {
      return mapValueToPicklistEntry.get('Agriculture');
    }}
    public Schema.PicklistEntry apparel { get {
      return mapValueToPicklistEntry.get('Apparel');
    }}

    public IndustryPickList() {
      mapValueToPicklistEntry = new Map<String, Schema.PicklistEntry>();
      List<Schema.PicklistEntry> picklistEntries = Account.Industry.getDescribe().getPicklistValues();
      for(Schema.PicklistEntry picklistEntry : picklistEntries) {
        mapValueToPicklistEntry.put(picklistEntry.getValue(), picklistEntry);
      }
    }
  }
}

プロファイル

プロファイルにアクセスするクラス
(テストと実行ユーザの判定以外の目的で使用されることは余りないはず)

  • ビジネスロジッククラスから参照しない
  • ユニットテストでスタブ使用しない(置き換えるコードがない)
  • インテグレーションテストでスタブ使用しない

コード

// 使用する側
public class UserInfoUtil {
  public static Boolean isStandardUser() {
    return UserInfo.getProfileId() == ProfileUtil.standardProfile.Id;
  }
}

// 実装
public with sharing class ProfileUtil {

  public static Profile adminProfile { get {
    return getMapNameToProifile().get('システム管理者');
  }}
  public static Profile standardProfile { get {
    return getMapNameToProifile().get('標準ユーザ');
  }}

  @TestVisible private static Map<String, Profile> mapNameToProifile;
  private static Map<String, Profile> getMapNameToProifile() {
    if (mapNameToProifile == null) {
      mapNameToProifile = new Map<String, Profile>();
      List<String> names = new List<String> {'システム管理者', '標準ユーザ'};
      List<Profile> profiles = [SELECT Id, Name FROM Profile WHERE Name IN :names];
      for(Profile profile : profiles) {
        mapNameToProifile.put(profile.Name, profile);
      }
    }
    return mapNameToProifile;
  }
}

カスタム表示ラベル

String型から他の型に変換が必要なカスタム表示ラベルを管理するクラス
変換せずにString型として扱うカスタム表示ラベルは管理しない

staticクラスと実装クラスと分離する
(staticメソッドはStubProviderを使用不可のため)

  • ビジネスロジッククラスから参照する
  • ユニットテストでスタブ使用する
  • インテグレーションテストでスタブ使用しない

コード

// 使用する側
System.debug(CustomLabelUtil.sendMailAge);
System.debug(CustomLabelUtil.mapNumberToItem);

// staticクラス
public class CustomLabelUtil {
  @TestVisible private static CustomLabelUtilImpl customLabelUtilImpl = new CustomLabelUtilImpl();

  public static Integer sendMailAge { get {
    return customLabelUtilImpl.sendMailAge();
  }}
  public static Map<String, String> mapNumberToItem { get {
    return customLabelUtilImpl.mapNumberToItem();
  }}
}

// 実装クラス
public class CustomLabelUtilImpl {
  public Integer sendMailAge() {
    // sendMailAge「10」「18」などの数値の文字列
    return Integer.valueOf(System.Label.sendMailAge);
  }

  // 変換処理が重いようならばFlyweightを使用する(基準はあいまい)
  private Map<String, String> mapNumberToItem;
  public Map<String, String> mapNumberToItem() {
    if (mapNumberToItem == null) {
      mapNumberToItem = new Map<String, String>();
      // numberToItemは「{"1": "foo", "2": "bar"}」などのJSON文字列
      Map<String, Object> mapKeyToValue = (Map<String, Object>)JSON.deserializeUntyped(System.Label.numberToItem);
      for(String key : mapKeyToValue.keySet()) {
        mapNumberToItem.put(key, (String)mapKeyToValue.get(key));
      }
    }
    return mapNumberToItem;
  }
}

スタブ使用例

@isTest
public class HogeTest {
  @isTest public static void foo() {
    // 依存するリソースへのアクセスをモックで上書きする
    CustomLabelUtil.customLabelUtilImpl = (CustomLabelUtilImpl)(
      new Amoss_Instance(CustomLabelUtilImpl.class)
        .when('sendMailAge')
          .willReturn(5)
        .also().when('mapNumberToItem')
          .willReturn(new Map<String, String> {'1' => 'fooA', '2' => 'barB'})
        .also().getDouble()
    );
    // act
    // assert
  }
}

現在日時取得

現在日時を取得するメソッドをラップしたクラス
テスト時に固定の値とする事が目的

staticクラスと実装クラスと分離する
(staticメソッドはStubProviderを使用不可のため)

  • ビジネスロジッククラスから参照する
  • ユニットテストでスタブ使用する
  • インテグレーションテストでスタブ使用する

コード

※Time型の戻り値はモック作成時にエラーになるためコメントアウト
amossではなくApex自身(Test.createStub)の不具合)と思われる)
問い合わせが可能な本番組織がないため、どなたかケースを投げてほしい

// 使用する側
System.debug(DatetimeWrapper.gmtNow());
System.debug(DatetimeWrapper.jstNow());
System.debug(DatetimeWrapper.jstToDay());

// staticクラス
public class DatetimeWrapper {
  @TestVisible private static DatetimeWrapperImpl datetimeWrapperImpl = new DatetimeWrapperImpl();

  public static Datetime gmtNow() {
    return datetimeWrapperImpl.gmtNow();
  }
  public static Date gmtToDay() {
    return datetimeWrapperImpl.gmtToDay();
  }
  // public static Time gmtTime() {
  //   return datetimeWrapperImpl.gmtTime();
  // }

  public static Datetime jstNow() {
    return datetimeWrapperImpl.jstNow();
  }
  public static Date jstToDay() {
    return datetimeWrapperImpl.jstToDay();
  }
  // public static Time jstTime() {
  //   return datetimeWrapperImpl.jstTime();
  // }
}

// 実装クラス
public class DatetimeWrapperImpl {

  public Datetime gmtNow() {
    return Datetime.now();
  }
  public Date gmtToDay() {
    return Datetime.now().dateGmt();
  }
  // public Time gmtTime() {
  //   return Datetime.now().timeGmt();
  // }

  public Datetime jstNow() {
    return addJstOffsetNow();
  }
  public Date jstToDay() {
    return addJstOffsetNow().dateGmt();
  }
  // public Time jstTime() {
  //   return addJstOffsetNow().timeGmt();
  // }

  private Datetime addJstOffsetNow() {
    TimeZone tz = TimeZone.getTimeZone('Asia/Tokyo');
    Datetime now = Datetime.now();
    Integer offsetMillisecond = tz.getOffset(now);
    return now.addSeconds(offsetMillisecond / 1000);
  }
}

スタブ使用例

@isTest
public class HogeTest {
  @isTest public static void foo() {
    DatetimeWrapper.datetimeWrapperImpl = (DatetimeWrapperImpl)(
      new Amoss_Instance(DatetimeWrapperImpl.class)
        .when('jstToday')
          .willReturn(Date.valueOf('2018-01-01'))
        .also().getDouble()
    );
    // act
    // assert
  }
}

トランザクションオブジェクトdao

トランザクションオブジェクトへのsoqlを実行するクラス

  • ビジネスロジッククラスから参照しない
  • ユニットテストでスタブ使用しない(置き換えるコードがない)
  • インテグレーションテストでスタブ使用する(例外の発生のみ)

共通化の単位

soqlの共通化はコンテキスト(状況)の単位で行う
SObjectの単位で共通化はしない

実行するコンテキストごとに「取得項目」「条件」「件数」などのデータ取得の目的は異なります
異なるコンテキストでsoqlを共通化すると「if文」「引数」「動的soql」などが増えて複雑なコードになる

コンテキストの粒度は決まっていません

NGの例

// SObjectへのsoqlを全てまとめたクラス
AccountDao.cls
ContactDao.cls

OKの例

// 予約用のRestAPIで使用するdao
RestApiReserveDao.cls
// エクスペリエンスクラウドで「with out sharing」を使用するdao
ExceperienceCloudWithOutSharingDao.cls

共通化の方針

  • 1.最初は共通化をしないことを選択する
    • BatchやVisualforceのコントローラークラスにsoqlを直接書く
  • 2.下記条件を満たしたときにdaoを作成しsoqlを移動する
    • 同じコンテキストでsoqlが重複した
    • with out sharingのクエリを使用する
    • インテグレーションテストで例外を発生させたい

コード

インスタンスメソッドで実装する

// 使用する側
public class ReservationApi {
  @TestVisible private RestApiReserveDao restApiReserveDao = new RestApiReserveDao();

  public void bar(Id accountId) {
    List<Account> accounts = restApiReserveDao.findByAccountId(accountId);
    if (accounts.isEmpty()) {
      // something...
    }
    // something...
  }
}

public with sharing class RestApiReserveDao {
  public List<Account> findByAccountId(String accountId) {
    List<Account> accounts = [SELECT Id FROM Account WHERE id = :accountId];
    return accounts;
  }
}

スタブ使用例

例外を発生する場合のみスタブで置き換える

※運用ミス以外で例外が発生しない場合は、「try catch」「スタブでの例外発生」などの不要なコードは書かない
 他には、Batchの場合はプラットフォームイベントを使用して例外を補足するなどベストな設計を考慮する

@isTest
public with sharing class ReservationApiTest {
  @isTest public static void bar_shouldBeException() {
    ReservationApi reservationApi = new ReservationApi();
    ReservationApi.restApiReserveDao = (RestApiReserveDao)(
      new Amoss_Instance(RestApiReserveDao.class)
        .when('findByAccountId')
          .willThrow(new IllegalArgumentException())
        .also().getDouble()
    );
    // act
    // assert
  }
}

マスタオブジェクトdao

マスタオブジェクトへのsoqlを実行するクラス

方針

SObjectの単位で共通化する

実装方法

  • 全取得キャッシュパターン
    • プラットフォームキャッシュを使用する
    • ビジネスロジッククラス内で使用(クエリ)する
    • レコードの全件をマスタデータをキャッシュに保存する
  • 部分キャッシュパターン
    • プラットフォームキャッシュを使用する
    • ビジネスロジッククラス内で使用(クエリ)する
    • レコードの1件をマスタデータをキャッシュに保存する
  • 通常パターン
    • ビジネスロジッククラス外で使用(クエリ)する
    • ビジネスロジッククラスのメソッドに引数でマスタデータを渡す

実装方法の選択

  • 全取得キャッシュパターンを選択する場合
    • マスタデータの追加・変更・削除時に「キャッシュ削除する」運用を追加することに問題がない場合
    • BatchやTriggerなどの複数レコードを処理時にキャッシュにヒットしない場合にガバナ制限を超えない場合
    • マスタデータの全件がキャッシュの最大サイズに収まり、全件をsoqlで取得可能な場合
  • 部分キャッシュパターンを選択する場合
    • 全取得キャッシュパターンの以下を満たせない場合
      • マスタデータの全件がキャッシュの最大サイズに収まり、全件をsoqlで取得可能な場合
  • 通常パターンを選択する場合
    • キャッシュパターンを選択する場合に当てはまらない場合

ビジネスロジッククラス内でマスタデータを取得するとコードの見通しが良くなるので可能であればキャッシュパターンにしたい

マスタオブジェクトの例

都道府県マスタ(Prefecture__c)の例

Code__c Name
01 北海道
02 青森県
03 岩手県

通常パターン

  • ビジネスロジッククラスから参照しない
  • ユニットテストでスタブ使用しない(置き換えるコードがない)
  • インテグレーションテストでスタブ使用する(例外の発生のみ)

コード

// 使用する側
System.debug(PrefectureDao.findAll());
System.debug(PrefectureDao.findByCode('01'));

// staticクラス
public with sharing class PrefectureDao {
  @TestVisible private static PrefectureDaoImpl prefectureDaoImpl = new PrefectureDaoImpl();
  public static List<Prefecture__c> findAll() {
    return prefectureDaoImpl.findAll();
  }

  public static Prefecture__c findByCode(String code) {
    return prefectureDaoImpl.findByCode(code);
  }
}

// 実装クラス
public with sharing class PrefectureDaoImpl {
  public List<Prefecture__c> findAll() {
    return [SELECT Id, Name, Code__c FROM Prefecture__c];
  }

  public Prefecture__c findByCode(String code) {
    return [SELECT Id, Name, Code__c FROM Prefecture__c WHERE Code__c = :code LIMIT 1];
  }
}

全取得キャッシュパターン

  • ビジネスロジッククラスから参照する
  • ユニットテストでスタブ使用する
  • インテグレーションテストでスタブ使用する(例外の発生のみ)

コード

// 使用する側
System.debug(PrefectureDao.findAll());
System.debug(PrefectureDao.findByCode('01'));

// staticクラス
public with sharing class PrefectureCacheDao {
  @TestVisible private static PrefectureCacheDaoImpl prefectureCacheDaoImpl = new PrefectureCacheDaoImpl();
  public static List<Prefecture__c> findAll() {
    return prefectureCacheDaoImpl.findAll();
  }

  public static List<Prefecture__c> findByCode(String code) {
    return prefectureCacheDaoImpl.findByCode(code);
  }
}

// 実装クラス(一回のクエリで全件取得可能な場合)
public class PrefectureCacheDaoImpl {
  private Cache.OrgPartition orgPartition;

  public PrefectureCacheDaoImpl() {
    // MasterDataという名前のキャッシュ区分が設定してあること
    orgPartition = Cache.Org.getPartition('local.MasterData');
  }

  public List<Prefecture__c> findAll() {
    return getMapNameToPrefecture().values();
  }
  public List<Prefecture__c> findByCode(String code) {
    Map<String, Prefecture__c> mapNameToPrefecture = getMapNameToPrefecture();
    return mapNameToPrefecture.containsKey(code)
      : new List<Prefecture__c>{mapNameToPrefecture.get(code)}
      ? new List<Prefecture__c>();
  }

  private Map<String, Prefecture__c> = mapNameToPrefecture;
  private Map<String, Prefecture__c> getMapNameToPrefecture() {
    if (mapNameToPrefecture == null) {
      mapNameToPrefecture = (Map<String, Prefecture__c>)orgPartition.get(FindAllCache.class, 'all');
    }
    return mapNameToPrefecture;
  }

  public class FindAllCache implements Cache.CacheBuilder {
    public Object doLoad(String all) {
      List<Prefecture__c> prefectures = [SELECT Id, Name, Code__c FROM Prefecture__c]
      Map<String, Prefecture__c> mapNameToPrefecture = new Map<String, Prefecture__c>();
      for(Prefecture__c prefecture : prefectures) {
        mapNameToPrefecture.put(prefecture.Name, prefecture);
      }
      return mapNameToPrefecture;
    }
  }
}

部分キャッシュパターン

  • ビジネスロジッククラスから参照する
  • ユニットテストでスタブ使用する
  • インテグレーションテストでスタブ使用する(例外の発生のみ)

コード

// 使用する側
System.debug(PrefectureDao.findByCode('01'));

// staticクラス
public with sharing class PrefectureCacheDao {
  @TestVisible private static PrefectureCacheDaoImpl prefectureCacheDaoImpl = new PrefectureCacheDaoImpl();

  public static List<Prefecture__c> findByCode(String code) {
    return prefectureCacheDaoImpl.findByCode(code);
  }
}

// 実装クラス(一回のクエリで全件取得不可な場合)
public class PrefectureCacheDaoImpl {
  private Cache.OrgPartition orgPartition;

  public PrefectureCacheDaoImpl() {
    // MasterDataという名前のキャッシュ区分が設定してあること
    orgPartition = Cache.Org.getPartition('local.MasterData');
  }

  public List<Prefecture__c> findByCode(String code) {
    return (List<Prefecture__c>)orgPartition.get(FindByCodeCache.class, code);
  }

  public class FindByCodeCache implements Cache.CacheBuilder {
    public Object doLoad(String code) {
      return [SELECT Id, Name, Code__c FROM Prefecture__c WHERE Code__c = :code LIMIT 1];
    }
  }
}

スタブ使用例

@isTest
public with sharing class HogeTest {
  @isTest public static void foo() {
    PrefectureCacheDao.prefectureCacheDaoImpl = (PrefectureCacheDaoImpl)(
      new Amoss_Instance(PrefectureCacheDaoImpl.class)
        .when('findAll')
          .willReturn(new List<Prefecture__c>{
            new Prefecture__c(Name = '東京都', Code__c = '13'),
            new Prefecture__c(Name = '北海道', Code__c = '01')}
          )
        .also().when('findByCode')
          .willReturn(new List<Prefecture__c>{
            new Prefecture__c(Name = '東京都', Code__c = '13')
          })
        .also().getDouble()
    );
    // act
    // assert
  }
}

Discussion