HCL Domino Leap アプリは UI オートメーション・テストの実装の手間を減らしてくれるのか？

## この記事の目的
ローコードアプリ開発プラットフォームで作成されたアプリケーションは画面を構成する部品が自動生成されるので、その設計や部品が統一されて UI オートメーション・テストを開発するのが少しなのではないかと思いました。そこでローコードアプリ開発・実行環境として紹介されている HCL Domino Leap を題材に、UI を操作をするプログラムを書いてみました。
ここでは Domino Leap で1画面だけの簡単なアンケートアプリを作り、そのアプリの画面操作を Selenium と JavaScript で実装してみました。本来は Jasmine などのテストフレームワークを使ってテスト・シナリオの管理をしますが、ここでは UI オートメーションがどのように簡略化できるかに焦点をあてて、テスト・フレームワークの使用は割愛します。

## UI オートメーション・テストの難しさ
API によるテストのオートメーション化に比べて、UI テストのそれは格段に難しく手間がかかります。API であれば呼び出し方法と戻り値が大抵は厳密に定義されており、リクエストとレスポンスに含まれるデータの検証や比較などは JSON オブジェクトなどで簡単に行うことができます。
一方で UI は一見整った画面であっても、それを構成している HTML の構造が綺麗に構成されているとは限りません。
Selenium や Puppeteer など、UI オートメーションに使われるフレームワークでは、画面上の Web 要素を HTML タグに含まれる `id` や `class`、`name` と言った属性や、XPath といったロケーターで特定して処理を行います。人が見て綺麗に作られたアプリ画面であってもこれらの属性が付いていなかったり、重複していたり、あるいはコンポーネントや画面によってその命名規則が異なったりなどすることがあります。また画面構成のための `<div>` や `<span>` の入れ子によって複雑な XPath が必要になったりします。
そのため UI オートメーションのコードを実装する際には対象の Web 要素にどんな属性があるかを調べ、それが他の Web 要素と重複していないかを確認して個々の Web 要素のロケーターを定義する必要がありました。
しかし、ローコードあるいはノーコードの開発プラットフォームを使うことでアプリ画面が自動で生成されるので、`id` や `name` と言った HTML タグの属性の統一性が担保され、個別の Web 要素を調べなくても属性値が推測できるかもしれません。そうすると UI オートメーションを実装する手間を大きく低減できます。

## 事前準備
### HCL Domino Leap の準備
この記事では、HCL Software Sandbox において無償で提唱されている HCL Domino Leap を使用しました。[HCL Domino Leap オンライン トライアル](https://www.hcljapan.co.jp/software/products/domino-leap/hcl-domino-volt-trial/) ページで[アカウント登録（英語）](http://start.myhclsandbox.com/general.nsf/RegistrationRequest) ボタンをクリックして登録ができます。

### Node.js のインストール
JavaScript の実行環境として Node.js を[こちら](https://nodejs.org/ja)からインストールします。

### Selenium と Chrome driver の準備
私は Windows 11 でしたので、[Windows 10 + nodejs + selenium-webdriver + chromedriver で headless chrome を試してみる](https://qiita.com/tabizou/items/9d9698aa79e5d8141f1d) の内容を参照してSelenium と Chrome driver のインストールをしました。

## テスト対象の Domino Leap アプリケーション
今回用意したのは簡単なアンケート・アプリです。
アンケート・フォーム（フォームID：`F_Form1`）にページ1（ページID：`P_NewPage1`）のみが存在し、そこに以下のようなフィールドがあります。

| フィールド名 | フィールドID | タイプ | 選択肢 |
|:------ |:----- |:----- |:----- |
| 姓 | F_SingleLine_KanjiLast | 単一行エントリー | |
| 名 | F_SingleLine_KanjiFirst | 単一行エントリー | |
| 性別 | F_SelectOne_Sex | 1つ選択 | 男性、女性、その他 |
| 年齢 | F_DropDown_Age | ドロップダウン | 9歳未満、10代、20代、30代、40代、50代、60代、70歳以上 |
| 購入の理由 | F_SelectMany_Reason | 複数選択 | 価格、品質、デザイン |
| 商品の感想 | F_SelectMany_Reason | 複数行エントリー | |

![01_survery_app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2530785/52847b64-e20c-8395-71b5-73eb152f984b.png)

HCL Domino Leap でのアプリケーション作成については [HCL Domino Leap ビデオチュートリアル](https://support.hcltechsw.com/csm?id=kb_article&sysparm_article=KB0078859) で詳しく説明されています。このチュートリアルでは HCL Domino Volt という古い名前で紹介されており、画面の構成が最新版と若干異なるのでご注意ください。

今回はログイン認証を省略するために、アプリの編集画面で「アクセス」→「イニシエーター(役割)」を開き、「役割メンバー」に `匿名ユーザー`を追加します。

![02_allow_anonymous.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2530785/752248a3-8fa4-a2d0-b7c9-b6f710f8a4b5.png)


## Web 要素のロケーターの確認
Selenium からアプリ画面上の Web 要素にアクセスするためのロケーターは、Chrome ブラウザのデベロッパーツールで確認できます。デベロッパーツールの使い方については [Google Chromeデベロッパーツールの基本的な使い方をわかりやすく解説](https://willcloud.jp/knowhow/dev-tools-01/#Elements-2) に詳しい解説があります。
デベロッパーツールの `Elements` タブで Web 要素の属性を見ることができます。そこで `F_SingleLine_KanjiLast` フィールドには `id` 属性値として `F_Form1-P_NewPage1-F_SingleLine_KanjiLast-widget` が設定されていることがわかります。
![03_SingleLine_FieldID.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2530785/d184e6d9-e814-c9ae-ef9f-b2a850010b68.png)

今回試した Web 要素では基本的に `{フォームID}-{ページID}-{フィールドID}` を基本形に、要素ごとに独自の接頭語や接尾語を追加して Web 要素の ID を生成していました。これらはルールに従って Web 要素ごとにユニークな ID が自動生成されるので、UI オートメーションコードを書く際にいちいちロケーターを探さなくて済みます。

| タイプ | ロケータの対象 | フィールドIDの生成ルール |
|:------ |:----- |:----- |
| 単一行エントリー | `<input>` タグの `id` 属性 | `${formId}-${pageId}-${fieldId}-widget` |  |
| 複数行エントリー | `<input>` タグの `id` 属性 | `${formId}-${pageId}-${fieldId}-widget` |
| 1つ選択 | 個々の `<input>` タグの `name` 属性 | `group_${formId}-${pageId}-${fieldId}-widget` |
| 複数選択 | 個々の `<input>` タグの `name` 属性| `group_${formId}-${pageId}-${fieldId}-widget` |
| ドロップダウン |  `<select>` タグの `id` 属性 |  `${formId}-${pageId}-${fieldId}-widget-select` |

## オートメーションのコード実装

Web 要素ごとにロケーターの生成ルールがわかったので、その要素を操作する JavaScript の実装を関数として共通化できることがわかりました。また `単一行エントリー` と `複数行エントリー`は、どちらも同じ `id` 属性をロケーターに使えるので、入力する設定も以下のようにまとめることができます。

#### 単一行エントリーと複数行エントリー
```javascript
// 単一行エントリーと複数行エントリーへのテキスト入力
setTextField = async(formId, pageId, fieldId, text) => {
  const elementId = `${formId}-${pageId}-${fieldId}-widget`;
  await driver.findElement(By.id(elementId)).sendKeys(text);
}
```

他の Web 要素も以下のように関数として部品化します。
#### 1つ選択
```javascript
// ラジオボタンでの1つの選択
selectRadioButton = async(formId, pageId, fieldId, target) => {
  const elementId = `group_${formId}-${pageId}-${fieldId}-widget`;
  const sexRadioButtons = await driver.findElements(By.name(elementId));
  sexRadioButtons.forEach(async(e) => {
    if (target == await e.getAttribute("value")) await e.click();
  })
}
```

#### 複数選択
```javascript
// チェックボックスでの複数選択
selectCheckboxes = async(formId, pageId, fieldId, targets) => {
  const elementId = `group_${formId}-${pageId}-${fieldId}-widget`;
  const checkBoxElements = await driver.findElements(By.name(elementId));
  // 選択対象を1つづつ処理
  targets.forEach(async(t) => {
    checkBoxElements.forEach(async(e) => {
      if (t == await e.getAttribute("value")) await e.click();
    });
  });
}
```

#### ドロップダウン
```javascript
selectDropdwon = async(formId, pageId, fieldId, target) => {
  const elementId = `${formId}-${pageId}-${fieldId}-widget-select`;
  const agePulldown = await driver.findElement(By.id(elementId));
  const select = new Select(agePulldown);
  await select.selectByVisibleText(target)
}
```

### 全体のコード
これらの関数を呼び出すメイン関数を含むコード全体を以下に掲載します。

```javascript
const { Builder, By, Key, until, Select } = require("selenium-webdriver");

const APP_URL = "https://volt.myhclsandbox.com/volt-apps/landing/org/app/02669920-ce20-439c-8b67-224ee2820b8f";
const FORM_ID = "F_Form1";
const PAGE_ID = "P_NewPage1";
const FIELD_SINGLELINE_FIRST = "F_SingleLine_KanjiFirst";
const FIELD_SINGLELINE_LAST = "F_SingleLine_KanjiLast";
const FIELD_SELECTONE_SEX = "F_SelectOne_Sex";
const FIELD_DROPDOWN_AGE = "F_DropDown_Age"
const FIELD_SELECTMANY_REASON = "F_SelectMany_Reason";
const FIELD_PARAGRAPHTEXT_COMMENT = "F_Paragraphtext_Comments";

let driver;

(async () => {
  try {
    driver = await new Builder().forBrowser("chrome").build();
    
    // アンケート・アプリを開く
    await driver.get(APP_URL);

    // 単一行エントリーで姓と名の入力
    await setTextField(FORM_ID, PAGE_ID, FIELD_SINGLELINE_LAST, "山田");
    await setTextField(FORM_ID, PAGE_ID, FIELD_SINGLELINE_FIRST, "花子");

    // ラジオボタンで性別を選択
    await selectRadioButton(FORM_ID, PAGE_ID, FIELD_SELECTONE_SEX, "女性");

    // ドロップダウンで年齢を選択
    await selectDropdwon(FORM_ID, PAGE_ID, FIELD_DROPDOWN_AGE, "50代");

    // チェックボックスで購入理由を選択
    await selectCheckboxes(FORM_ID, PAGE_ID, FIELD_SELECTMANY_REASON, ["品質", "デザイン"]);

    // 複数行エントリーで感想を入力
    await setTextField(FORM_ID, PAGE_ID, FIELD_PARAGRAPHTEXT_COMMENT, "商品に満足しています。");

    // // 入力内容を目視で確認したいときは、以下のコメント以下のコメントは外してください。
    // console.log("5秒待ちます...");
    // await driver.sleep(5000);

    // 送信
    console.log("Submitting this form...")
    await submitForm();
  }
  catch(error) {
    console.error(error);
  }
  finally {
    if(driver) {
      await driver.quit();
    }
  }
})();

// 単一行エントリーと複数行エントリーへのテキスト入力
setTextField = async(formId, pageId, fieldId, text) => {
  const elementId = `${formId}-${pageId}-${fieldId}-widget`;
  await driver.findElement(By.id(elementId)).sendKeys(text);
}

// ラジオボタンでの1つの選択
selectRadioButton = async(formId, pageId, fieldId, target) => {
  const elementId = `group_${formId}-${pageId}-${fieldId}-widget`;
  const sexRadioButtons = await driver.findElements(By.name(elementId));
  sexRadioButtons.forEach(async(e) => {
    if (target == await e.getAttribute("value")) await e.click();
  })
}

// チェックボックスでの複数選択
selectCheckboxes = async(formId, pageId, fieldId, targets) => {
  const elementId = `group_${formId}-${pageId}-${fieldId}-widget`;
  const checkBoxElements = await driver.findElements(By.name(elementId));
  // 選択対象を1つづつ処理
  targets.forEach(async(t) => {
    checkBoxElements.forEach(async(e) => {
      if (t == await e.getAttribute("value")) await e.click();
    });
  });
}

// ドロップダウン・リストの選択
selectDropdwon = async(formId, pageId, fieldId, target) => {
  const elementId = `${formId}-${pageId}-${fieldId}-widget-select`;
  const agePulldown = await driver.findElement(By.id(elementId));
  const select = new Select(agePulldown);
  await select.selectByVisibleText(target)
}

// フォームの送信と送信後メッセージの確認
submitForm = async() => {
  // 「送信」ボタンをクリック
  driver.findElement(By.xpath("//*[text()=\"送信\"]")).click();
  // 送信完了メッセージを待ってテキストを取得
  const asmlocator = By.className("afterSubmitMessage");
  await driver.wait(until.elementLocated(asmlocator), 10000);
  const asmElement = await driver.findElement(asmlocator);
  await driver.wait(until.elementIsVisible(asmElement), 10000);
  const asmText = await asmElement.getText();
  console.log(`Message after submit: ${asmText}`)
}
```