TECHBLOGスキルブログ

【JUnit+Selenium】カスタムMatcherでテストコードをリファクタリング

2018.09.25

こんにちは。ユニトラストの櫻井です。

最近、Webアプリケーションのテスト自動化ツール「Selenium」とテストツール「JUnit」を組み合わせ、
テストの自動化を検討する機会がありました。
今回はその時の経験を活かして、カスタムMatcherを用いるテストコードのリファクタリング方法を紹介します。


実行環境と使用ツールについて

【実行環境】

  • Eclipse Java EE IDE Mars.2 Release (4.5.2)
  • Java 1.8
  • 【参照ライブラリ】

  • selenium-java 2.53.0
  • JUnit 4.12
  • hamcrest-core-1.3
  • hamcrest-library-1.3
  • 以下、ライブラリの内容について説明していきます。

    Selenium

    Seleniumとは、Webアプリケーションのテスト自動化ツールです。
    様々なブラウザにおいて、記録した操作を何度でも自動実行することができます。
    またテストだけでなく、Webアプリケーション上の業務システムの操作を自動化することもできます。
    対応言語は、Java、C#、Ruby、Pythonで、対応ブラウザはFireFox、Google Chrome、Internet Explorerなどです。

    JUnit

    JUnitとは、Javaで開発したシステムの単体テストを行うためのフレームワークです。
    想定した値と実際にAPIが返した値について、一致するかどうかを機械的に何度でもテストできます。
    つまり、回帰テストを自動的に実施し、
    システム改修やバグ修正によって新しいバグが混入していないかを検証することが可能です。


    サンプルテストコード

    JUnitとSeleniumを組み合わせると、実際に画面を遷移させつつ、表示項目を検証できます。
    以下は、弊社サイトの「イベント記事カテゴリ」について、
    画面の表示項目と想定結果が一致するか検証するためのテストコードです。
    まずはリファクタリング前のコードから見ていきます。

    リファクタリング前

    最も単純なコーディング方法は、テストする画面のそれぞれの表示項目を1つずつ検証することです。

    テスト実行クラス

    /**
     * イベントページの記事要素の検証.
     */
    public class TestArticlesRough {
    
        private WebDriver driver;
    
        /**
         * テスト開始.
         */
        @Test
        public void testOpen() {
            // WebDriverインスタンス化
            driver = new FirefoxDriver();
    
            // seleniumによりFireFox上でイベント記事カテゴリを表示
            driver.get("http://www.unitrust.co.jp/category/blog/event/");
    
            // ブラウザ画面から記事要素を取得
            List<Article> actualArticles = TestUtil.fetchArticles(driver);
    
            // 比較
            assertArticles(actualArticles);
        }
    
        /**
         * 記事内容のチェック.
         *
         * @param actualArticles
         */
        private void assertArticles(List<Article> actualArticles) {
    
            // 記事数が異なる場合はアサートエラー
            assertThat(actualArticles.size(), is(9));
    
            // 1記事目
            Article articleFirst = actualArticles.get(0);
            assertThat(articleFirst.getUrl(), is(
                    "http://www.unitrust.co.jp/2016%e5%b9%b4-%e7%a4%be%e5%93%a1%e6%97%85%e8%a1%8c%ef%bc%a0%e9%87%91%e6%b2%a2-%ef%bc%93%e6%97%a5%e7%9b%ae/"));
            assertNotNull(articleFirst.getUrlHtml());
            assertThat(articleFirst.getTitleImgPath(),
                    is("http://www.unitrust.co.jp/wp-content/uploads/2016/10/s_IMG_1036.jpg"));
            assertNotNull(articleFirst.getTitleImg());
            assertThat(articleFirst.getTitle(), is("2016年 社員旅行@金沢 3日目"));
            assertThat(articleFirst.getDate(), is("2016年11月29日"));
            assertThat(articleFirst.getContent(), is(
                    "こんにちは。ユニトラストの高田です。皆さん楽しそうな2日目でしたね。 (以下略)"));
            // ~続いて9記事目までアサーションが続く~
        }
    }
    

    Seleniumを用いたデータ取得用クラス

    /**
     * ユーティリティクラス.
     */
    public class TestUtil {
    
        /**
         * 表示記事取得.
         *
         * @param driver
         * @return articles
         */
        public static List<Article> fetchArticles(WebDriver driver) {
            List<Article> articles = new ArrayList<>();
            WebElement thumbnails = driver.findElement(By.className("thumbnails"));
            List<WebElement> tiles = thumbnails.findElements(By.className("span3"));
    
            for (WebElement tile : tiles) {
                // URL
                WebElement linkThumb = tile.findElement(By.className("link_thum"));
                String url = linkThumb.getAttribute("href");
    
                // リンク先URLのHTML
                String urlHtml = downloadHtml(url);
    
                // タイトル画像パス
                WebElement thumb = tile.findElement(By.className("thumb"));
                String titleImgPath = thumb.getAttribute("src");
    
                // タイトル画像
                BufferedImage titleImg = downloadImg(titleImgPath);
    
                // タイトル
                WebElement title = tile.findElement(By.className("titleclump"));
                String titleString = title.getText();
    
                // 日付
                WebElement date = tile.findElement(By.tagName("small"));
                String dateString = date.getText();
    
                // 本文内容
                WebElement clump = tile.findElement(By.className("articleclump"));
                String content = clump.getText();
    
                Article article = new Article(url, urlHtml, titleImgPath, titleImg, titleString, dateString, content);
                articles.add(article);
            }
            return articles;
        }
    

    画面の項目1つ1つに対して検証用メソッド(assert~)を呼び出しているので、
    何をしているか分かりやすくはあります。
    しかしこのままだと、テストケースを増やすたびに検証処理を何度も記述することになります。
    例えば、もしテストコードを何百も作成した後に表示項目を増やす改修が入ったら…
    作成した何百のテストコードを全て修正することになります。

    以上の問題は、カスタムMatcherにて解決することができます。


    リファクタリング後

    Matcherとは、Junit4.4以降で実装された検証用クラスです。
    抽象クラスであるTypeSafeMatcherを継承してカスタムMatcherを作成すると、
    様々なオブジェクトに関して独自の検証方法を作成することができます。

    テスト実行クラス

        /**
         * テスト開始.
         */
        @Test
        public void testOpen() {
            // WebDriverインスタンス化
            driver = new FirefoxDriver();
    
            // seleniumによりFireFox上でイベント記事カテゴリを表示
            driver.get("http://www.unitrust.co.jp/category/blog/event/");
    
            // ブラウザ画面から記事要素を取得(Articleに詰める)
            List<Article> actualArticles = TestUtil.fetchArticles(driver);
    
            // 想定結果の取得(CSVや画像イメージから想定結果を生成し、Articleに詰める)
            List<Article> expectedArticles = TestUtil.createExpectedArticles();
    
            // assertThatとカスタムMatcherで比較
            assertThat(actualArticles, is(new EqualArticle(expectedArticles)));
        }
    
    /**
     * 記事クラス.
     */
    public class Article {
    
        private String url;
        private String urlHtml;
        private String titleImgPath;
        private BufferedImage titleImg;
        private String title;
        private String date;
        private String content;
    
        /**
         * コンストラクタ.
         *
         * @param url URL
         * @param urlHtml リンク先URLのHTML
         * @param titleImgPath タイトル画像パス
         * @param titleImg タイトル画像
         * @param title タイトル
         * @param date 投稿日付
         * @param content 記事本文
         */
        public Article(String url, String urlHtml, String titleImgPath, BufferedImage titleImg, String title, String date,
                String content) {
            this.url = url;
            this.urlHtml = urlHtml;
            this.titleImgPath = titleImgPath;
            this.titleImg = titleImg;
            this.title = title;
            this.date = date;
            this.content = content;
        }
    
        public String getUrl() {
            return url;
        }
        // ~以下各プロパティに対するgetterが続く~
    
        /* (非 Javadoc)
         * アサートエラー時に呼び出される。詳細を表示するためOverride
         * @see java.lang.Object#toString()
         */
        @Override
        public String toString() {
            StringBuffer sb = new StringBuffer();
            // URL
            sb.append("URL:" + url +"\r\n");
            // ~以下各プロパティに対するアサートエラー時の表示が続く~
            return sb.toString();
        }
    }
    

    カスタムMatcherクラス

    /**
     * 記事内容の比較.
     */
    public class EqualArticle extends TypeSafeMatcher<List<Article>> {
    
        // 想定結果
        private List<Article> expected;
    
        // 不一致項目名
        private String mismatchElement;
        // 想定値
        private Object actualValue;
        // 実際値
        private Object expectedValue;
    
        public EqualArticle(List<Article> expected) {
            this.expected = expected;
        }
    
        @Override
        protected boolean matchesSafely(List<Article> actual) {
    
            // 記事数が異なる場合はアサートエラー
            if (actual.size() != expected.size()) {
                mismatchElement = "記事数";
                actualValue = actual.size();
                expectedValue = expected.size();
                return false;
            }
    
            for (int i = 0; i < expected.size(); i++) {
                // URL
                String actualUrl = actual.get(i).getUrl();
                String expectedUrl = expected.get(i).getUrl();
                if (!actualUrl.equals(expectedUrl)) {
                    mismatchElement = "URL";
                    actualValue = actual.get(i).getUrl();
                    expectedValue = expected.get(i).getUrl();
                    return false;
                }
    
                // タイトル画像
                BufferedImage actualImage = actual.get(i).getTitleImg();
                BufferedImage expectedImage = expected.get(i).getTitleImg();
                // ピクセル単位で画像のRGBを比較
                if (equalsImage(actualImage, expectedImage)) {
                    mismatchElement = "タイトル画像";
                    actualValue = actualImage;
                    expectedValue = expectedImage;
                    return false;
                }
                // ~以下各画面要素に対するアサーションが続く~
    
            }
            return true;
        }
    
        /**
         * bufferedImageの比較用メソッド
         * ピクセル単位でRGBを比較
         */
        private boolean equalsImage(BufferedImage firstImg, BufferedImage secondImage) {
            for (int x = 0; x < firstImg.getWidth(); x++) {
                for (int y = 0; y < firstImg.getHeight(); y++) {
                    if (firstImg.getRGB(x, y) != secondImage.getRGB(x, y))
                        return false;
                }
            }
            return true;
        }
    
        /* (非 Javadoc)
         * エラー時に詳細が表示されるようOverride
         * @see org.hamcrest.SelfDescribing#describeTo(org.hamcrest.Description)
         */
        public void describeTo(Description description) {
            description.appendValue(expectedValue);
            description.appendText(mismatchElement).appendText("が不一致(実際値:" + actualValue + ")");
        }
    }
    

    このように検証処理をMatcherクラスに移せば、
    記事の更新日や作成者の名前/所属等が新たに表示されることになっても、
    テストコードの修正は最低限で済みますね。


    まとめ

    システム開発において、メンテナンス性の高いテストコードを作りやすくしておくと、様々なメリットを享受できます。
    例えば、既存コードのリファクタリング後にテストを自動で実行できれば、新たなバグを短時間ですぐに検出できます。
    つまり定常的にリファクタリングができるようになるので、システムの保守性や拡張性を高めることが可能です。


                  

    OTHER CONTENTSその他のコンテンツ

    UNITRUST会社を知る

    • 私たちについて

    • 企業情報

    SERVICE事業内容

    • システム開発

    CONTACT
    お問い合わせ

    あなたの「想い」に挑戦します。

    どうぞお気軽にお問い合わせください。

    受付時間:平日9:00〜18:00 日・祝日・弊社指定休業日は除く

    お問い合わせ