GrailsでCucumberを使用する

RubyRailsの世界では、プレーンテキストで記述できる受け入れテストツールとして有名なCucumberですが、Pure-Javaで実装されたcucumber-jvmのお陰で、Grailsでもプラグインをインストールすることで簡単にcucumberが利用できるようなります。

準備

cucumber pluginをBuildConfig.groovyに追加しインストールします。

plugins {
    ...
    test ":cucumber:0.8.0"
}

featureの準備

デフォルトではcucumberに必要なfeatureやstepファイルはtest/functionalに格納します。これは設定ファイルで変更することも可能です。 まず始めに以下の様なfeatureをtest/functional/NewBook.featureに作成します。

#language: ja

フィーチャ: 新しい本の登録
    本の所有者として
    私はBookTrackerに自分の本を追加したい
    自分でそれを覚えておく必要がないように

シナリオ: 新しい本
    前提 BookTrackerを開く
    もし "Specification by Example"を追加する
    ならば "Specification by Example"の詳細を参照できる

日本語でfeatureファイルを記述するには#language: jaをファイルの先頭に追加する必要があります。

実行

実行はGrailsのfuctionalテストとして実行します。

grails test-app functional:
grails test-app :cucumber

まだstepを実装していないため、以下のようなエラーが出力されるはずです。

You can implement missing steps with the snippets below:

前提(~'^BookTrackerを開く$') { ->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}
もし(~'^"([^"]*)"を追加する$') { String arg1 ->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}
ならば(~'^"([^"]*)"の詳細を参照できる$') { String arg1 ->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}

stepの実装

test/functional/steps/Book_steps.groovyを作成してstepを実装していきます。デフォルトではstepはtest/functional/steps/ディレクトリに格納する必要があります。step実装の主な流れは以下のようになります。

  • 先ほどの出力をコピーする
  • PendingExceptionをインポートする
  • JAのlanguageをインポートする
  • (ダブルクオートで使用している場合は$をエスケープする、GStringの制約のため)

これを実施すると以下のようなファイルになります。

import cucumber.runtime.PendingException

this.metaClass.mixin(cucumber.api.groovy.JA)
// 以下のようにstaticインポートしても同じ
//import static cucumber.api.groovy.JA.*

前提(~'^BookTrackerを開く$') {->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}
もし(~'^"([^"]*)"を追加する$') { String arg1 ->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}
ならば(~'^"([^"]*)"の詳細を参照できる$') { String arg1 ->
    // Express the Regexp above with the code you wish you had
    throw new PendingException()
}

ここで再度テストを実行すると、PendingExceptionがスローされます。それでは次に必要な実装を追加していきます。

前提(~'^BookTrackerを開く$')

必要なセットアップがあるならここで実装を追加しますが、今回は特に必要なセットアップが存在しないため、単に何もしないように変更します。

前提(~'^BookTrackerを開く$') {->
    // NOP
}

もし(~'^"([^"]*)"を追加する$')

まず、必要となるdomainとcontrollerを実装します。今回作成するcontrollerは単にJSONを返すインタフェースになっています。

domain:

package books

class Book {
    String title
}

controller:

package books

import grails.converters.JSON

class BookController {

    def add() {
        render new Book(params).save() as JSON
    }

}

次にstepを次のように実装します。

もし(~'^"([^"]*)"を追加する$') { String bookTitle ->
    bookController = new BookController()
    bookController.params.title = bookTitle
    bookController.add()
}

ここでは詳細には説明しませんが、これはGrailsにおけるcontrollerのテストの仕組みにそってテスト実装する必要があります。 この状態で再度テストを実行するとjava.lang.IllegalStateExceptionがスローされます。 これを解消するには、コントローラが外部から呼ばれているように見せるために、コントローラのテストに必要なセットアップとクリーンアップのコードを追加する必要があります。

Before & After

test/functional/hooks/env.groovyに以下のようなファイルを追加します。これによりコントローラをテストする際のモック機能が有効になります。

import org.codehaus.groovy.grails.test.support.GrailsTestRequestEnvironmentInterceptor

this.metaClass.mixin(cucumber.api.groovy.Hooks)

GrailsTestRequestEnvironmentInterceptor scenarioInterceptor

Before() {
    scenarioInterceptor = new GrailsTestRequestEnvironmentInterceptor(appCtx)
    scenarioInterceptor.init()
}

After() {
    scenarioInterceptor.destroy()
}

この状態でテストを実施すると、先ほどのjava.lang.IllegalStateExceptionがスローされずにstepの最後のブロックで、PendingExceptionが発生します。

ならば(~'^"([^"]*)"の詳細を参照できる$')

以下のように実装します。

ならば(~'^"([^"]*)"の詳細を参照できる$') { String bookTitle ->
    def actual = bookController.response.json

    assert actual.id
    assert actual.title  == bookTitle
}

controllerのレスポンスに格納されているjsonの値を参照し、saveした際に生成されるidと、paramsから取得したtitleが正しく設定されているか検証しています。

最後にもう一度テストを実行してみます。

$ grails test-app functional:
| Server running. Browse to http://localhost:8080/cucumber
| Running 1 cucumber test...
| Completed 1 cucumber test, 0 failed in 2429ms
| Tests PASSED - view reports in /Users/yamkazu/IdeaProjects/grails-examples/cucumber/target/test-reports

うまくテストが通りました。

まとめ

Grailsで簡単にCucumberが利用できました! 次回は?Gebとの連携を紹介します。

参考

GradleのプロジェクトでGroovy Consoleを起動する

GradleのプロジェクトでGroovy Consoleを起動できると、gradleプロジェクトで依存するライブラリとプロダクトコードをクラスパスにロードした状態で、ちょっとしたコードの動作を試せるようになる。

build.gradleに以下を追加してgradle consoleを実行する。

task console(dependsOn: 'classes', type: JavaExec) {
    main = 'groovy.ui.Console'
    classpath = sourceSets.test.runtimeClasspath
}

classpathsourceSets.main.runtimeClasspathでも問題ないが、testにしておくとtestスコープにspockなどを含んでおき、spockを使用して動作確認をしたい場合に都合が良い。

参考: http://piraguaconsulting.blogspot.jp/2012/02/gradle-groovy-console.html

GrailsでアノテーションベースでBeanを登録する

元ネタ

Grailsではgrails-app/serviceディレクトリ配下などにクラスを置くと自動的にSpringのbeanとして認識されますが、src/groovysrc/javaといったディレクトリでは自動的にはbeanとして登録されません。

src/groovysrc/java配下のクラスをbeanとして登録したい場合はSpring Bean DSLを使用して登録することができますが、もう一つの方法としてSpringのcomponent-scanを使用する方法がGrailsでも提供されています。

component-scanを使用すると指定したパッケージ配下のクラスに対してアノテーションベースでbean登録ができるようになります。

設定の準備

Grailscomponent-scanを使用するにはConfig.groovyでgrails.spring.bean.packagesを指定します。

grails.spring.bean.packages = ["grails.example"]

これであとはgrails.example配下にアノテーションベースで定義したクラスを置くことで自動的にbean登録されます。

アノテーションベースでbeanを定義する

Grails特有のルールというのは基本的になくSpringのルールに従うだけです。詳細はSpringのドキュメントを参照してくだい。

いくつかサンプルを紹介します。

Springのアノテーションを使用して登録する

Springの@Component@Autowired@Qualifierなどを使用して登録します。

package grails.example

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component

@Component
class MyBean {

    @Autowired
    MyBeanHoge myBeanHoge

    @Autowired
    @Qualifier("myPiyo")
    def piyo

    @Autowired
    @Qualifier("grailsApplication")
    def grailsApplication

    String toString() { "This is MyBean" }
}

@Component
class MyBeanHoge {
    String toString() { "This is MyBeanHoge" }
}

@Component("myPiyo")
class MyBeanPiyo {
    String toString() { "This is MyBeanPiyo" }
}

JSR330を使って登録する

JSR330の@Inject@Namedを使用して登録します。

package grails.example

import javax.inject.Inject
import javax.inject.Named

@Named
class NamedBean {

    @Inject
    NamedBeanHoge namedBeanHoge

    @Inject
    @Named("namedPiyo")
    def piyo

    @Inject
    @Named("grailsApplication")
    def grailsApplication

    String toString() { "This is NamedBean" }
}

@Named
class NamedBeanHoge {
    String toString() { "This is NamedBeanHoge" }
}

@Named("namedPiyo")
class NamedBeanPiyo {
    String toString() { "This is NamedBeanPiyo" }
}

JSR330のアノテーションを使用するには依存ライブラリの追加が必要です。

dependencies {
    ...
    compile 'javax.inject:javax.inject:1'
}

JSR250系も使える

package grails.example

import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Named

@Named
class PostConstructAndPreDestroyBean {

    def number

    @PostConstruct
    def init() { number = 100 }

    @PreDestroy
    def destroy() {}
}

Database Migration Pluginでcontextを使用する

LiquibaseではchangeSetにcontext属性が設定できます。このcontext属性を使用することで、実行時に適用するchangeSetの範囲を指定できます。

databaseChangeLog = {

    changeSet(author: "yamkazu", id: "default-ctx") {
        ...
    }

    changeSet(author: "yamkazu", id: "prod-ctx", context: "prod") {
        ...
    }

    changeSet(author: "yamkazu", id: "test-ctx", context: "test") {
        ...
    }
}

実行時にcontextを指定するには--contextsオプションを使用します。

grails> dbm-update --contexts="test"

いくつかの特徴的なルールがあるので以下にまとめます。

  • 複数指定する場合はカンマで区切る
  • changeSetでcontextが無指定の場合は常に実行される
  • --contextsを指定しないとすべてのコンテキストが対象になる

後ろ2つが若干わかりにくいですが、--contextsの指定と実行されるidは以下のようになります。

  • dbm-update - default-ctxtest-ctxtest-ctxのchangeSetを実行
  • dbm-update --contexts="test" - default-ctxtest-ctxのchangeSetを実行
  • dbm-update --contexts="test,prod" - default-ctxtest-ctxtest-ctxのchangeSetを実行

特に指定しないとすべてのコンテキスト実行される点は注意が必要です。例えばtestのようなコンテキストを作成した時点で、本番環境に適用する際には明示的に何かのコンテキストを指定しないと、testを含む全てのchangeSetが適用されるといった動作をします。この場合は実際にそのコンテキストが存在するかは別として、明示的に何らかの適当なコンテキストを指定しておく必要があります。

updateOnStartでアプリケーション起動時に連動させるにはConfig.groovyでgrails.plugin.databasemigration.updateOnStartContextを指定してください。

grails.plugin.databasemigration.updateOnStartContext="prod"

Database Migration Pluginで毎回実行するchangelogを定義する

runAlways属性をtrueに設定することで毎回実行するchangelogを定義できます。runOnChange属性がtrueの場合ではchangesetのチェックサムが変更になった時のみ実行しますが、runAlways属性をtrueにするとチェックサム変更有無に関係なく毎回実行してくれます。

changeSet(author: "yamkazu", id: "create-dummy-data", runAlways: true, runOnChange: true) {
    sql("DELETE FROM person")
    sql("""
        |INSERT INTO person ( id, version, name ) VALUES ( nextval('hibernate_sequence'), 0, 'tanaka' );
        |INSERT INTO person ( id, version, name ) VALUES ( nextval('hibernate_sequence'), 0, 'sato' );
        """.stripMargin())
}

リファレンスを読む限り

Executes the change set on every run, even if it has been run before

と記述されておりrunAlwaysだけ付与すれば動くように読めますが現時点(Database Migration Plugin 1.3.2、Liquibase 2.0.5)ではrunOnChangeをtrueにしないとチェックサムのエラーになります。

こんな投稿を発見したが、いまいち直感的じゃない気がします。

Database Migration PluginでNotNull制約のカラムを追加する

既存のデータが存在する場合に、NotNull制約が付与されたカラムを追加する場合は少し工夫が必要です。単にカラムを追加すると既存のデータがNULLになってしまうためエラーとなります。これを回避するには一度NotNull制約を付与せずにカラムを追加し、既存データに対してUPDATEをかけた上で、NotNull制約を追加してあげる必要があります。

以下のドメインがあるとします。

class Person {
    String name
}

以下のchangesetでデータベースと同期済みであるとします。

changeSet(author: "yamkazu (generated)", id: "1362294228819-1") {
    createTable(tableName: "person") {
        column(name: "id", type: "int8") {
            constraints(nullable: "false", primaryKey: "true", primaryKeyName: "personPK")
        }

        column(name: "version", type: "int8") {
            constraints(nullable: "false")
        }

        column(name: "name", type: "varchar(255)") {
            constraints(nullable: "false")
        }
    }
}

さらにデータベースには以下のデータが入っているとします。

 id | version |  name
----+---------+--------
  1 |       0 | yamada
  2 |       0 | sato

単純にカラムを追加するとエラーとなる

ドメインにageのプロパティを追加します。

class Person {
    String name
    Integer age
}

この状態でdbm-gorm-diffコマンドを使用すると以下のようなchangelogを生成します。

changeSet(author: "yamkazu (generated)", id: "1362294947235-1") {
    addColumn(tableName: "person") {
        column(name: "age", type: "int4") {
            constraints(nullable: "false")
        }
    }
}

このchangesetを反映するためにdbm-updateを実行します。

| Error 2013-03-03 16:19:10,773 [main] ERROR liquibase  - Change Set changelog-0.1.groovy::1362294947235-1::yamkazu (generated) failed.  Error: Error executing SQL ALTER TABLE person ADD age int4 NOT NULL: ERROR: column "age" contains null values
Message: Error executing SQL ALTER TABLE person ADD age int4 NOT NULL: ERROR: column "age" contains null values

期待した通りエラーとなりました。

addNotNullConstraintを使用する

エラーを回避するためには、はじめに記述したように一度NotNull制約を付与せずにカラムを追加し、既存データに対してUPDATEをかけた上で、NotNull制約を追加します。NotNull制約を追加するにはaddNotNullConstraintが使用できます。

addNotNullConstraintの詳細はリファレンスを参照してください。

changeSet(author: "yamkazu (generated)", id: "1362294947235-1") {
    addColumn(tableName: "person") {
        column(name: "age", type: "int4")
    }
    sql("UPDATE person SET age = 30")
    addNotNullConstraint(tableName: "person", columnName: "age")
    rollback {
        dropColumn(tableName: "person", columnName: "age")
    }
}

addColumnconstraints(nullable: "false")とせず、(年齢を一律30才としていいかはおいといて)一度値を設定した後に、addNotNullConstraintを使用してNotNull制約を追加しています。

rollbackはchangeSet配下に複数のコマンドがある場合は自動でロールバック処理を作成しません。自動生成させるためにchangeSetをコマンド毎に分けるという案もありますが、ここではグループ化して、明示的にroolbackを指定しています。

addNotNullConstraintのdefaultNullValueを使用する

上記では明示的にUPDATEsqlコマンドを使用して設定しましたが、単純な値セットだけならばaddNotNullConstraintのdefaultNullValueが使用できます。

changeSet(author: "yamkazu (generated)", id: "1362294947235-1") {
    addColumn(tableName: "person") {
        column(name: "age", type: "int4")
    }
    addNotNullConstraint(tableName: "person", columnName: "age", defaultNullValue: "30")
    rollback {
        dropColumn(tableName: "person", columnName: "age")
    }
}

defaultNullValueを使用すると以下のことを自動でやってくれます。

UPDATE person SET age = '30' WHERE age IS NULL;
ALTER TABLE person ALTER COLUMN  age SET NOT NULL;

単純な値セットであればdefaultNullValueで十分ですが、他のテーブル、カラムから値を算出するといった場合には使用できないため、その場合は先程のsqlコマンドなどを使用してください。

Database Migration Pluginで任意のSQLを実行する

Database Migration PluginではLiquibaseで使用可能なchangesetのコマンドが、groovyフォーマットのchangesetでも同様に使用可能になっています。

使用可能なコマンドの一覧はLiquibaseのリファレンスを参照してください。

今日はこの中からCustom SQLCustom SQL Fileについて紹介します。

Custom SQL

Custom SQLは任意のSQLを実行するコマンドです。

changeSet(author: "yamkazu (generated)", id: "create-person") {
    sql("CREATE TABLE person ( id int8 primary key, name varchar(255) )")
    sql("""
        |INSERT INTO person ( id, name ) VALUES ( 1, 'tanaka' );
        |INSERT INTO person ( id, name ) VALUES ( 2, 'sato' );
        """.stripMargin())
    sql([stripComments: true, splitStatements: false],
        """
        |INSERT INTO person ( id, name ) VALUES ( 3, 'suzuki' );   -- insert suzuki
        |INSERT INTO person ( id, name ) VALUES ( 4, 'yamamoto' ); -- insert yamamoto
        """.stripMargin())
    rollback {
        sql("DELETE FROM person")
        dropTable(tableName: "person")
    }
}

例を見ればだいたい使い方が想像できると思います。

一番シンプルな使い方はsql("...")形式で実行したいSQLを指定するだけです。

属性を付与しつつ内容を記述する場合はsql([stripComments: true, splitStatements: false],"…")のように第1引数にmapで属性を指定し、第2引数にクエリーの文字列を指定します。いくつか属性がありますがstripComments属性にtrueを指定するとコメントが削除され、splitStatementsfalseを指定すると;でステートメントが分割されず、ひとつの1つのステートメントとして実行されます。

sqlコマンドを使用する際の注意点ですが、自動ロールバックに対応していないということです。createTableといったコマンドではデフォルトでそれに対するロールバックが定義されていますが、sqlではそれがないため明示的にrollbackコマンドを使用して、このchangeSetをロールバックする処理を記述する必要があります。

roolbackは直接SQLを記述したりchangeSetで使用可能なコマンドが使用できます。詳細はリファレンスを参照してください。

Custom SQL File

Custom SQL Fileは任意のSQLファイルを実行できます。

changeSet(author: "yamkazu (generated)", id: "create-person") {
    sqlFile(path: "create-person.sql")
    sqlFile(path: "sql/person-data-1.sql")
    sqlFile(path: "/sql/person-data-2.sql", stripComments: true, splitStatements: false)
    rollback {
        dropTable(tableName: "person")
    }
}

pathにファイルを指定することで使用できます。ファイルはクラスパスから読みだされchangelog.groovyを起点としてた相対パス、または絶対パスが使用できます。

Custom SQLと同様にCustom SQL Fileも自動ロールバックには対応していないため、自身でロールバック処理を記述する必要があります。