Skip to content

Instantly share code, notes, and snippets.

@ritalin
Created May 12, 2013 06:08
Show Gist options
  • Save ritalin/5562602 to your computer and use it in GitHub Desktop.
Save ritalin/5562602 to your computer and use it in GitHub Desktop.
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.junit.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.remote.DesiredCapabilities;
public class SeleniumSmokeTest {
@Test
public void testName() throws Exception {
File logFile = new File("./logs/chromedriver.log");
File driverFile = new File("./driver/chromedriver");
ChromeDriverService service = new ChromeDriverService.Builder().withLogFile(logFile)
.usingDriverExecutable(driverFile).build();
Map<String, String> prefs = new HashMap<String, String>();
DesiredCapabilities capabilities = DesiredCapabilities.chrome();
capabilities.setCapability("chrome.prefs", prefs);
WebDriver webDriver = new ChromeDriver(service, capabilities);
webDriver.quit();
}
}

概要

環境構築

依存ライブラリの追加

pom.xmlを修正し、seleniumとcucumberを追加する。 修正後、プロジェクトをアップデートすること。

    <!-- selenium/cucumber -->
    <dependency>
      <groupId>org.seleniumhq.selenium</groupId>
      <artifactId>selenium-chrome-driver</artifactId>
      <version>2.31.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>info.cukes</groupId>
      <artifactId>cucumber-java</artifactId>
      <version>1.1.3</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>info.cukes</groupId>
      <artifactId>cucumber-junit</artifactId>
      <version>1.1.3</version>
      <scope>test</scope>
    </dependency>

seleniumのドライバーを追加

下記URLから開発環境に対応したドライバーをダウンロードし、driverディレクトリに配置する。 https://code.google.com/p/chromedriver/downloads/list

最初のシナリオ

フィーチャの作成

featuresディレクトリを作成し、home.featureを作成する。

# language: ja

フィーチャ: ホーム画面を表示する

  シナリオ: ホーム画面を表示する
    もし "ホーム"ページを表示する
    ならば "ホーム"ページが表示されていること

RunCukeTestsの作成

テストソースディレクトリにjunitbook.bookstore.cukesパッケージを作成し、RunCukeTestsを作成する。 このクラスはJUnitを使ってCucumberのテストを実行するためのエントリポイント(テストクラス)となる。Mavenなどからテストを実行する時に、他のユニットテスト用のテストクラスと区別できるように、クラス名をTestで終わらせずに、Testsとする。

/**
 * Cucumberテストを実行するためのJUnitテストクラス.
 */
@RunWith(Cucumber.class)
@Cucumber.Options(features = { "features" }, glue = { "junitbook.bookstore.cukes" })
public class RunCukeTests {
}

RunCukeTestsでは、テストランナーとしてCucumber暮らすを指定し、テストメソッドは定義しない。Cucumberテストランナーでは、フィーチャファイルを読み込みテストケースを動的に生成する。 @Cucumber.OptionsにはCucumberテストを実行する時のデフォルトオプションを指定する。オプションはCucumberテストの実行時に、VM引数で与えることも可能だが、ここでデフォルトの引数を指定しておくと便利。

なお、src/test/javaに加えて、ソースフォルダとしてsrc/it/javaを作成し、RunCukeTestsを完全に分離してもよい。この時は、Mavenの設定を変更し、テストソースフォルダをカスタマイズすること。

Cucumberテストの実行

Cucumberテストを実行するには、RunCukeTestsを指定しJUnit Testを実行する。すなわち、コンテキストメニューから、Run As - JUnit Testで実行する。

実行すると、ふたつのテストケースが実行される。このテストケースは、フィーチャファイルに定義した各ステップが対応している。また、実行結果はグリーンとなるが、テストケースはignoreとなり、次のようにメッセージが表示される。

You can implement missing steps with the snippets below:

@もし("^\"([^\"]*)\"ページを表示する$")
public void ページを表示する(String arg1) throws Throwable {
    // Express the Regexp above with the code you wish you had
    throw new PendingException();
}

@ならば("^\"([^\"]*)\"ページが表示されていること$")
public void ページが表示されていること(String arg1) throws Throwable {
    // Express the Regexp above with the code you wish you had
    throw new PendingException();
}

ステップ定義クラスの作成

前節で作成したRunCukeTestsを使うことでCucumberテストは実行できた。次に各ステップをJavaのプログラムとして実行するためのステップ定義クラスを作成する。ステップ定義クラスは、自然言語(日本語)で定義したフィーチャファイルをプログラミング言語(Java)に変換する責務を持つ。

テストソースディレクトリにjunitbook.bookstore.cukes.stepsパッケージを作成し、CommonWebStepsクラスを作成する。CommonWebStepsクラスを作成したならば、Cucumberテストを実行した時に表示されたコードスニペットを貼り付けると効率よくステップ定義を作成出来る。

public class CommonWebSteps {

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String arg1) throws Throwable {
        // Express the Regexp above with the code you wish you had
        throw new PendingException();
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String arg1) throws Throwable {
        // Express the Regexp above with the code you wish you had
        throw new PendingException();
    }

}

なお、現行のCucumberでは、「ならば」アノテーションの「ば」の文字がMac UTF-8でコンパイルされているらしく(カーソルを動かすと解るが2文字で構成されている)、コピペだけではインポートがうまく機能しない。コンテンツアシストを活用したり、既にインポート済みの「ならば」をコピーするなどして対応すること。

Seleniumでブラウザを操作する

Cucumberテストを実行することで、フィーチャファイルはステップ定義が実行される。ここに、ブラウザを制御するためのコードを実装していく。ここでは、Chrome用のドライバを利用するが、各ブラウザのドライバが実行環境毎に用意されているため、ドライバ部分を修正することで他のブラウザでのテストも可能。

public class CommonWebSteps {

    static WebDriver webDriver;
    static String baseUrl;

    WebDriver getPage() {
        if (webDriver == null) {
            baseUrl = "http://localhost:8080/bookstore/";
            File logFile = new File("./logs/chromedriver.log");
            File driverFile = new File("./driver/chromedriver");
            ChromeDriverService service = new ChromeDriverService.Builder().withLogFile(logFile)
                    .usingDriverExecutable(driverFile).build();
            Map<String, String> prefs = new HashMap<String, String>();
            DesiredCapabilities capabilities = DesiredCapabilities.chrome();
            capabilities.setCapability("chrome.prefs", prefs);
            webDriver = new ChromeDriver(service, capabilities);
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
                public void run() {
                    webDriver.quit();
                }
            });
        }
        return webDriver;
    }

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        switch (pageName) {
        case "ホーム":
            getPage().get(baseUrl + "");
            break;
        default:
            throw new AssertionError("No such page: " + pageName);
        }
        Thread.sleep(1000);
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        String currentPath = getPage().getCurrentUrl().substring(baseUrl.length());
        switch (pageName) {
        case "ホーム":
            assertThat(pageName, currentPath, is(""));
            break;
        default:
            throw new AssertionError("No such page: " + pageName);
        }
        Thread.sleep(1000);
    }

}

実行時に早すぎると何が起こっているのか良くわからないため、各ステップでsleepをしている。

このようにステップ定義クラスで、SeleniumのAPIを使いステップ定義を実装していく。しかし、このまま実装を進めていくと、ステップ定義クラスはすぐにスパゲティコードと化してしまうだろう。早い段階でテストコードをリファクタリングして、これからの戦いに備えよう。

ステップ定義のリファクタリング(1)

はじめに、抽出するのは2個所に表れているswitch文を共通化する。これらのswitch文では自然言語のページ名をプログラミング言語でのURL(パス)に変換している。したがって、次のようなメソッドに抽出できる。

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        getPage().get(baseUrl + toPath(pageName));
        Thread.sleep(1000);
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        String currentPath = getPage().getCurrentUrl().substring(baseUrl.length());
        assertThat(pageName, currentPath, is(toPath(pageName)));
        Thread.sleep(1000);
    }

    String toPath(String pageName) {
        switch (pageName) {
        case "ホーム":
            return "";
        default:
            throw new AssertionError("No such page: " + pageName);
        }
    }

これで随分と綺麗になったが、これからたくさんのページが追加されていくことを考慮し、新しいクラスを作成しよう。Cucumberでは、このような補助的なメソッドはsupportフォルダ(ディレクトリ)に配置することが多いため、junitbook.bookstore.cukes.supportパッケージを作成する。クラス名は自然言語からコードへの変換であるため、HumanToCodeクラスとする。

public class HumanToCode {
   
   /**
    * ページ名をパスに変換する
    * @param pageName ページ名
    * @return パス
    * @throws AssertionError ページ名が登録されていない場合
    */
   public static String path(String pageName) {
        switch (pageName) {
        case "ホーム":
            return "";
        default:
            throw new AssertionError("No such page: " + pageName);
        }
    }
}

HumanToCodeクラスの変換メソッドは、ステップ定義から自然な形で利用できるようにstaticメソッドとし、名前も工夫する。クラスを作成したならば、CommonWebStepsクラスで次のようにstatic importして利用する。

import static junitbook.bookstore.cukes.support.HumanToCode.*;

HumanToCodeクラスの実装は、switch文でなくとも、Mapを使ったり、外部リソースにページ名とパスを定義して読み込んでもよいだろう。ただし、ページ名が登録されていない場合は、必ず例外を送出すること。

ステップ定義のリファクタリング(2)

次に気になる個所は、次のように手続き的なコードである。ブラウザ操作のコードをもっと直感的に扱いたいため、seleniumを利用するコードをラップしたクラスを作成し、次のようなコードで記述できるようにする。

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        page.visit(path(pageName));
        Thread.sleep(1000);
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        page.shouldShow(path(pageName));
        Thread.sleep(1000);
    }

ここでは、Cucumberのhook機能を利用しよう。hook機能とは、Cucumberで共通の初期化や終了処理を行うための機能で、JUnitでの@Beforeや@Afterに相当することを実現できる。 それでは、junitbook.bookstore.cukes.support.hooksパッケージを作成し、SharedWebDriverクラスを作成する。

import cucumber.api.java.Before;

public class SharedWebDriver {

    @Before
    public void setUp() {
    }
    
}

hookとなるクラスは、cucumber.api.java.Beforeアノテーション(またはAfterアノテーション)が定義されたメソッドが含まれていれば良い。 後はCucumberテストの実行時に、glueで定義されたパッケージのサブパッケージを検索し、各Hookが実行される。 Hookはシナリオ毎に、シナリオの実行前にBeforeアノテーションが定義された初期化メソッドが、Afterアノテーションが定義された終了メソッドが実行される。

しかし、Hookはシナリオ毎にインスタンスが作成されるため、全シナリオで共通となるBeforeClassアノテーション的な使い方はできない(MLで議論があったがリジェクトされている)。このため、次のようなGlobal Hook Hackを行う。

public class SharedWebDriver {

    static SharedWebDriver INSTANCE;
    
    @Before
    public void setUp() {
        if (INSTANCE == null) {
            INSTANCE = new SharedWebDriver();
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
                public void run() {
                    // 終了時の処理
                }
            });
        }
    }
    
}

以上を考慮し、WebDriverをSharedWebDriverに移動すると次のようになる。

public class SharedWebDriver {

    static SharedWebDriver INSTANCE;
    
    public static SharedWebDriver get() {
        return INSTANCE;
    }

    @Before
    public void setUp() {
        if (INSTANCE == null) {
            INSTANCE = this;
            INSTANCE.baseUrl = "http://localhost:8080/bookstore/";
            File logFile = new File("./logs/chromedriver.log");
            File driverFile = new File("./driver/chromedriver");
            ChromeDriverService service = new ChromeDriverService.Builder().withLogFile(logFile)
                    .usingDriverExecutable(driverFile).build();
            Map<String, String> prefs = new HashMap<String, String>();
            DesiredCapabilities capabilities = DesiredCapabilities.chrome();
            capabilities.setCapability("chrome.prefs", prefs);
            INSTANCE.webDriver = new ChromeDriver(service, capabilities);
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
                public void run() {
                    INSTANCE.webDriver.quit();
                }
            });
        }
    }

    public WebDriver webDriver;
    public String baseUrl;

}
public class CommonWebSteps {

    SharedWebDriver page = SharedWebDriver.get();

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        page.webDriver.get(page.baseUrl + path(pageName));
        Thread.sleep(1000);
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        String currentPath = page.webDriver.getCurrentUrl().substring(page.baseUrl.length());
        assertThat(pageName, currentPath, is(path(pageName)));
        Thread.sleep(1000);
    }
}

これで随分とすっきりした。しかし、まだ手続き的なコードが残っているため、直感的なAPIとして、細かい手続き的な処理はSharedWebDriverに隠蔽しよう。

public class SharedWebDriver {
    
    // 中略

    WebDriver webDriver;
    String baseUrl;

    /**
     * 指定したパスを表示する
     * @param path パス
     */
    public void visit(String path) {
        webDriver.get(baseUrl + path);
    }

    /**
     * 指定したパスを表示しているかを検証する
     * @param path パス
     */
    public void shouldShow(String path) {
        String currentPath = webDriver.getCurrentUrl().substring(baseUrl.length());
        assertThat(currentPath, is(path));
    }
}

最後にステップ定義を修正する。

public class CommonWebSteps {

    SharedWebDriver page = SharedWebDriver.get();

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        page.visit(path(pageName));
        Thread.sleep(1000);
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        page.shouldShow(path(pageName));
        Thread.sleep(1000);
    }
}

SharedWebDriverに定義するメソッドの名前は、RubyのライブラリであるCapyparaを参考にするとRuby on Railsをやる場合に便利であるだけでなく、Railsプログラマと仕事する場合にも都合が良い。 https://github.com/jnicklas/capybara

ステップ定義のリファクタリング(3)

最後の仕上げとして、sleepを解りやすく書き換える。さらに、VM引数で待機時間を設定できるようにすれば便利だろう。

junitbook.bookstore.cukes.stepsパッケージにStepHelperクラスを作成し、static importを利用して扱うwaitIfNeedメソッドを定義する。

public class StepHelper {
    
    static long waitTime = 0L;
    static {
        waitTime = Long.parseLong(System.getProperty("waitTime", "0"));
    }

    public static void waitIfNeed() throws Exception {
        if (waitTime <= 0) return;
        Thread.sleep(waitTime);
    }

}

これでステップ定義クラスは完璧になった。

public class CommonWebSteps {

    SharedWebDriver page = SharedWebDriver.get();

    @もし("^\"([^\"]*)\"ページを表示する$")
    public void ページを表示する(String pageName) throws Throwable {
        page.visit(path(pageName));
        waitIfNeed();
    }

    @ならば("^\"([^\"]*)\"ページが表示されていること$")
    public void ページが表示されていること(String pageName) throws Throwable {
        page.shouldShow(path(pageName));
        waitIfNeed();
    }
}

書籍一覧のシナリオ(正常系)

それでは本格的に受け入れテストの自動化を行っていこう。はじめに書籍の一覧を表示するシナリオを作成する。 フィーチャファイルbook-list.featureを作成しよう。

# language: ja

フィーチャ: 書籍の一覧を表示する

  シナリオ: 書籍が3冊登録されているとき、書籍の一覧が表示される
    前提 以下の書籍が登録されている:
      | タイトル     | 価格  | ページ数 | ISBN       | イラスト | 説明 |
      |JUnit実践入門 | 3465  |     480  | 477415377X |   無     | 体系的に学ぶユニットテストの技法 |
      |Effective Java| 3780  |     327  | 489471499X |   無     | Javaプログラミング書籍の定本 |  
      |創るJava      | 3999  |     672  | 4839932530 |   有     | プログラムを「創り」ながら学ぶ、Javaの学習書 |
    かつ "ホーム"ページが表示されている
    もし "書籍一覧"リンクをクリックする
    ならば "書籍一覧"ページが表示されていること
    かつ 以下の書籍の一覧が表示されていること:
      | タイトル     | 価格  | ページ数 | ISBN       |
      |JUnit実践入門 | 3465  |     480  | 477415377X |
      |Effective Java| 3780  |     327  | 489471499X |  
      |創るJava      | 3999  |     672  | 4839932530 |

Cucumberでは、テーブルレイアウトが利用できる。テーブルレイアウトを利用することでテストシナリオが直感的に理解できるようになる。 シナリオを作成したならば、各ステップを実装していく。

タグ

RunCukeTestsではすべてのフィーチャを実行していくため、シナリオが増えていくとすべてのテストの実行に長い時間がかかるようになる。このようなタスクはCIに任せるべきであり、開発中はおこないたくない。 したがって、タグ機能を行って実行するフィーチャを制御する。タグは、カテゴリ化テストに相当する。

@wip

wip(Work in progress: 仕掛かり中)は、Cucumberを利用している時に最も利用するタグの名前だ。別に@nowでもなんでも良いのだが、@wipがよく利用される。 @wipは、次のようにシナリオの前またはフィーチャの前に付ける。シナリオに定義したならば@wipのついたシナリオのみを、フィーチャに付ければ@wipのついたフィーチャに含まれる全てのシナリオを実行出来るようになる。

# language: ja
@wip
フィーチャ: 書籍の一覧を表示する

  シナリオ: 書籍が3冊登録されているとき、書籍の一覧が表示される
  (省略)

タグを指定してRunCukeTestsを実行する

タグを指定してRunCukeTestsを実行するには、Run Configurationを開き、VM引数に追加オプションを設定する。先ほど作成したwaitTimeと合わせて@wipを実行する設定を作成しておくと良い。

-Dcucumber.options="--tags @wip" -DwaitTime=1500

BookWebStepsの作成

ステップ定義は複数作成することができる。そこで、書籍関連の機能を扱うステップ定義クラスをBookWebStepsとして定義し、今後に備えることとする。適切にクラスを分割することは、マージ作業が楽になることに繋がる。

public class BookWebSteps {
    SharedWebDriver page = SharedWebDriver.get();
}

先ほどリファクタリングしているので、簡単だ。

DataTableの利用

シナリオでテーブルレイアウトを利用していると、次のようなスニペットが提供される。

@前提("^以下の書籍が登録されている:$")
public void 以下の書籍が登録されている(DataTable arg1) throws Throwable {
    // Express the Regexp above with the code you wish you had
    // For automatic conversion, change DataTable to List<YourType>
    throw new PendingException();
}

DataTableは、テーブルレイアウトを読み込んだオブジェクトであり、大きくふたつの使い方ができる。

asMaps

DataTable#asMapsを利用すると、Map<String, String>のリストが取得できる。リストは、1行目をヘッダとして扱うため、テーブルレイアウトの2行目から最後までが含まれる。各Mapのキーは1行目の値であり、バリューは各項目となる。 すなわち、次のようなコードでテーブルレイアウトを出力できる。

    @前提("^以下の書籍が登録されている:$")
    public void 以下の書籍が登録されている(DataTable table) throws Throwable {
        for (Map<String, String> map : table.asMaps()) {
            System.out.println("タイトル:" + map.get("タイトル"));
            System.out.println("価格:" + map.get("価格"));
        }
        waitIfNeed();
    }

DataTable#asMapsはリストの検証や、フィクスチャの定義などの時に便利に使える。

raws

DataTable#rawsを利用すると、Listのリストが取得できる。rawsでは1行目をヘッダとして扱わず、各行の文字列が順番に格納されたリストを返す。 したがって、フォームへの入力項目など、1列目をヘッダとして2列目を値とするような場合に便利に使える。

    かつ 書籍の登録フォームに次のように入力する:
      | タイトル   | JUnit実践入門               |
      | 価格       | 3465                        |  
      | ページ数   | 480                         |  
      | ISBN       | 477415377X                  |
      | イラスト   | 無                          |
      | 説明       | 体系的に学ぶユニットテストの技法  |
        for (List<String> row : table.raw()) {
            System.out.println(row.get(0) + "=" + row.get(1));
        }

フィクスチャのセットアップ

書籍の一覧を表示するシナリオでは、初期データをデータベースに投入しなければならない。このようなフィクスチャの初期化をCucumberテストで行うにはふたつの方針がある。

  • 実際にGUIを操作して登録する
  • データベースに直接アクセスしてデータを登録する

このふたつの方法を比較すると、前者の方がより実際の操作に近いために望ましい。しかし、多くのデータを登録しなければならない場合は時間の無駄であり、登録のUIが提供されていない場合には対応できない。このため、ここではデータベースに直接登録する方法を採用する。

JPAを利用したデータベースのセットアップ

データベースにデータを投入するにはJDBCとSQLを使う方法など幾つかの方法が考えられるが、なるべく楽をするために、プロダクションコードで利用しているJPAのエンティティクラスを活用する。JPAのエンティティクラスはユニットテストを通しているため、安全に利用できるだろう。

Fixturesフックの作成

SharedWebDriverと同様にフィクスチャを扱うためにフック機能を利用してみる。

public class Fixtures {
    /** 1回目の起動時に生成されるEntityManager */
    private static EntityManager entityManager;

    @Before
    public void setUp() {
        // Global hook hack
        if (entityManager == null) {
            Map<String, String> props = new HashMap<>();
            props.put("javax.persistence.jdbc.driver", "org.h2.Driver");
            props.put("javax.persistence.jdbc.url", "jdbc:h2:tcp://localhost:9092/bookstore;SCHEMA=dev");
            props.put("javax.persistence.jdbc.user", "sa");
            props.put("javax.persistence.jdbc.password", "");
            final EntityManagerFactory entityManagerFactory = Persistence
                    .createEntityManagerFactory("junitbook", props);
            entityManager = entityManagerFactory.createEntityManager();
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
                public void run() {
                    entityManager.close();
                    entityManagerFactory.close();
                }
            });
        }
        // テーブルの初期化
        new Tx() {
            @Override
            void invoke(EntityManager entityManager) {
                entityManager.createNamedQuery("deleteAllBooks", Book.class).executeUpdate();
            }
        };
    }

    public static void add(final Book book) {
        new Tx() {
            @Override
            void invoke(EntityManager entityManager) {
                entityManager.persist(book);
            }
        };
    }

    abstract static class Tx {
        abstract void invoke(EntityManager entityManager);

        Tx() {
            execute();
        }

        void execute() {
            EntityTransaction tx = entityManager.getTransaction();
            tx.begin();
            invoke(entityManager);
            tx.commit();
        }
    }

    public static Book newBook(Map<String, String> props) {
        Book book = new Book();
        book.setTitle(props.get("タイトル"));
        book.setPrice(toInt(props.get("価格")));
        book.setDescription(props.get("説明"));
        book.setIsbn(props.get("ISBN"));
        book.setNbOfPage(toInt(props.get("ページ数")));
        if (props.get("イラスト").equals("有")) {
            book.setIllustrations(true);
        } else {
            book.setIllustrations(false);
        }
        return book;
    }

    static Integer toInt(String str) {
        if (str == null || str.isEmpty()) return null;
        return Integer.parseInt(str);
    }
}

今回のワークショップではJPAに関する解説は行わないので、ポイントだけ解説する。 EntityManagerはJPAのエンティティをデータベースに永続化するオブジェクトであり、EntityManagerFactory経由で生成する。データベースの永続化を行う場合には個別にトランザクションを作成するため、最初に1回だけ生成すれば良い。このため、Global hook hackを利用している。 各シナリオでのsetUp時には、データを削除する。この時、createNamedQueryメソッドを使い、Bookクラスに定義されたクエリを実行している。 トランザクションはEntityManagerのgetTransactionメソッドで取得し、beginメソッドで開始し、commitメソッドでコミットする。このコードでは、それらの処理を簡単に記述できるようにTxクラスを定義している(が、new演算子を使うことは避けられないのはJava7の限界)。 newBookメソッドでは、DataTable#asMapsを想定して定義した。

BookWebStepsは次のように記述できるようになった。

    @前提("^以下の書籍が登録されている:$")
    public void 以下の書籍が登録されている(DataTable table) throws Throwable {
        for (Map<String, String> map : table.asMaps()) {
            Fixtures.add(Fixtures.newBook(map));
        }
        waitIfNeed();
    }

前提のステップ定義を追加

Cucumberのシナリオでは、前提・もし・ならばのテストトしての違いを意識することが重要である。ステップ定義の中では同じ操作を行っていても、それが前提条件であれば別途定義した方が良い。

public class CommonWebSteps {

    @前提("^\"([^\"]*)\"ページが表示されている$")
    public void _ページが表示されている(String pageName) throws Throwable {
        page.visit(path(pageName));
        page.shouldShow(path(pageName));
        waitIfNeed();
    }
}

また、次のように各ステップの語尾を注意して記述すると良い。 |前提 | 〜されている| |もし | 〜する| |ならば| 〜されていること|

CSSセレクタとクリック操作のステップ定義を追加

seleniumではCSSセレクタとリンクテキストからHTML上のオブジェクトWebElementを取得できる。しかし、存在しない場合に例外が発生するなどイマイチ使いにくいため、次のようにラップしよう。

public class SharedWebDriver {

    public void click(String textOrSelector) throws Exception {
        findElementByTextOrSelector(textOrSelector).click();
    }
    
    private WebElement findElementByTextOrSelector(String textOrSelector) {
        try {
            return webDriver.findElement(By.cssSelector(textOrSelector));
        } catch (NoSuchElementException e) {
            // not found.
        }
        try {
            return webDriver.findElement(By.linkText(textOrSelector));
        } catch (NoSuchElementException e) {
            // not found.
        }
        throw new AssertionError("can't find element: '" + textOrSelector + "'");
    }
}

これで、ステップ定義は簡潔に書ける。

public class CommonWebSteps {

    @もし("^\"([^\"]*)\"リンクをクリックする$")
    public void _リンクをクリックする(String linkText) throws Throwable {
        page.click(linkText);
        waitIfNeed();
    }
}

リストの検証

seleniumでHTMLの要素を検証するためには、各要素にidなどを定義し、適切に参照できるようにしなければならない。言い換えれば、検証するためにidやclassを割り当てていく。この時、CSSセレクタを利用してどのように取得したいかを考える。 リストを検証する場合は、各カラムにインデックス付きのidを付けると良い。thymeleaf(テンプレートエンジン)では次のようにしてリストにidを付けている。

          <tbody>
            <tr th:each="book,iter:${list}" th:class="${iter.odd}?'odd':'even'" >
              <td>
                <a href="view.html" th:href="@{/book/view.html(id=${book.id})}" th:text="${book.title}" th:id="'book_title_' + ${iter.index}" ></a>
              </td>
              <td class="right" th:text="${book.price}" th:id="'book_price_' + ${iter.index}"></td>
              <td class="right" th:text="${book.nbOfPage}" th:id="'book_nbOfPage_' + ${iter.index}"></td>
              <td th:text="${book.isbn}" th:id="'book_isbn_' + ${iter.index}"></td>
            </tr>
          </tbody>

このように仕込んだ上で、BookWebStepsクラスに一覧の検証ステップを追加する。

public class BookWebSteps {

    @ならば("^以下の書籍の一覧が表示されていること:$")
    public void 以下の書籍の一覧が表示されていること(DataTable table) throws Throwable {
        int index = 0;
        for (Map<String, String> map : table.asMaps()) {
            for (Entry<String, String> entry : map.entrySet()) {
                page.select("#book_" + name(entry.getKey()) + "_" + index).shouldHaveContent(entry.getValue());
            }
            index++;
        }
        waitIfNeed();
    }

    public static String name(String name) {
        switch (name) {
        case "タイトル":
            return "title";
        case "価格":
            return "price";
        case "ページ数":
            return "nbOfPage";
        case "ISBN":
            return "isbn";
        case "説明":
            return "description";
        case "イラスト":
            return "illustrations";
        default:
            throw new AssertionError("No such name: " + name);
        }
    }
}

次のステップへ

以上で、チュートリアルは完了。以後は各フィーチャについて、各自で実装すること。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment