UTAM で Salesforce の E2E テストをはじめよう

[UTAM](https://utam.dev/) (UI Test Automation Model) は、Salesforce が提供する、E2E テストコードと DOM を切り離すためのフレームワークです。これまで、Salesforce (Lightning Experience) に対してナイーブに E2E テストを実装しようとすると、XPath で一生懸命 Fragile な DOM を特定して…と実装にもメンテナンスにも労力が必要でした。UTAM では、DOM を参照する再利用可能な JSON のページオブジェクトを定義し、それをコンパイルすることで生成されたメソッドを用いてコンポーネントを特定します。Salesforce 標準のコンポーネント群についてもページオブジェクトが提供されているため、それらを用いるとナビゲーションやアクションを始めとした各種標準操作も直感的に記述できます。

[公式のレシピ集](https://github.com/salesforce/utam-js-recipes)が提供されているため試してみましたが、私のような初学者には若干難しいところもありました。そこでこの記事では、UTAM を使用した簡単な E2E テストをはじめから実行できるようにするため、レシピ集の内容の一部をステップバイステップで追いかけます。

![utam.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184624/6e2e1930-4208-90cd-c868-9ab7b1afe73c.gif)


## WebdriverIO の環境構築
Salesforce のプロジェクトディレクトリに、WebDriverIO の設定を追加します。
```
$ npm init wdio@latest .
Need to install the following packages:
  create-wdio@8.4.7
Ok to proceed? (y) y
(中略)
===============================
🤖 WDIO Configuration Wizard 🧙
===============================
? A project named "salesforce-app" was detected at "/Users/*****", correct? Yes
? What type of testing would you like to do? E2E Testing - of Web or Mobile Applications
? Where is your automation backend located? On my local machine
? Which environment you would like to automate? Web - web applications in the browser
? With which browser should we start? Chrome
? Which framework do you want to use? Mocha (https://mochajs.org/)
? Do you want to use a compiler? Babel (https://babeljs.io/)
? Do you want WebdriverIO to autogenerate some test files? Yes
? What should be the location of your spec files? /Users/*****/**/*.js
? Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)? Yes
? Where are your page objects located? /Users/*****/**/*.js
? Which reporter do you want to use? spec
? Do you want to add a plugin to your test setup? 
? Would you like to include Visual Testing to your setup? For more information see https://webdriver.io/docs/visual-testing! No
? Do you want to add a service to your test setup? 
? Do you want me to run `npm install` Yes

```
サンプルのテストファイルを実行してみましょう。以下は、UTAM の Web サイトにアクセスし、ガイドのリンクをクリックしてからスクリーンショットを保存するテストコードです。

```tests/specs/example.spec.js
describe("UTAM.dev の Web サイト", () => {
  it("Guide ページのスクリーンショットを保存", async () => {

  await browser.url('https://utam.dev/')

  const guideLink = await browser.$('=Guide') // https://webdriver.io/docs/selectors#link-text
  await guideLink.click()

  await browser.saveScreenshot('./screenshot.png')
  });
});
```

`npx wdio run ./wdio.conf.js` を実行します。うまくいくと、コンソールにレポートが表示され、ルートディレクトリにスクリーンショットが保存されるはずです。

![wdio example result](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184624/74bc9e7a-6bbb-3506-dca0-479a45f87207.png)

## Salesforce 標準の UTAM ページオブジェクトを使用する
WebdriverIO が動くようになったので、次に、Salesforce に接続し UTAM を使用したテストを記述していくための準備をします。まずは必要なモジュールをインストールします。

```
npm i --save-dev dotenv utam wdio-utam-service　salesforce-pageobjects
```

以下のファイルを作成します。=の右側の値はご自身の環境の値に置き換えてください。
```.env
SALESFORCE_USERNAME=youruser@example.com
SALESFORCE_PASSWORD=password@example.com
SALESFORCE_LOGIN_URL=https://test.salesforce.com
SALESFORCE_RECENT_ACCOUNTS_URL=https://YOURDOMAIN.lightning.force.com/lightning/o/Account/list?filterName=Recent
```

```.babelrc
{
  "presets": [
    [
      "@babel/preset-env"
    ]
  ]
}
```

また、WebDriverIO の設定ファイルに UTAM のライブラリを追加します。

```wdio.conf.js
const { UtamWdioService } = require("wdio-utam-service")
exports.config = {
// 中略
// commands. Instead, they hook themselves up into the test process.
services: [
  [
    UtamWdioService,
    {
      implicitTimeout: 0,
      injectionConfigs: [
        "salesfore-pageobjects/ui-utam-pageobjects.config.json"
      ]
    }
  ]
],
// 以下略
```

以下は、Salesforce 組織にログインして、取引先をリストビューから作成するサンプルのテストコードです。ログインユーザの言語が日本語で、設定がデフォルトの組織であれば、取引先の作成まで辿り着くはずです。

```tests/spec/example.account.js
import Login from "salesforce-pageobjects/helpers/pageObjects/login";
import ObjectHome from "salesforce-pageobjects/force/pageObjects/objectHome";
import RecordActionWrapper from 'salesforce-pageobjects/global/pageObjects/recordActionWrapper';
import RecordHome from 'salesforce-pageobjects/global/pageObjects/recordHomeTemplateDesktop';

import * as dotenv from "dotenv";
dotenv.config();

describe("テスト", () => {
  it("リストビューから取引先を新規作成", async () => {
    await browser.url(process.env.SALESFORCE_LOGIN_URL);
    // ログイン 
    const loginPage = await utam.load(Login);
    await loginPage.login(
      process.env.SALESFORCE_USERNAME,
      process.env.SALESFORCE_PASSWORD
    );
    // ログイン後にURL遷移
    await browser.url(process.env.SALESFORCE_RECENT_ACCOUNTS_URL);
    // ページが読み込まれるまで待機
    const document = utam.getCurrentDocument();
    await document.waitFor(async() => {
      const docUrl = await document.getUrl();
      return docUrl.includes(process.env.SALESFORCE_RECENT_ACCOUNTS_URL);
    })
    // リストビューの新規アクションボタンを特定しクリック
    const objectHome = await utam.load(ObjectHome);
    const listView = await objectHome.getListView();
    const listViewHeader = await listView.getHeader();
    const newAction = await listViewHeader.waitForAction("新規");
    await newAction.click();
    // モーダルが表示されるまで待機
    const recordModal = await utam.load(RecordActionWrapper);
    await recordModal.waitForVisible();
    // 取引先名を入力
    const recordForm = await recordModal.getRecordForm();
    const recordLayout = await recordForm.getRecordLayout();
    const nameField = await recordLayout.getItem(1, 2, 1); // 順にセクション番号, 行番号, 列番号。数字は1から。
    const nameInput = await nameField.getTextInput();
    await nameInput.setText('テスト');
    // 保存ボタンをクリック
    await recordForm.clickFooterButton('保存');
    // モーダルが閉じられるまで待機
    await recordModal.waitForAbsence();
    // レコード詳細画面を読み込み
    await utam.load(RecordHome);
  });
});

```
ここでは分かりやすくするために個別のテストコンディション内にログイン処理を記載していますが、実際には[レシピ集のようにまとめておく](https://github.com/salesforce/utam-js-recipes/blob/main/force-app/test/utilities/salesforce-test.js#L18-L30)のが良さそうです。ログインするところは本質的な内容ではないので、セッションId が付与された URL を `sfdx force:org:open -r` で出力してそれを用いるのも良いと思います。

テストコード内では、`salesforce-pageobjects` からページオブジェクトを import して要素を特定していることがわかります。import すべきページオブジェクトや利用できるメソッドを特定するには、[UTAM のブラウザ拡張機能](https://utam.dev/tools/browser-extension) を利用することができます。

![UTAM Browser Extension](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184624/75475a3a-75e1-8616-794f-b36419d5ee3a.png)

多少の読み替えが必要ですが、図のように、リストビューの新規アクションを特定するには、 `/force/ObjectHome` を import し、`getListView()` → `getHeader()` → `waitForAction(ラベル)` と辿っていけば良いことが分かります。


## カスタムコンポーネントのページオブジェクトを定義する
カスタムのコンポーネントを処理するためには、独自でページオブジェクトを定義する必要があります。入力された数字を 2 乗する簡単なコンポーネントのテストを書いてみましょう。以下の LWC を考えます。

```exampleCalculator.html
<template>
  <lightning-card icon-name="custom:custom14" title="テスト">
    <div class="slds-p-horizontal_small">
      <lightning-input
        type="number"
        label="数字"
        value={inputNum}
        onchange={handleChangeInputNum}
      ></lightning-input>
      <span class="result">与えられた数の2乗は {squareValue} です。</span>
    </div>
  </lightning-card>
</template>

```

```exampleCalculator.js
import { LightningElement } from 'lwc';

export default class ExampleCalculator extends LightningElement {
  inputNum = 0;

  handleChangeInputNum(event) {
    this.inputNum = event.target.value;
  } 

  get squareValue() {
    return this.inputNum * this.inputNum;
  }
}
```
```exampleCalculator.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>55.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>
```

コンポーネントをデプロイしてホームページの任意の場所に配置します。

続けて、コンポーネントフォルダ配下に、`__utam__` とい名前でフォルダを作成し、以下のページオブジェクトファイルを作成します。

```force-app/main/default/lwc/exampleCalculator/__utam__/exampleCalculator.utam.json
{
  "shadow": {
    "elements": [
      {
        "name": "numInput",
        "selector": {
          "css": "lightning-input"
        },
        "type": "salesforce-pageobjects/lightning/pageObjects/input",
        "public": true
      },
      {
        "name": "result",
        "selector": {
          "css": ".result"
        }
      }
    ]
  },
  "methods": [
    {
      "name": "getResultText",
      "compose": [
        {
          "element": "result",
          "apply": "getText",
          "returnType": "string"
        }
      ]
    }
  ]
}

```

また、`.forceignore` に `**/__utam__/**` を追加しておきましょう。

ホームページ自体を特定するために、ルートのページオブジェクトも定義します。

```force-app/main/default/flexipages/__utam__/homePage.utam.json
{
  "root": true,
  "selector": {
    "css": "body"
  },
  "elements": [
    {
      "name": "navigationBar",
      "type": "salesforce-pageobjects/global/pageObjects/appNav",
      "public": true,
      "selector": {
        "css": "one-appnav"
      }
    },
    {
      "name": "calculator",
      "selector": {
        "css": "c-example-calculator"
      },
      "type": "utam-sfdx/pageObjects/exampleCalculator",
      "public": true
    }
  ],
  "methods": [
    {
      "name": "waitForLoad",
      "compose": [
        {
          "apply": "waitFor",
          "args": [
            {
              "type": "function",
              "predicate": [
                {
                  "element": "calculator"
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

```

UTAM では CSS のセレクタを使用して要素を特定します。`"elements"` に定義した要素はコンパイル後にパブリックメソッドが生成されます。Shadow DOM 内部の要素は、`"shadow"` 配下に同じように定義しますが、各要素に `"public": true` を付与すると同じようにパブリックメソッドが生成され、Shadow DOM 内部の要素が簡単に特定できるようになります。

UTAMをコンパイルするための設定を追加します。以下のファイルを作成してください。

```utam.config.js
module.exports = {
    pageObjectsFileMask: ['force-app/**/__utam__/**/*.utam.json'], // 対象とするJSONファイルのパス
    pageObjectsOutputDir: 'pageObjects', // 出力先フォルダ
    alias: {
       'utam-sfdx/': 'salesforce-app/' // package.json の名前に置き換え
    }
};
```

UTAM がコンパイル時に現在のプロジェクトフォルダのページオブジェクトを解決できるようにするため、`package.json` に `"workspaces": ["./"]` を追加して、`npm install` し直します。 

続いて、`"scripts"` に `"utam": "utam -c utam.config.js"` を追加します。コマンド `npm run utam` を実行しページオブジェクトをコンパイルします。

`pageObjects` フォルダにホームページや `exampleCalculator` を扱うための JavaScript ファイルが生成されるはずです。これを用いてテストコードを実装してみましょう。

```.env
(中略)
SALESFORCE_HOME_URL==https://YOURDOMAIN.lightning.force.com/lightning/page/home
```

```tests/spec/calculator.spec.js
import Login from "salesforce-pageobjects/helpers/pageObjects/login";
import HomePage from "../../pageObjects/homePage";

import * as dotenv from "dotenv";
dotenv.config();

describe("テスト", () => {
  beforeAll(()=> {
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000;
  });

  it("c-example-calculator", async () => {
    // 前述のサンプルと同じログイン処理を呼び出す
    // ログイン後にURL遷移
    await browser.url(process.env.SALESFORCE_HOME_URL);
    // ページが読み込まれるまで待機
    const document = utam.getCurrentDocument();
    await document.waitFor(async () => {
      const docUrl = await document.getUrl();
      return docUrl.includes(process.env.SALESFORCE_HOME_URL);
    });
    // ホームページの読み込み
    const /** @type {HomePage} */ homePage = await utam.load(HomePage); // "root": true のページオブジェクトでないと load できないので注意。
    await homePage.waitForLoad(); // カスタムコンポーネントの読み込み完了まで待機
    // カスタムコンポーネントの特定と操作
    const calculator = await homePage.getCalculator();
    const numInput = await calculator.getNumInput();
    await numInput.setText(2023);
    const resultText = await calculator.getResultText();
    expect(resultText).toBe('与えられた数の2乗は 4092529 です。');
  });
});

```

はじめに、`utam.load()` を用いてページを読み込みます。ここで引数とするページオブジェクトは `"root": true` が設定されている必要があります。ホームページのページオブジェクトの構成要素 (elements) に、`calculator` という名前で `<c-example-calculator>` を含めています。これがコンパイルされると `getCalculator()` というメソッドで、テストコード内で `<c-example-calculator>` を特定するために利用できるようになります。

カスタムコンポーネントのページオブジェクトには、`numInput` という名前で `<lightning-input>` を含めているため、ホームページと同様に `getInput()` というメソッドが生成され、これでテキストボックスにアクセスできます。特定した要素に対しては、`setText()` (やボタンであれば `click()` など) のような、基本的な操作を行うためのメソッドが提供されています。全量は、[ガイドの Base Actions](https://utam.dev/grammar/spec#basic-actions) を参照してください。 

要素を特定するための情報はページオブジェクト側で定義されているため、テストコード内ではそれを意識する必要がなく、すっきりとした実装になりますね。これまで取り上げたようなシンプルなテストだとあまり旨味がないように思えるかもしれませんが、ページがより複雑になると、このように再利用性の高い形でテストコードを実装できることは大きな効果があるのではないかと思います。

## おわりに
ここまで一通り試すと、[レシピ集の内容](https://github.com/salesforce/utam-js-recipes/tree/main/force-app/test) や [E-Bikes の検索画面のテスト](https://github.com/trailheadapps/ebikes-lwc/blob/main/force-app/test/utam/page-explorer.spec.js)が読み始められるようになっているはずです。この記事が私のように UTAM を触ってみようと思っている人の手助けになれば幸いです。