Selenium学習サイトをCodeceptJSのヘルパーAppiumでテストしてみた

#概要
[Selenium学習用サイト](https://hotel.testplanisphere.dev/ja/)にて、会員登録機能や宿泊予約機能をCodeceptJSを使ってテストしてみました。モバイル実機ではなくEmulatorです。
いくつかの宿泊プランから予約をすることになっていたり、会員登録機能を有し、 会員 / 非会員で利用できる宿泊プランが異なっているなど、Selenium練習サイトも予約システムである点は同じですが、機能的な相違から、テストするポイントやCodeceptJSで利用するメソッドが異なりますので、そのあたりを中心に。
未だCodeceptJSを知らなかった頃、このサイトについては生SeleniumとCucumberで試した経緯があり、今回、改めてCodeceptJSを使ってみたところ、

1. JavScript初心者でも、Semantic Locaterを使ってStepDefinition自作の手間が省けるので楽。
2. 今回悩んだ範囲ではありますが、CodeceptJSのライブラリとして公開されているものが使えた。
3. 生Seleniumでの経験も役に立った。

その点で、テストコードを書くより、テストとして十分かどうかの考察に注力できたのが大きいかと思います。
テスト対象となる機能としては、

* 合計宿泊料金は勿論、検証の対象だが、他にはプランによって異なる部屋タイプも検証対象とした。
予約時と違っていたら、やっぱり嫌でしょう？
* 合計料金表示が、7,000円のようにカンマが入ったり円が付いたりしたので、期待値と数値で比較したかったため、see をそのまま使わず、CodeceptJSコミュニティで公開されていたライブラリを使った。
* 宿泊プランによって、宿泊人数と連泊数に制約があるので境界値はプランによっては変えた。
* 会員の場合、予約時の連絡先については会員登録した内容を初期値とした入力済みになるので、初期値が入ったかどうかは、検証対象とした。
* ただし、電話番号については会員登録時に任意登録のため、予約時に連絡先として電話連絡を選択した場合は改めて電話番号が入力できるか検証しました。

CodeceptJSで使った主なメソッドの内、Selenium練習サイトで既に利用済み以外では、

* switchToNextTab（宿泊プランページでプラン選択すると別TABで予約操作をするので）
* switchTo（一部のプランで、iFrame内に、部屋タイプが表示されるため）
* seeInField（テキストボックス内の初期値を検証するため）
* dontSee （会員によって利用できる宿泊プランの制限を宿泊プラン一覧画面が満たしているかの検証に使用）
* メソッドでは無いが、カスタムロケータ（hrefで移動するメソッドがCodeceptJSに無かったので）
* asserEqual を[Codeceptjs-chai](https://www.npmjs.com/package/codeceptjs-chai) から（JavaScriptにはassertが無いので）

環境は、npx codeceptjs info で、
codeceptVersion:  "3.1.1"
nodeInfo:  15.8.0
osInfo:  Windows 10 10.0.19043
cpuInfo:  (16) x64 Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz
# テストシナリオ作成
会員登録は、一般会員とプレミアム会員の両方（水準数 = 2）、また、登録時の電話番号を記入有無（水準数 = 2）登録時の住所を記入有無（水準数 = 2）の組み合わせで会員を作成（8種類）して臨むこととする。
会員登録しないで予約もできるので、ゲストユーザーとして利用するテストについても実施する。この場合は連絡先について初期値入力はされません。
また、会員登録するとマイページができるので、メールアドレス / 電話番号（任意） / 住所（任意） / 生年月日 / 性別 / お知らせの受け取り可否が登録されたものかどうか、宿泊プランのページにおいて会員のみ利用可能な宿泊プランが表示されているかを検証することにする。
今回もPictmasterによるALL Pairの組み合わせ生成でテストデータを作成しました。
生成されたデータに、選択したプランによって異なる1泊あたりの宿泊料金と部屋タイプを割り付けるためにExcelのINDEX関数を使って別Sheetのテーブル参照で実装しています。
![excel002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/16ceb746-e5b0-38a9-fbfc-39f8d738c68f.png)
ALL Pair生成されたデータの**_K_列**を使って、PlanBillTableシートの**_A_列**に**宿泊プラン名**、**_D_列**に**部屋タイプ**が記載されたテーブルを参照して部屋タイプを求めるのは こんな感じ。

```AppiumTest_PICT003.xlsx
=INDEX(PlanBillTable!$D$1:$D$11,SUMPRODUCT((PlanBillTable!$A$1:$A$11='a2'!K2)*(ROW(PlanBillTable!$A$1:$A$11))))
```
1泊あたりの宿泊料金についても同様にテーブル参照して求めています。
出来上がったExcel表は、テキストにコピペすると、タブ区切りになったので、テキストエディタで、\tを', 'に変換、次に
データ1行の先頭に、reserveTestTable.add(['
データ1行の末尾に、']);
を付け加えたらテストデータできあがり。
# 今回、CodeceptJSで初めてしたこと
1. 生Seleniumだと、タブ移動したりWindow移動したりする時、移動元と移動先のオブジェクトを一度取得しておいて、切替えてましたので、switchToNextTab とかだけでできてしまうのは楽ちんでしたね。生SeleniumをJavaで扱った場合、

```
    public void setWindow() {
        Set<String> set = webDriver.getWindowHandles();
        java.util.Iterator<String> it = set.iterator();
        window1 = it.next();
        window2 = it.next();
        webDriver.switchTo().window(window1);
    }

    public void setChild() {
    	webDriver.switchTo().window(window2);
    }

    public void setParent() {
    	webDriver.switchTo().window(window1);
    }
```
こんなことしてメソッド自作してたのが、バンドルされてます。
2. CodeceptJSでは、CSSまたはxPathによるWeb Elementへのアクセスを奨めているように見えましたが、予約プランは、会員と非会員とではCSSとxPathが同じモノを指定できなく、且つ、クリックしたいボタン名も「このプランで予約する」と全部同じなので、CucumberではこんなStepDefinitionを記述していたのが、

```
    @もし("宿泊プランを\"([^\"]*)\"にして$")
    public void planSelect(String plan) {
    	String commandLocater;
    	switch(plan) {
    	case("お得な特典付きプラン"):
    		commandLocater = "./reserve.html?plan-id=0";
    		connector.clickHrefAndWait(commandLocater);
    		reserveType = "お得な特典付きプラン";
    		break;
    	case("プレミアムプラン"):
    		commandLocater = "./reserve.html?plan-id=1";
    		connector.clickHrefAndWait(commandLocater);
    		reserveType = "プレミアムプラン";
    		break;
    	case("ディナー付きプラン"):
    		commandLocater = "./reserve.html?plan-id=2";
    		connector.clickHrefAndWait(commandLocater);
    		reserveType = "ディナー付きプラン";
    		break;

```
I.click({href: .hoge.html}) みたいにしてもダメだったので、カスタム定義することになりました。
定義したカスタムロケータを使って、以下のようにできました。（抜粋）

```steps_file.js
      clickPlan: function(plan){
            var selector;
            switch (plan){
            case 'お得な特典付きプラン':
                this.click(locate('a').withAttr({href: './reserve.html?plan-id=0'}));
                break;
            case '素泊まり':
                this.click(locate('a').withAttr({href: './reserve.html?plan-id=4'}));
                break;
  
```
Anchorがカスタム定義を要するのは意外でした。
非会員と会員とでは、例えば同じ「素泊まり」プランへのリンクボタンが、xPathやCSSで

|会員・非会員|CSS|xPath|
|:---|:---|:--|
|非会員|#plan-list > div:nth-child(1) > div > div > a|//*[@id="plan-list"]/div[1]/div/div/a|
|プレミアム会員|#plan-list > div:nth-child(4) > div > div > a|//*[@id="plan-list"]/div[4]/div/div/a|
となり、異なるので、hrefで移動したいところですが、カスタム定義が必要でした。
3. seeだとチョット足りてない検証を、コミュニティで公開しているライブラリを使って実装しました。
see で大概の画面表示の検証は事足りると思いますが、どうしてもassert使いたかったので、どうしたものかと悩んでいたところ、CodeceptJSのコミュニティにズバリありました。導入はプロジェクトのフォルダで、

```
npm install codeceptjs-chai
```
でインストールし、codecept.conf.jsのhelper以下に書き加えます。

```codecept.conf.js
,
    "ChaiWrapper" : {
           "require": "codeceptjs-chai"
         }
```
既にAppiumがhelperに書かれているので、カンマ「,」忘れずにです。
検証メソッドはsteps_file.js に足しました。「円」とか「税込み」「合計」とかを取り除いて、期待値と数字で比較したかったからです。

```steps_file.js
      seeBill: function(bill, billString){
            var ret;
            billString = billString.replace(',', '');
            billString = billString.replace('円', '');
            billString = billString.replace('（税込み）', '');
            billString = billString.replace('合計', '');
            billString = billString.trim();
            this.assertEqual(bill, billString);
        }
```
assertEqual を codeceptjs-chai ライブラリから使いました。
CodeceptJS利用者が増えて、こういったチョット欲しいのが公開されていると、初心者としてはとても助かります。

### 出来上がったテストシナリオ
以下はシナリオ部分だけですが、結構な量になりました。
条件分岐もそれなりに記述ありで、「もし」と「ならば」だけでは語り尽くせないシナリオではありますが、その分、StepDefinitionの記述は、大きく省けました。

```ReserveByMemberNexus4.js
Feature('Member予約機能_By_Nexus4');

Data(reserveTestTable).Scenario('Member予約_部屋タイプ確認_料金確認_By_Nexus4', async({I , current}) => {
    I.amOnPage('https://hotel.testplanisphere.dev/ja/');
    if(current.向き == '縦向き'){
        I.setOrientation('PORTRAIT');
    }
    if(current.向き == '横向き'){
        I.setOrientation('LANDSCAPE');
    }
//会員登録
    if(current.向き == '縦向き'){
        I.click('body > nav > button');
    }
    I.waitForClickable('#navbarNav > ul > li:nth-child(3) > a');
    I.click('会員登録');
    I.fillField('email', current.メルアド);
    I.fillField('password', current.パスワード);
    I.fillField('password-confirmation', current.パスワード);
    I.fillField('username', current.氏名);
    if(current.ランク == 'プレミアム会員'){
        I.click('input[name="rank"]');
    }
    if(current.ランク == '一般会員'){
        I.click('#rank-normal');
    }
    if((current.住所).length != 0){
        I.fillField('address', current.住所)
    }
    if((current.電話).length != 0){
        I.fillField('tel', current.電話);
    }
    if(current.性別 == '男性'){
        I.selectOption('gender', '男性');
    }
    if(current.性別 == '女性'){
        I.selectOption('gender', '女性');
    }
    if(current.性別 == 'その他'){
        I.selectOption('gender', 'その他');
    }
    if(current.性別 == '回答しない'){
        I.selectOption('gender', '回答しない');
    }
    if((current.生年月日).length != 0){
         birth = current.生年月日;
        I.executeScript(function(birth) {
//            var bDay = birth;
            var bDay = '1960-12-11';
            $(birthday).val(bDay);
        });
    }
    if(current.お知らせ == '受け取る'){
        I.checkOption('notification');
    }
    I.click('登録');

//会員で予約
    if(current.向き == '縦向き'){
        I.click('body > nav > button');
    }
    I.waitForClickable('#navbarNav > ul > li:nth-child(3) > a');
    I.click('宿泊予約');
    I.clickPlan(current.宿泊プラン);
    I.switchToNextTab(1);
    I.see(current.宿泊プラン);
    I.fromDay(current.宿泊初日);
    I.click('閉じる');
    I.fillField('term', current.連泊数);
    I.fillField('head-count', current.宿泊人数);
    if(current.朝食 == 'on'){
        I.checkOption('#breakfast');
    }
    if(current.昼からチェックインプラン == 'on'){
        I.checkOption('#early-check-in');
    }
    if(current.お得な観光プラン == 'on'){
        I.checkOption('#sightseeing');
    }
//会員は氏名が初期値として入力される
    I.seeInField('username', current.氏名);
    I.selectOption('contact', current.確認のご連絡);
    if(current.確認のご連絡 == 'メールでのご連絡'){
        if((current.メルアド).length != 0) {
//会員はメールアドレスが登録されていたら初期値として入力済みになる
            I.seeInField('#email', current.メルアド);
        }else{
            I.fillField('email', current.メルアド);
        }
    }
    if(current.確認のご連絡 == '電話でのご連絡'){
            if((current.電話).length != 0) {
//会員は電話番号が登録されていたら初期値として入力済みになる
                I.seeInField('#tel', current.電話);
            }else{
                I.fillField('tel', current.電話番号)
            }
    }
    if(current.ご要望 == '144文字'){
        I.fillField('comment', message);
    }
    if((current.宿泊プラン == 'お得な特典付きプラン') || (current.宿泊プラン == 'プレミアムプラン') || (current.宿泊プラン == '素泊まり') || (current.宿泊プラン == '出張ビジネスプラン') || (current.宿泊プラン == 'カップル限定プラン')){
        I.switchTo('#room-info > iframe');
        I.see(current.部屋タイプ);
        I.switchTo();
    }else{
        I.see(current.部屋タイプ);
    }
    let totalBill = await I.grabTextFrom('#total-bill');
    I.seeBill(current.合計料金, totalBill);
    let term = await I.grabValueFrom('#term');
    let headcount = await I.grabValueFrom('#head-count');
    I.click('予約内容を確認する');
//確認画面
    I.see(current.宿泊プラン);
    totalBill = await I.grabTextFrom('#total-bill');
    I.seeBill(current.合計料金, totalBill);
    I.seeTerm(term);
    I.seeHeadCount(headcount);
    if(current.朝食 == 'on'){
        I.see('朝食バイキング');
    }
    if(current.昼からチェックインプラン == 'on'){
        I.see('昼からチェックインプラン');
    }
    if(current.お得な観光プラン == 'on') {
        I.see('お得な観光プラン');
    }
    I.see(current.氏名);
    if(current.確認のご連絡 == '希望しない'){
        I.see('希望しない');
    }
    if(current.確認のご連絡 == 'メールでのご連絡'){
        I.see(current.メルアド);
    }
    if(current.確認のご連絡 == '電話でのご連絡'){
        I.see(current.電話番号);
    }
    if(current.ご要望 == '144文字'){
        I.see(message);
    }
    I.click('この内容で予約する');

//予約完了画面
    I.see('予約を完了しました');
    I.see('ご来館、心よりお待ちしております。');
    I.click('閉じる');
});
```
# テスト結果
機種毎にプロジェクトを設置しているので、PowerShellで一気にテストし、Allureのデータを書きだして最後に表示させます。
![testResult007.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/b86762a1-7e6d-5422-cf82-699d8bde3af6.png)
steps_file.jsに記述したカスタムステップやカスタムロケータ、codeceptjs-chaiから利用したassertとかで、無理矢理の日本語に不思議な表現もありますが、テストレポートとしては十分だと思います。
今回はエラーデータを混入させていませんのでオール・グリーンでエラーのスクリーンショットもありませんが、画面遷移してなかったり（waitFor で待つもの間違えたとか）、テスト条件や期待値が間違っていた場合には、エラー報告していましたので、スクリーンショットは参考になります。

### その他、気づき
1. 会員登録後のマイページへの遷移時間と、ナビゲータ・ボタンを押した後のメニュー表示するのに時間がかかってしまい、エラーが時々発生したが、CodeceptJSのリトライのおかげなのか、テスト失敗になることはなかった。
2. 生年月日の入力でDatePickerが起動してカレンダーが表示されるとテキストボックス入力ができなくなり、executeScriptを使って、直接valueを設定することになったが、テストデータの生年月日をexecuteするJavaScriptに渡すことがどうしてもできなかった。（目下、調査中）
3. 宿泊初日の入力でDatePickerが表示したままだと他のエレメントの操作に被ってしまい、エラーになってしまうことがあった。
（これは、Selenium WebDriverでも経験しているが、CodeceptJSでも同様に発生）
バグじゃないのにテストが止まることにも繋がるので、この点は生Selenium同様に、注意を要す。

# 総括
まだまだCodeceptJSで触れていない部分は多いのですが、Webサイトを2つほど利用してみた感じでは、

1. CodeceptJSにバンドルされているメソッドであっさりと動かせるのは楽ちん
2. seeと、その類いで、ほとんどの検証は事足りるかもしれない
3. どうしても足りないと思った場合は、自作もできる。既に公開されているものも利用すると楽

最後に、Selenium練習サイトや学習用サイトを公開頂き、CodeceptJSを試す機会を与えてくれた

* [日本Seleniumユーザーコミュニティ](http://www.selenium.jp/)運営の皆様。
* [学習用サイト Hotel Planisphere](https://hotel.testplanisphere.dev/ja/) 管理者の皆様

ありがとうございます。