SelenideによるDSL風E2E自動テスト基盤開発の実例

  • 25
    いいね
  • 0
    コメント

Selenium/Appium Advent Calendar 2016の23日目です。

はじめに

今年6月に転職をして、E2E自動テストを書くようになりました。その際に、ライブラリとしてWebDrvier+Selenideを選択し、テスト基盤の実装にとりかかりました。その際のノウハウをまとめてみようと思います。

基盤作成の目的

まず最初に、E2Eテスト基盤作成の目的を書いていきます。基盤というからにはそれをベースにして様々なテストが書かれていくわけで、これから作成されるテストコードの保守性などを大きく左右する重要な部分です。

保守性向上

E2E自動テストでよくある話ですが、UI変更によりテストが壊れることがあります。プロセスである程度防げるかもしれませんが、保守性を向上することで変更コストをできるだけ下げたいということがありました。

生産性向上

プログラムでもそうですが、基盤側をリッチにすることでその上の層の記述量を削減するという目的があります。基盤を作りこみすぎると初期コストが跳ね上がり、本来書きたいE2Eテスト量が少ない場合元がとれないことがありますが、「網羅度を上げたい」という会社の目標があったので今回この点は問題となりませんでした。

可能であれば仕様策定者が自分でテストをかけるようにする

これが今回の一番の眼目なのですが、テスト自動化担当やテスト担当者だけでなく仕様策定者自身が自動テストをかけるようになることを目的としました。
幸いにして弊社の仕様策定者は元プログラマで仕様書もRedmine Wiki上に疑似コードを書いていたので素養はあります。今時の言語でコードを書くことは難しいかもしれませんが、簡単なコードは十分かけるはずです。
そこで、できる限りユーザ目線での操作を記述することで自動テストを実装することを一番の目標としました。

やったこと

前述の目標を達成するために、以下のことを試してみました。

Page Object Pattern の導入とその拡張

PageObject PatternはE2Eテスト自動化の定番となったPatternです。ページ毎にクラスを作成しそこにページ固有のふるまいやAssertionを記述することで、ページ単位での処理を共通化するものです。
このPage Obeject Patternを一歩進め、Page Object を以下の二つの層にさらに分離しました。

  • ページ内の要素を取得するセレクタのみを記述する層。(PageBase)
  • PageBaseを継承し、ページ毎の振る舞いを記述する層。(Page)

ここでそれぞれの層の役割について説明します。

PageBase

前者のセレクタのみの層は、本当に純粋にセレクタのみをひたすら書きます。メソッドの戻り値はすべてSelenideで使われるSelenideElementか複数の場合はCollectionElementとなります。
またメソッドは原則すべてprotectedとしており、サブクラス以外からの呼び出しを行えないようにしています。

Page

PageBaseを継承したクラスで、テストクラス(シナリオ)でインスタンス化し操作するのは常にこちらとなります。一般的なPage Objectとほぼ同じ役割になります。
差異としては以下の部分があります。
* ふるまいはPageObejct だが、セレクタ記述が原則ない。
* publicメソッドのシグネチャに現れる型は原則としてJava標準のみでWebDriver/Selenide固有のものはない。

こうすることでWebDriver/Seleindeの知識がなくてもPageObjectを介して操作することが可能となります。

サンプル

簡単なサンプルとして以下のようになります。

public abstract class BasicInformationPageBase {

    protected SelenideElement code(){
        return $(byId("csCodeTR")).$("td");
    }

    protected SelenideElement name(){
        return $(".pr-client-setting tbody", 0).$(byText("法人表記名称")).parent().$("td");
    }
}
public class BasicInformationPage extends BasicInformationPageBase {

    public String getName(){
        return name().text();
    }

    public String getCode(){
        return code().text();
    }

    public void setCode(String code){
        return code().val(code);
    }
}

Baseの戻り値はSelenideElement、Pageでは逆にSelenideやSeleniumに関するものはでずに、Java標準のみとなります。テストケースからはSelenideやSeleniumの存在が隠蔽されます。また、要素の位置変更が発生した場合Baseを修正すればよく、振る舞いは一切変更がありません。Page Objectを使いつつもセレクタと振る舞いを分離しない場合よりメンテナンス性をあげるようにしています。

Selenium/Selenideの直接操作の排除

前述のPage Objectを利用する事でテストケースからSelenium/SelenideのAPIを隠蔽することができました。
open(url , class)などの最初の起動部分は若干SelenideのAPIを呼び出す事がないとはいえませんが、原則Selelium/Selenideの依存を排除します。

全面的な日本語メソッド化

ユニットテスト実装では比較的一般になってきた日本語メソッドをさらに進め、ほぼすべてのクラスおよびメソッドを日本語化しました。
これは以下理由によるものです。

  • 税務・会計・給与を対象しているため、一般的なWeb画面に比べて多数のまた似たような名前で且つ業務的に特殊な用語が使われることが多くなります。その際に無理に英訳してしまうよりも日本語化することで画面の項目とメソッド名とのギャップを埋めることができます。
  • 非プログラマでもテストをかけるようにということで、可能な限り英語を排した。日本語で記述できることを目指した。

日本語により可読性は向上したように感じますが、欠点としては基盤/テストシナリオ実装時に英語と日本語との切り替えが面倒なことがあります。ただし、シナリオ実装時は後述の「戻り値をObject化」することでIDEによる補完を行いやすくし問題を緩和しています。

先ほどのサンプルを日本語化すると以下の様になります。

public abstract class BasicInformationPageBase {

    protected SelenideElement コード(){
        return $(byId("csCodeTR")).$("td");
    }

    protected SelenideElement 名前(){
        return $(".pr-client-setting tbody", 0).$(byText("法人表記名称")).parent().$("td");
    }
}
public class BasicInformationPage extends BasicInformationPageBase {

    public String 名前取得(){
        return name().text();
    }

    public String コード取得(){
        return code().text();
    }

    public void コード設定(String コード){
        return code().val(コード);
    }
}

クラス名も日本語するべきだったかもしれませんが、現段階では行っていません。

疑似Builder Patternによるデータ登録

テストの安定性を向上させるため、テストシナリオで使うデータはシナリオ内で作成するようにしています。その際、データ登録のためにたくさんのフィールドをうめなければならない場合があります。多数のフィールドを埋めて登録を行う際、以下のような処理の抽象化段階が考えられます。

  1. 生のセレクタで項目をひとつずつ埋めていく。
  2. Page Objectを経由してひとつずつ入力項目を埋めていく。
  3. Page Objectに一括入力メソッドを作成し、1メソッド複数引数で登録処理を行う。

これらの手段を用いても以下の問題は解決しません。

  • 項目数に比例して手続きが多くなる。
  • 1メソッドに押し込めると引数が多くなり、引数と項目の対応が分かりにくくなる。

これらの問題を解決するために、簡易的なBuilder Pattern を導入し専用の登録メソッドを準備することにしました。

実装詳細は省きますが、例として社員情報登録は以下のように記述します。

共通.社員 社員 = new 共通.社員().社員コード("000002").("従業員").("入社前")
                .姓カナ("ジュウギョウ").名カナ("イン")
                .役職(EmployeeInformationPageBase.役職.社員)
                .生年月日(JapaneseDate.of(1975, 10, 10))
                .入社日(JapaneseDate.of(2020, 2, 1))
                .性別(EmployeeInformationPageBase.性別.男性)
                .郵便番号("000", "0000")
                .番地("2-1-0", "2-1-0")
                .建物名("ほげ", "ホゲ");
共通.社員情報登録(コード, 社員);

このようにすることでどういう情報を登録するのかという可読性をあげ、引数の順番に戸惑う事が無くなる上IDEのサポートにより入力時の保管がきくようになります。

Java8 Interfaceのdefault mehtodによる mix-inと共通処理の一元化

ページ毎の処理はPage Objectで集約できますが、ではページ横断で共通なものはどうすればよいでしょうか。共通の親クラスを持たせる方法もありますが、それだとJavaは単一継承なので一律になってしまい画面ごとに有効・無効がある場合に対応しきれません。

そこで、Java8から導入されたInterfaceのdefault methodを使う事でmin-inを実現させました。

横断的な処理を個別にInterfaceとして定義し、それが実行可能な画面のPage Objectにimplement させていきます。Interfaceなので単一のPage Objectに複数implement することができます。

私が実際にInterfaceとして処理共通化したものには以下のようなものがあります。

  • 常に全画面左部で表示されているメニュー
  • ファンクションキー操作
  • ポップアップ

Interfaceによる共通処理の外部により、先のPage Object は以下のようになります。

public abstract class BasicInformationPageBase implements Menu, Popup, FunctionKey {

    protected SelenideElement コード(){
        return $(byId("csCodeTR")).$("td");
    }

    protected SelenideElement 名前(){
        return $(".pr-client-setting tbody", 0).$(byText("法人表記名称")).parent().$("td");
    }
}
public interface FunctionKey {
    public SelenideElement F1(){
        return $("#fn1");
    }

    public SelenideElement F2(){
        return $("#fn2");
    }

    // 以下略
}

画面要素の細かいObject化

画面ごとにPage Objectを作って処理をまとめましたが、私が担当しているシステムは業務の特性上、画面の項目数が非常に多くある程度カテゴライズしないとメソッドが日本語化されているとはいえ探すのが大変です。また、一覧表示などで三行目のデータを見たいなどの場合に簡単にアクセスできるようしたいところです。

そこで画面の大まかなカテゴリや一覧表示の行、タブにより切り替わる画面を全てPage Object内のinner classに定義し分割しました。

public class BonusSlipListPageBase
        implements Menu {

    protected SelenideElement _1回目(){
        return $(byId("timeTab")).$(by("pr-month-month", "one"));
    }

    protected SelenideElement _2回目(){
        return $(byId("timeTab")).$(by("pr-month-month", "two"));
    }

    protected SelenideElement _3回目(){
        return $(byId("timeTab")).$(by("pr-month-month", "three"));
    }

    protected SelenideElement _4回目(){
        return $(byId("timeTab")).$(by("pr-month-month", "four"));
    }

    protected static class タブbase {

        /**
         * 従業員および合計列の、先頭部分にある文字列(社員ID,社員名、「合計」の文字列)を
         * 列毎の文字列集合として返します。
         *
         * @return 列先頭部分の文字列
         */
        protected List<String> 列情報取得(){
            List<String> list = $("#bpslTableCaption").$$(".bpsl-cell").stream().map(a -> a.text()).collect(Collectors.toList());
            return list;
        }

        protected SelenideElement 手当1名称(){
            return $(byId("bpslLeft2allowance1"));
        }

        protected SelenideElement 手当2名称(){
            return $(byId("bpslLeft2allowance2"));
        }
  // 略
  }
}
public class BonusSlipListPage extends BonusSlipListPageBase {

    private タブ _1 = new タブ();
    private タブ _2 = new タブ();
    private タブ _3 = new タブ();
    private タブ _4 = new タブ();

    public タブ タブ1回目(){
        _1回目().click();
        return _1;
    }

    public タブ タブ2回目(){
        _2回目().click();
        return _2;
    }

    public タブ タブ3回目(){
        _3回目().click();
        return _3;
    }

    public タブ タブ4回目(){
        _4回目().click();
        return _4;
    }

    public static class タブ extends タブbase {

        public  従業員選択(String id){
            List<String> list = 列情報取得();
            int i = 0;
            for(String name : list){
                if(name.matches("^" + id +"\n")){
                    return new (i);
                }
                i++;
            }
            throw new RuntimeException("従業員が見つかりません:" + id);
        }

        /**
         * 合計列を取得します。
         *
         * @return 合計列
         */
        public  合計列取得(){
            return new (列情報取得().size());
        }

        public String 手当1名称取得(){
            return 手当1名称().text();
        }
    // (略)

    }
}

例にあるように、inner class も全てBaseにセレクタのみを記述することで振る舞い層との分離をはかっています。
また行取得もメソッド化することで、目的行を容易に取得する事ができるようにしています。

疑似的なDSLのために、戻り値をObject化

さて最後になりますが、今回のテスト基盤を作成するにあたり自分が利用しているSelenideの「IDEの力を借りる」という点に非常に感銘をうけ、自分で構築するにあたっても同じ方法を採用しました。それを実現するために、最終的な要素への振る舞い以外のメソッドはすべて何かしら画面要素をあらわすObjectを返すようにしました。先の例にも「列」というObjectが定義されていますが、あらゆるものがObjectになっています。min-in で紹介したMenuも、実際にはinterfaceにはMenuというObjectを返すメソッドだけ定義しています。

結果

今まで書いてきたことを実践した結果、テストケースは以下のように記述することができるようになりました。

        EmployeeInformationPage employee = 共通.社員情報登録(顧問先コード, 社員);
        employee.社会保険タブ().健康保険設定(true,"12345");
        SocialInsuranceRateSettingPage setting = employee.メニュー().社会保険料率設定画面へ遷移();

        setting.都道府県区分設定("13");
        setting.警告ダイアログ().はいボタン押下();
        setting.健康保険().旧タブ().料率適用を開始する給料の支給年月年設定("28");
        setting.健康保険().新タブ().料率適用を開始する給料の支給年月月設定("3");
        setting.ファンクションキー().F5().click();
        InputPaySlipPage 明細 = setting.メニュー().給与明細書入力画面へ遷移();
        明細.従業員一覧().従業員取得("000005");
        明細.一月分選択();
                // 略

戻り値は全てObject のため、Page Objectの操作はもちろんそこから先のメニュー処理やファンクションキー、画面内のタブなど全てIDEで.を入力するだけで操作できることが一覧表示されます。それもメソッドは全て日本語なので、非常に可読性が高く、Selenium/Selenideの直接操作の排除したため自分たちで定義した振る舞いのみ選べるようになっています。
画面要素の細かいObject化との組み合わせはsetting.健康保険().旧タブ().料率適用を開始する給料の支給年月年設定("28");に現れています。画面上の項目が多くても、IDEのサジェスチョンによるサポートをもらい少しずつ絞り込んでいく事ができます。
Java8 Interfaceのdefault mehtodによる mix-inと共通処理の一元化 により、必要な画面でファンクションキーやメニューの処理を呼び出す事ができ、実装は共通化されています。

このようにして、当初の目的だった以下の目的達成のための準備を行いました。

  • 保守性向上
  • 生産性向上
  • 可能であれば仕様策定者が自分でテストをかけるようにする

とくに、最後の自動テスト作成を容易化する部分が今回の一番の力点でしたが、かなりうまくいったのではないかと考えています。

終わりに

さて私が取り組んできた、自動テスト基盤開発について説明してきましたがいかがだったでしょうか。
これだけの基盤を作るのはそれなりにコストはかかりますが、設計方針さえ固まってしまえば通常のPage Object Pattern導入以上の大きなオーバーヘッドは特にないような気がします。

すべての組織・プロジェクトでこのような基盤を作る必要はないかもしれませんが、皆様の参考になれば幸いです。

近いうちにもっと詳細かつ具体的なコードでのサンプルを公開できればと考えています。

この投稿は Selenium/Appium Advent Calendar 201623日目の記事です。