前回は、マイクロサービスを呼び出す側のWebアプリケーション(BFF:BackendForFrontend)におけるRepositoryやServiceの単体テストについて説明しました。今回は引き続き、HTMLUnitを使用したControllerの単体テスト、Seleniumを使用したEndToEndテストについて説明します。

マイクロサービスを呼び出すWebAPのController単体テスト実装

BFFアプリケーションのControllerテストは、リクエストハンドリング/バリデーションの妥当性など、マイクロサービスでのテスト観点と重複する部分もあります。ただし、マイクロサービスのレスポンスがJSON文字列を返却するコンテンツタイプ「application/json」だったのに対し、BFFアプリケーションは「text/html」であるHTTPレスポンス返却が中心です。

そこで、マイクロサービスのテストで実施した、MockMvcを使ったControllerのテスト観点に加え、オープンソーステストライブラリ「HtmlUnit」を使って、生成するHTTPレスポンスのなかで必要なパラメータやメッセージが含まれているかどうかの判定を行います。 その準備として、Mavenプロジェクトのpom.xmlで、spring-boot-starter-testに加えて、HtmlUnitのライブラリを定義します。

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>net.sourceforge.htmlunit</groupId>
    <artifactId>htmlunit</artifactId>
  </dependency>
</dependencies>

Controllerの単体テスト実装でのリクエストハンドリングやMock設定などの基本的な要領は、第5回で解説したマイクロサービスにおけるControllerの単体テスト実装とほぼ同様です。

以降は、com.gargoylesoftware.htmlunit.WebClientを使用した、HTMLの検証コードを中心に解説を進めます。SpringMVCおよびMockMvcを使用したHtmlUnitの使用方法については、Springの公式ドキュメント「HtmlUnit Integration」も適宜参照してください。

Webアプリケーションでは、マイクロサービスのテストとは異なり、org.springframework.validation.BindingResultにエラー項目が格納されます。テストコードではそこからエラーとなる項目を取り出し、期待したパラメータやメッセージが取得できるかどうか検証を行う必要があります。

また、HTMLUnitはAJAX(非同期通信)における実行をサポートするため、画面遷移を伴わない処理も合わせて検証が可能です。サンプルのテストコードは以下の通りです。

package org.debugroom.mynavi.sample.continuous.integration.bff.app.web;

// omit

import com.gargoylesoftware.htmlunit.BrowserVersion;
import com.gargoylesoftware.htmlunit.BrowserVersion.BrowserVersionBuilder;
import com.gargoylesoftware.htmlunit.NicelyResynchronizingAjaxController;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.HtmlButton;
import com.gargoylesoftware.htmlunit.html.HtmlElement;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.html.HtmlTextInput;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder;

// omit

@RunWith(SpringRunner.class)                                       // ...(A)
@WebMvcTest(controllers = BackendForFrontendController.class)      // ...(B)
public static class UnitTest{

    WebClient webClient;

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrchestrateService orchestrateServiceMock;

    // omit

    @Before
    public void setUp() throws Exception{

        BrowserVersionBuilder browserVersionBuilder = new BrowserVersionBuilder(BrowserVersion.CHROME);
        browserVersionBuilder.setBrowserLanguage("jp_JP");
        webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc)
                .withDelegate(new WebClient(browserVersionBuilder.build()))
                .build();                                                        // ...(C)
        webClient.setAjaxController(new NicelyResynchronizingAjaxController());  // ...(D)

        // omit
    }

    @Test
    public void getUsersTest() throws Exception{
        HtmlPage page = webClient.getPage("http://localhost:8080/getUsers");     // ...(E)
        assertThat(page.getTitleText(), is("GetUsers"));
        assertThat(page.getElementById("td-firstName-0")
             .getFirstChild().asText(), is("太郎"));
                                                                                 // ...(F)
        // omit
    }

    @Test
    public void isUsableLoginIdNormalTest() throws Exception{
        HtmlPage page = webClient.getPage("http://localhost:8080/portal");
        HtmlButton htmlButton = (HtmlButton)page.getElementById("isUsableLoginIdButton-0");
        // omit
        HtmlPage updatePage = htmlButton.click();
        webClient.waitForBackgroundJavaScript(10000);
        HtmlElement htmlElement = (HtmlElement)updatePage.getElementById("message-panel");

        assertThat(htmlElement.getFirstChild().asText(), is("使用可能なログインIDです。"));
                                                                                 // ...(G)
    }

    // omit

    @Test
    public void addUsersInputParamTest() throws Exception{
        MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
        params.set("users[0].firstName", "taro");
        // omit
        MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders
                                  .post("/addUsers").params(params)).andReturn();
        ModelAndView modelAndView = mvcResult.getModelAndView();
                                                                                 // ...(H)

        assertThat(modelAndView.getViewName(), is("portal"));
        BindingResult bindingResult = (BindingResult)
           modelAndView.getModel().get("org.springframework.validation.BindingResult.addUsersForm");

        List fieldErrors = bindingResult.getFieldErrors();
        assertThat(fieldErrors.size(), is(6));
                                                                                 // ...(I)
        // omit
    }

}
項番 説明
A テストランナーとして、SpringRunnerを指定します
B @WebMvcTestでテスト対象のControllerを指定します
C ブラウザとして日本語ロケールのChromeを指定して、セットアップメソッドでWebClientを構築します
D 非同期通信(AJAX)のテストを行うためにAjaxControllerを設定しておきます
E WebClientを使用して、テスト対象のHTMLページを取得します
F HTMLページに含まれる項目のアサーションロジックを実装します
G HTMLページに含まれるAJAX処理を実行し、処理実行時間を考慮してWAITした上で、処理実行後に得られる項目を検証します
H リクエストパラメータを設定し、テスト対象のControllerメソッドを呼び出した後、ModelAndViewを取得します
I BindingResultを取得し、エラーとなっている項目を取得して検証します

上記の通り、マイクロサービスの呼び出しをモック化して、取得したデータが正しく画面に表示されるかどうかや、入力チェックエラー発生時に正しいメッセージやパラメータが含まれるかどうかを検証できます。

マイクロサービスにおけるControllerの単体テスト同様、ControllerやFormの設定ミスはセキュリティホールに直結するので、境界値試験など含め、リクエストパラメータの異常系バリエーションを充実させて検証したほうが良いでしょう。

また、後述しますが、後続のSeleniumを使用したEndToEndテストは実装/処理コストが大きいため、マイクロサービスのテストとは異なり、できるだけController単体試験に寄せて試験を行うほうが得策です。

サンプルとして実装したテストケースと検証観点は以下になります。

ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[正常系]ユーザー一覧画面を表示する BackendForFrontendController#getUsers ・サービス実行結果が正しく画面に表示されるか
BackendForFrontendControllerTest#getUsersTest()
[異常系]ユーザー情報を取得する BackendForFrontendController#getUser ・入力チェックやビジネスエラー発生時に正しいメッセージやパラメータを返却するか
BackendForFrontendControllerTest#getUserAbnormalTest()
[正常系]ログインIDが使用可能か判定する(非同期通信) BackendForFrontendController#isUsableLoginId ・非同期通信の実行結果が正しく画面に表示されるか
BackendForFrontendControllerTest#isUsableLoginIdNormalTest()
[異常系]ログインIDが使用可能か判定する(非同期通信) BackendForFrontendController#isUsableLoginId ・非同期通信の実行結果が正しく画面に表示されるか
BackendForFrontendControllerTest#isUsableLoginIdAbnormalTest()
[異常系]複数のユーザーを追加する BackendForFrontendController#addUsers ・入力チェックやビジネスエラー発生時に正しいメッセージやパラメータを返却するか
BackendForFrontendControllerTest#addUsersInputParamTest()

Seleniumを使用したEndToEndテスト

BFFアプリケーションの単体テストが一通り終わったタイミングで、マイクロサービスのテストと同様に、Service⇔Repository、Controller⇔Service⇔Repositoryの結合テストを実施するのも一つの考え方です。しかし、Repositoryがバックエンドのマイクロサービスを呼び出すことや、単体テストである程度異常系テストケースの網羅が可能であることから、次のステップでは、結合テストと言うよりもEndToEndテスト(以降、E2Eテスト)として、バックエンドのマイクロサービスの呼び出しを含めた検証を行うこととします。

Seleniumを使用したE2Eテストのイメージ

E2Eテストでは、一般にエンドユーザーの操作を想定したユースケースを基に、実行結果を検証します。ここではオープンソースのGUI自動化テストツールとして多くの実績があるSeleniumを使って、テストコードを実装します。

なお、WebDriverなどSeleniumで提供されるライブラリはさまざまな言語で利用可能ですが、Java版ではJUnitコードでのスクリプト記述が可能であり、Springが提供するMockMvcと統合して使用することが可能です。必要に応じて、Springの公式ドキュメント「MockMvc and WebDriver」も適宜参照ください。

SpringBootアプリケーションのテストでSeleniumを使用するには、以下の通り、Mavenプロジェクトのpom.xmlで、spring-boot-starter-testに加えて、Seleniumのライブラリを定義します。

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
  </dependency>
</dependencies>

また、ローカル環境でSeleniumを実行するには、ドライバのインストールが必要です。Seleniumのインストール手順に従って、ドライバをインストールしてください。以降の説明は、/usr/local/binにchromedriverがインストールされた前提で進めます。

なお、このE2Eテストではバックエンドのマイクロサービスを呼び出すため、事前にBackendアプリケーションを起動しておく必要があります。マイクロサービスのController⇔Sevice⇔Repositoryの結合テストと同様に、@SpringBootTestを使って実装したサンプルのテストコードは以下の通りです。

package org.debugroom.mynavi.sample.continuous.integration.bff.app.web;

// omit
import org.junit.experimental.categories.Category;

// omit

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;

import org.openqa.selenium.By;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;

// omit

import org.debugroom.mynavi.sample.continuous.integration.common.apinfra.test.junit.E2ETest;

// omit

@RunWith(SpringRunner.class)                                   // ...(A)
@SpringBootTest(classes = {
  TestConfig.EndToEndTestConfig.class,
  BackendForFrontendControllerTest.EndToEndTest.Config.class
}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // ...(B)
@Category(E2ETest.class)                                       // ...(C)
public static class EndToEndTest{

    @Configuration
    public static class Config{

        @Autowired
        SeleniumProperties seleniumProperties;                 // ...(D)

        @Bean
        @Profile("dev")
        WebDriver webDriver(){
            System.setProperty("webdriver.chrome.driver", seleniumProperties.getChromeDriverPath());
            ChromeOptions options = new ChromeOptions();
            //omit
            return new ChromeDriver(options);                  // ...(E)
        }

    }

    @Value("#{servletContext.contextPath}")
    private String contextPath;

    @Autowired
    SeleniumProperties seleniumProperties;

    @LocalServerPort
    private int port;

    @Autowired(required = false)
    WebDriver webDriver;                                       // ...(F)

    @Autowired
    PortalPage portalPage;                                     // ...(G)

    // omit

    @Test
    public void addUsers_2_AbnormalTest(){
        webDriver.get("http://localhost:" + port + contextPath + "/portal");
        webDriver.findElement(By.id("addFormButton-0")).click();
        portalPage.setAddUserForm1("saburo", "mynavi",
                "saburo.mynavi1", "100-0000", "Tokyo Minato",
                "saburo.mynavi1@debugroom.org");
        portalPage.setAddUserForm2("jiro", "mynavi",
                "jiro.mynavi1", "300-0000", "Tonde Saitama",
                "jiro.mynavi1@debugroom.org");
        webDriver.findElement(By.id("addUsersButton")).click();
        webDriver.getPageSource().contains("使用できないログインIDです。LoginID : jiro.mynavi1");
        File file = ((TakesScreenshot) webDriver).getScreenshotAs(OutputType.FILE);
        file.renameTo(new File(seleniumProperties.getEvidencePath() + "/addUserAbnormalTest_screenshot.png"));
                                                                 // ...(H)
    }
   // omit
項番 説明
A テストランナーとして、SpringRunnerを指定します
B @SpringBootTestアノテーションには、テスト向け固有の設定クラスを任意に指定し、Webコンテナ(Server)の起動時のポートをランダムで指定しておきます
C テストクラスを種別で分類するためにCategoryアノテーションを付与します。テスト実行にはBackendアプリケーションの起動が前提となるので、pom.xmlのデフォルトプロファイル内でmaven-surefire-pluginを設定し、Mavenビルド時に、デフォルトではE2ETestインタフェースのカテゴリのテストは実行されないようにしておきます
D Chromeドライバのパスや実行時のスクリーンショット画像を保存するパスなどをプロパティとして定義して利用します
E WebDriverにChromeドライバを設定します。インスタンス生成前にシステム変数webdriver.chrome.driverにChromeドライバのパスを設定しておきます
F WebDriverをインジェクションして利用します。次回以降解説しますが、バックエンドのマイクロサービスの起動が前提となるE2Eテストは、ビルド時にテストを実施しないよう設定し、WebDriverが除外されてもテストクラスが問題ないようrequiredオプションを「false」に設定しておきます
G テスト対象のHTMLページを、PageObjectパターンでPageObjectとして実装しておき、インジェクションして利用します。今回サンプルとして実装したPageObjectはこちらを参照してください
H WebDriverでテスト対象のページに遷移し、テストパラメータを設定して処理ボタンを押下し、結果の文字列をアサーションします。最後に実行結果の画面をエビデンスとして指定したディレクトリに保存します

※ PageObjectパターンとは、Seleniumの公式サイトでも推奨されているパターンで、HTMLページをPageObjectとして実装し、テストで関係する主なHTML要素を変数として定義して、WebDriverのアクセスを一元的に行うことで実装効率性を高めるデザインパターンです。

テストを実行すると、Chromeブラウザが起動し、テストケース操作が自動実行されます。最後に画面のスクリーンショットを保存して、画面表示のレイアウトが崩れていないかなどを目視で確認します。

画面表示のレイアウトなどを確認する

上記を含め、サンプルとして実装したテストケースと検証観点は以下になります。必要に応じて、ブラウザごとの表示の差異やデータベースへの反映状況なども検証すると良いでしょう。

ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[正常系]ユーザー一覧画面を表示する BackendForFrontendController#getUsers ・ユースケースシナリオ通り操作した時に、正しく画面に結果が表示されるか
・画面表示のレイアウトが崩れていないか
BackendForFrontendControllerTest#E2E#getUsersTest()
[正常系]複数ユーザーを追加する BackendForFrontendController#addUsers ・ユースケースシナリオ通り操作した時に、正しく画面に結果が表示されるか
・画面表示のレイアウトが崩れていないか
BackendForFrontendControllerTest#E2E#addUsers_1_NormalTest()
[異常系]複数ユーザーを追加する BackendForFrontendController#addUsers ・ユースケースシナリオ通り操作したときに、エラーメッセージが正しく表示されるか
・画面表示のレイアウトが崩れていないか
BackendForFrontendControllerTest#E2E#addUsers_2_AbnormalTest()

E2Eテストの実行は、実装にかかる工数もさることながら、ブラウザの起動やスクリーンキャプチャ処理などが挟まることで、テスト実行自体にも相応の時間を要します。実装するテストケースは最小限に絞り、可能な限り、異常系のパターンバリエーションなどは相対的にコストが低い単体テストで検証するようにしてください。

マイクロサービスを呼び出すWebアプリケーションのテスト戦略

これまで、Repository、Service、Controllerの単体テストと、Seleniumを使ったE2E自動化テストコード実装を解説してきました。 これまでの説明の再掲にはなりますが、初めから完璧にテストコードを整備しておく必要もありませんし、必要以上のテストコードの実装でかえって開発のアジリティを損なうようでは本末転倒です。 ただ、テストが疎かになるとせっかくの継続的インテグレーションも機能しません。開発のスピードと品質を両立するために、テスト計画やスコープ、検証の観点を明示的に策定しておくことが重要です。

マイクロサービスを呼び出す側のWebアプリケーションの効率的なテスト戦略策定のポイントとしては、以下のような点が挙げられるでしょう。

  • バックエンドのマイクロサービスは別途試験されていることから、結合試験を除外し、E2Eテストにフォーカスする
  • E2Eテストは実装工数や処理時間など高コストになりがちなため、できるだけ異常系のバリエーションなどは単体テストで行う
  • 探索的テストを導入し、実装状況に応じてテストケースの重複を極力減らしながらテストコードを作成する
  • 機能や処理の重要度に応じて、テスト実施内容に濃淡をつける(ビジネス的にそこまで重要でない処理の参照系はテストしないなど)

* * *

次回はこれまで実装したソースコード/テストコードに対し、AWS CodeBuildを使用して、ビルドやテスト実行、SonarQubeとの連携について、実践していきます。

著者紹介


川畑 光平(KAWABATA Kohei) - NTTデータ 課長代理

金融機関システム業務アプリケーション開発・システム基盤担当を経て、現在はソフトウェア開発自動化関連の研究開発・推進に従事。

Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなどさまざまな開発プロジェクト支援にも携わる。2019 APN AWS Top Engineers & Ambassadors選出。