2017.05.23

Apexテストクラスを自動生成

今回はSuPICEで行っているApexテストコードの生成についてご紹介します。



Apexコードの生成

SuPICEはそのツールの特性上、Salesforce上で定義可能な様々なデータ型の項目に対応しなければなりません。 検索機能においては、データ型 x 演算子 x 値 の組み合わせによって、 検索が実行できるケース/できないケースが存在します。 たとえば、
  • 数値系の項目で条件値が空のときに不等号で検索はできない
  • チェックボックス項目で条件値が空のときはfalse(チェック無し)として動作する
  • 「値を含む」「値を含まない」を使用できるのは選択リスト項目のみ
  • ロングテキストエリア、リッチテキストエリアは検索できない
などです。 SuPICEではそれらのパターンで検索を実行した場合についてテストする必要がありますが、 すべてのパターンを直接テストメソッドとしてコーディングすると、 同じような内容のメソッドが数百から数千もできてしまい、メンテナンスが大変になります。 そこで、node.jsからApexコードのテストメソッドを生成し、 内容がにているテストについて一括で管理できるようにしています。

gulpタスク

以下で、gulpタスクとしてSalesforceの項目定義とその定義からテストメソッドの生成・デプロイを行う コードを示していきます。

gulpからSalesforceのメタデータをデプロイする基本的な方法については、 gulpで作るSalesforce開発環境 を御覧ください。

項目定義

項目は項目定義とテスト生成の2箇所で使用されるため、別ファイルに定義して使いまわせるようにします。 fields.js
module.exports = function () {
  const types = [
    'Checkbox',
    'Currency',
    'Date',
    'DateTime',
    'Email',
    'Number',
    'Percent',
    'Phone',
    'Picklist',
    'MultiselectPicklist',
    'Text',
    'TextArea',
    'LongTextArea',
    'Url',
    'Html'
  ];
  var fields = types.map(function (type) {
    var field = {};
    field.type = type;
    field.label = type;
    field.fullName = type + '01__c';
    if (type === 'Text') {
      field.length = 255;
    }
    if (['Currency', 'Number', 'Percent'].indexOf(type) >= 0) {
      field.precision = 18;
      field.scale = 0;
    }
    if (['MultiselectPicklist', 'Html', 'LongTextArea'].indexOf(type) >= 0) {
      field.visibleLines = 10;
    }
    if (type === 'Checkbox') {
      field.defaultValue = 'false';
    }
    if (type === 'LongTextArea' || type === 'Html') {
      field.length = 32000;
    }
    if (type === 'MultiselectPicklist' || type === 'Picklist') {
      field.picklist = {
        picklistValues: [
          { fullName: '1'},
          { fullName: '2'},
          { fullName: '3'},
          { fullName: '4'},
          { fullName: '5'}
        ]
      };
    }
    return field;
  });
  return fields;
};

メタデータ

オブジェクト・(管理者への)項目権限・クラスについての生成 & デプロイが以下です。

メソッドについては複数の配列からその積集合を返す cartesian-product から生成します。

gulpfile.js
const fs = require('fs');
const gulp = require('gulp');
const zip = require('gulp-zip');
const file = require('gulp-file');
const deploy = require('gulp-jsforce-deploy');
const through = require('through2');
const metadata = require('salesforce-metadata-xml-builder');
const product = require('cartesian-product');
const meta = require('jsforce-metadata-tools');

const FIELD_LIST = require('./fields.js')();

const API_VERSION = '36.0';
const CLASS_NAME = 'TestToSOQL';
const OBJECT_NAME = 'TestObject__c';
const SF_USERNAME = process.env.SF_USERNAME;
const SF_PASSWORD = process.env.SF_PASSWORD;

const FIELDS = FIELD_LIST.map((f) => f.fullName);
const OPERATORS = [
  {name: 'eq'  , exp: '='},
  {name: 'lt'  , exp: '<'},
  {name: 'gt'  , exp: '>'},
  {name: 'le'  , exp: '<='},
  {name: 'ge'  , exp: '>='},
  {name: 'like', exp: 'LIKE'},
  {name: 'inc' , exp: 'INCLUDES'},
  {name: 'exc' , exp: 'EXCLUDES'}
];
const VALUES = [
  {name: 'number'  , exp: '1' },
  {name: 'string'  , exp: '\'1\'' },
  {name: 'null'    , exp: 'null' },
  {name: 'list'    , exp: 'new String[]{\'1\'}' },
  {name: 'bool'    , exp: 'true' },
  {name: 'date'    , exp: 'Date.today()' },
  {name: 'datetime', exp: 'DateTime.now()' }
];

function makeQueryTestClass(name) {
  const methods = product([
    FIELDS, OPERATORS, VALUES
  ]).map((args) => {
    const field = args[0];
    const op = args[1];
    const val = args[2];
    return `
    @isTest
    static void test_${field.replace('__c', '')}_${op.name}_${val.name}() {
      Object v = ${val.exp};
      SObject[] records = Database.query('SELECT Id FROM ${OBJECT_NAME} WHERE ${field} ${op.exp} :v ');
    }`;
  });
  return `
@isTest
class ${name} {
  ${methods.join('\n')}
}
`;
}

function makeObject() {
  const label = OBJECT_NAME.replace('__c', '');
  return {
    label: label,
    pluralLabel: label,
    fields: FIELD_LIST,
    nameField: {
      type: 'AutoNumber',
      label: '番号'
    },
    deploymentStatus: 'Deployed',
    sharingModel: 'ReadWrite'
  }
}

function makeProfile() {
  return {
    name: 'Admin',
    objectPermissions: [{
      allowRead: true,
      allowCreate: true,
      allowEdit: true,
      allowDelete: true,
      object: OBJECT_NAME
    }],
    fieldPermissions: FIELDS.map((f) => {
      return {field: `${OBJECT_NAME}.${f}`, readable: true, editable: true}
    })
  };
}

gulp.task('deploy', () => {
  const classbody = makeQueryTestClass(CLASS_NAME);
  const classmeta = metadata.ApexClass({ apiVersion: API_VERSION, status: 'Active' });
  const objectxml = metadata.CustomObject(makeObject());
  const profilexml = metadata.Profile(makeProfile());
  const packagexml = metadata.Package({ version: API_VERSION, types: [
    { name: 'ApexClass', members: ['*'] },
    { name: 'CustomObject', members: ['*'] }
  ]});

  through.obj()
    .pipe(file(`src/classes/${CLASS_NAME}.cls`, classbody, {src: true}))
    .pipe(file(`src/classes/${CLASS_NAME}.cls-meta.xml`, classmeta))
    .pipe(file(`src/objects/${OBJECT_NAME}.object`, objectxml))
    .pipe(file(`src/profiles/Admin.profile`, profilexml))
    .pipe(file('src/package.xml', packagexml))
    // .pipe(gulp.dest('./tmp'))
    .pipe(zip('pkg.zip'))
    .pipe(deploy({
      username: SF_USERNAME,
      password: SF_PASSWORD,
      rollbackOnError: true
    }));

});
コマンドラインから
$ gulp deploy
を実行すると、
  • テスト用のオブジェクト
  • 管理者プロファイルへの項目権限許可
  • テストクラス
が生成・デプロイされます。 実際にはSOQLを直に実行ではなくSuPICEパッケージのクエリ処理を呼び出していたり、 アサーションがあったりしますが、簡略化のために省略しています。 このコードでは、項目(15) x 演算子(8) x 条件値(7) で840のテストメソッドが生成されます。

テスト実行

デプロイしたApexテストクラスのテスト実行し、 結果をTSVとして出力するタスクです。
gulp.task('test', () => {
  const packagexml = metadata.Package({ version: API_VERSION, types: []});
  const testdeploy = through.obj((file, enc, callback) => {
    meta.deployFromZipStream(file.contents, {
      username: SF_USERNAME,
      password: SF_PASSWORD,
      testLevel: 'RunSpecifiedTests',
      runTests: [CLASS_NAME],
      pollTimeout: 180e3,
      pollInterval: 10e3
    })
      .then(function(res) {
        const testResult = res.details.runTestResult;
        function splitMethodName(methodName) {
          return methodName.split('_').slice(1);
        }
        function makeTSVLine(r, resultText) {
          const method = splitMethodName(s.methodName);
          const arr = [r.message, method[0], method[1], method[2]];
          if (r.message) { arr.push(r.message); }
          return arr.join('\t');
        }
        const successLines = testResult.successes.map((r) => makeTSVLine(r, 'success'));
        const failureLines = testResult.successes.map((r) => makeTSVLine(r, 'fail'));
        const lines = successLines.concat(failureLines);
        fs.writeFileSync('testResult.tsv', lines.join('\n'), 'utf8');
        callback(null, file);
      })
      .catch(function(err) {
        callback(err);
      });
  });
  return through.obj()
    .pipe(file('src/package.xml', packagexml, {src: true}))
    .pipe(zip('pkg.zip'))
    .pipe(testdeploy);
});
$ gulp test
を実行することで、テストを実行し、TSVファイルとして結果を出力します。

課題点

ここまでApexテストメソッドの生成について説明しましたが、 このアプローチには課題も多いです。 パラメータ毎のパターンから生成するテストは、 ありえないと分かりきったパターンでのテストが生成されるため、 Apexでテストメソッドを直接書く場合よりもメソッド数自体が増えます。 また、パターン毎のアサーションを判定するために生成処理の中で分岐を書いてしまい、 結局テスト生成処理の中でテスト対象を再実装することになりがちです。 かといって無駄な部分を削って直接Apexを書いたとしても、 元となるパラメータがこれだけあると結局メソッド数は多くなり、 メンテナンスの困難さは解決しません。 この問題をある程度軽減するために考えているのが以下の方法です。 生成する際にループしている各パラメータがその値毎にユニークな名前をもっていれば、 実行されるケースは多階層の連想配列で示すことができます。 上の例で言うと 項目名、演算子、条件値でそれぞれユニークな名前をメソッド名の生成に使用しているので、 各ケースごとの情報は
{
  Text01__c: {
    eq: {
      string: {},
      number: {},
      //
      //
      //
}
のような形式で個別のケース毎に必要な情報を表すことができます。 この連想配列のケースごとにテスト生成から除外フラグやアサーションの種類を 記述できればいいわけですが、素のオブジェクトで記述すると結局膨大な行数になります。 そこで検討しているのがtemplate stringでJSONよりも個々のケースについての記述量が少なく、 また、キーを配列で指定できる記法です。 イメージとしては以下になります。
const objexp = require('objexp');
const get = require('lodash.get');

const NUMERIC_FIELDS = ['Currency01__c', 'Number01__c', 'Percent01__c'];
const INEQUALITY_OPERATORS = ['lt', 'gt', 'le', 'ge'];
const ALL_VALUES = VALUES.map((v) => v.name);
const data = {
	INEQUALITY_OPERATORS,
	NUMERIC_FIELDS,
	ALL_VALUES
};

const IGNORE_CASES = objexp(`
	$NUMERIC_FIELDS	like					$ALL_VALUES
	Picklist01__c	$INEQUALITY_OPERATORS	$ALL_VALUES
`, {data});
//=> {
//     Currency01__c: {
//       like: {
//         number: {},
//         string: {},
//         null: {},
//         list: {},
//         bool: {},
//         date: {},
//         datetime: {}
//       }
//     }
//     Picklist01__c: {
//       lt: {
//         number: {},
//         string: {},
//         null: {},
//         list: {},
//         bool: {},
//         date: {},
//         datetime: {}
//       },
//       gt: {...},
//       le: {...},
//       ge: {...}
//     }
//   }

product([
    FIELDS, OPERATORS, VALUES
  ])
  .filter((args) => !get(IGNORE_CASES, args))// IGNORE_CASESに登録されたケースを除外
  .forEach((args) => {
    // generate apex methods
  });

まとめ

テストクラスの生成は課題も多いですが、手順が同じテストについて一元化して管理できることで 既存テストの編集が楽になったり、全体のソースコード量が減ったりとメリットも多いので、 必要に応じてこういったアプローチでテストメソッドを実装するのも良いでしょう。

ちなみに今回のソースコードは こちら においてあります。

8 件
     
  • banner
  • banner

関連する記事