GrailsでCucumberを使用する
Ruby、Railsの世界では、プレーンテキストで記述できる受け入れテストツールとして有名な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 }
classpathはsourceSets.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/groovy
やsrc/java
といったディレクトリでは自動的にはbeanとして登録されません。
src/groovy
、src/java
配下のクラスをbeanとして登録したい場合はSpring Bean DSLを使用して登録することができますが、もう一つの方法としてSpringのcomponent-scan
を使用する方法がGrailsでも提供されています。
component-scan
を使用すると指定したパッケージ配下のクラスに対してアノテーションベースでbean登録ができるようになります。
設定の準備
Grailsでcomponent-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-ctx
、test-ctx
、test-ctx
のchangeSetを実行 - dbm-update --contexts="test" -
default-ctx
、test-ctx
のchangeSetを実行 - dbm-update --contexts="test,prod" -
default-ctx
、test-ctx
、test-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") } }
addColumn
でconstraints(nullable: "false")
とせず、(年齢を一律30才としていいかはおいといて)一度値を設定した後に、addNotNullConstraint
を使用してNotNull
制約を追加しています。
rollback
はchangeSet配下に複数のコマンドがある場合は自動でロールバック処理を作成しません。自動生成させるためにchangeSetをコマンド毎に分けるという案もありますが、ここではグループ化して、明示的にroolback
を指定しています。
addNotNullConstraintのdefaultNullValueを使用する
上記では明示的にUPDATE
をsql
コマンドを使用して設定しましたが、単純な値セットだけならば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 SQLとCustom SQL Fileについて紹介します。
Custom 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
を指定するとコメントが削除され、splitStatements
にfalse
を指定すると;
でステートメントが分割されず、ひとつの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も自動ロールバックには対応していないため、自身でロールバック処理を記述する必要があります。