Grailsの制約によるバリデーションを効率的にテストする
今回は、Grailsのドメインクラスやコマンドオブジェクトで大変便利な「制約」によるバリデーション機能を、Spockによるデータ駆動テスト(パラメタライズドテスト)で効率的にテストする方法です。
中野靖治のGroovy活用術 第9回
- 2016年06月03日公開
はじめに
こんにちは。NTTソフトウェアの中野です。
今回は、Grailsのドメインクラスやコマンドオブジェクトで大変便利な「制約」によるバリデーション機能を、Spockによるデータ駆動テスト(パラメタライズドテスト)で効率的にテストする方法です。
制約とは?
Grailsにおける「制約」とは、ドメインクラスやコマンドオブジェクトの constraints
静的変数上のクロージャで定義されるもので、プロパティの値に対してその名の通り「制約」をかけます。
定義した制約は、以下の3点において動作に影響します。
- 入力値のバリデーション
save()
時に暗黙的に実行されます。validate()
で明示実行もできます。
- データベースのテーブル作成時のDDL
- たとえば
String
型のプロパティにmaxSize: 10
という制約を書けると、データベース上の型はVARCHAR(10)
になります。
- たとえば
- 生成されるスキャフォルドのフォーム要素
- たとえば
blank: false
を指定すると、スキャフォルドで生成されるGSPファイル内のinput
要素にHTML5のrequired
が付加され、未入力のままではサブミットできなくなります。
- たとえば
今回は、ひとつめの「入力値のバリデーション」についてのみ注目して、期待した通りにバリデーションがかかるかどうかを確認します。
制約テスト用スーパクラスを準備する
制約のテストを簡単にするために、以下の内容のスーパクラスを作成します。 テスト用のヘルパクラスなので、src/test
配下がお勧めです。
// パッケージは適当に変更してください
package test
import grails.databinding.DataBinder
import grails.databinding.SimpleMapDataBindingSource
import grails.web.databinding.GrailsWebDataBinder
import org.grails.config.CodeGenConfig
import spock.lang.Specification
abstract class ConstraintUnitSpec extends Specification {
static final String VALID = 'valid'
DataBinder dataBinder
def setup() {
dataBinder = new GrailsWebDataBinder(null)
// application.ymlでの設定を反映する。
def config = loadConfig()
if (config.containsKey('grails.databinding.convertEmptyStringsToNull')) {
dataBinder.convertEmptyStringsToNull = config['grails.databinding.convertEmptyStringsToNull']
}
if (config.containsKey('grails.databinding.trimStrings')) {
dataBinder.trimStrings = config['grails.databinding.trimStrings']
}
}
void bind(obj, String field, Object value) {
dataBinder.bind(obj, [(field): value] as SimpleMapDataBindingSource)
}
void validateConstraints(obj, String field, String error) {
def valid = obj.validate()
if (error && error != VALID) {
assert !valid
assert obj.errors[field]
assert error == obj.errors[field].code
} else {
assert !obj.errors[field]
}
}
private static CodeGenConfig loadConfig() {
def config = new CodeGenConfig()
def file = new File("grails-app/conf/application.yml")
if (file.exists()) {
config.loadYml(file)
println "Loaded configuration file: $file"
}
return config
}
}
制約のテストを書く
例として以下のようなドメインクラスを考えます。
class Person {
String name
Integer age
static constraints = {
name blank: false, maxSize: 1000
age min: 0
}
}
先ほどの ConstraintUnitSpec
を使うと、このドメインクラスの制約のテストは以下のように書けます。
import grails.test.mixin.TestFor
import spock.lang.Unroll
import test.ConstraintUnitSpec
@TestFor(Person)
class PersonSpec extends ConstraintUnitSpec {
Person person = new Person()
@Unroll
def "validate: #field is #error when value is #value.inspect()"() {
given:
bind(person, field, value)
expect:
validateConstraints(person, field, error)
where:
field | error | value
"name" | "nullable" | null
"name" | "blank" | ""
"name" | "blank" | " " * 1001
"name" | "valid" | "武田信玄"
"name" | "valid" | "x" * 1000
"name" | "maxSize.exceeded" | "x" * 1001
"age" | "nullable" | null
"age" | "nullable" | ""
"age" | "nullable" | " " * 1001
"age" | "typeMismatch" | "NOT_INTEGER"
"age" | "min.notmet" | "-1"
"age" | "valid" | "0"
"age" | "valid" | "17"
}
}
Spockでは、このように where:
ブロックでテストデータの組み合わせを複数定義しておいて(横一列が1つの組み合わせ)、それぞれの組み合わせごとに同一構造のテストコードを繰り返し実行できます。
ここでは、field
としてプロパティ名を、value
にユーザ入力値を指定しています。 更に、そのときに、バリデーションがOKとなる場合は error
に "valid"
を指定します。 バリデーションがNGの場合は、制約の種類ごとに以下のような文字列を指定します(一部抜粋)。
制約 | NG時のerror文字列 |
---|---|
nullable | nullable |
blank | blank |
maxSize | maxSize.exceeded |
minSize | minSize.notmet |
max | max.exceeded |
min | min.notmet |
validator | validator.invalid |
(型違反) | typeMismatch |
[TIPS]実行しながら反復的にテストを完成させる
上記の error
の文字列ですが、単純に制約名と同じかと思いきや、微妙にそうでもなく、覚えるのが大変そうにみえます。 しかし、実はすべてを暗記する必要はありません。
error
を空文字や "HOGE"
などとしておき、実際にテストを実行してあえて失敗させ、そのときのエラーメッセージ上で、どのような文字列を実際に error
に指定すれば良いかを確認すれば良いのです。
それが期待する制約によるエラーであれば、安心してその文字をコピー&ペーストして、where
条件を整えていきます。 もちろん、予期しないエラーの場合に何も考えずにコピペしてはいけません。きちんと字面をチェックしましょう。
たとえば、わざとテストを失敗させた場合に、以下のようにエラーメッセージが出力されたとします。 この場合は、; arguments [age,...
の直前にあるリストの最終要素である min.notmet
を error
に指定すればOKです。 要は「複数のi18nメッセージキーの候補の中で一番最後にある一番シンプルな文字列」がソレです。 出力が多くてごちゃごちゃと見えますが、何度かやればすぐに慣れるでしょう。
Condition not satisfied:
!obj.errors[field]
|| | ||
|| | |age
|| | Field error in object 'sample.Person' on field 'age': rejected value [-1]; codes [sample.Person.age.min.error.sample.Person.age,sample.Person.age.min.error.age,sample.Person.age.min.error.java.lang.Integer,sample.Person.age.min.error,person.age.min.error.sample.Person.age,person.age.min.error.age,person.age.min.error.java.lang.Integer,person.age.min.error,sample.Person.age.min.notmet.sample.Person.age,sample.Person.age.min.notmet.age,sample.Person.age.min.notmet.java.lang.Integer,sample.Person.age.min.notmet,person.age.min.notmet.sample.Person.age,person.age.min.notmet.age,person.age.min.notmet.java.lang.Integer,person.age.min.notmet,min.notmet.sample.Person.age,min.notmet.age,min.notmet.java.lang.Integer,min.notmet]; arguments [age,class sample.Person,-1,0]; default message [Property [{0}] of class [{1}] with value [{2}] is less than minimum value [{3}]]
...(省略)...
テストを実行する
さて、テストを実行した結果も紹介しておきましょう。
./gradlew test
上記のGradleコマンドを実行すると、build/reports/tests/index.html に次のようなテストレポートが出力されます。
(※画像サイズの都合により、テストメソッド名が長大ないくつかのテストメソッドを結果から削除しています。)
Spockの @Unroll
の効果によって、パラメータの組み合わせごとに別々のテストメソッドかあるかのように実行されていることがわかります。 また、SpEL記法を使ったテストメソッド名に各パラメータの値が自動的に展開されているので、それぞれのテストがどのような条件で何を期待しているのかが、レポートを読むだけでもすぐにわかります。
[おまけ] 改善ポイント
今回紹介した手法は、実は既にインターネット上で出回っていてGrails界隈ではそれなりに知られたテクニックです。
- http://www.christianoestreich.com/2012/11/domain-constraints-grails-spock-updated/
- http://yamkazu.hatenablog.com/entry/20120519/1337434864
今回の方法がこれらと違うのは、GrailsWebDataBinder
を使ってデータバインディングしている点です。 これにより以下のメリットが得られます。
String
以外の型を定義したプロパティに対して、typeMismatchエラーをテストできる- 従来の直接代入方式の場合は、代入時点でキャストエラーになってしまいテストできません。しかし、
GrailsWebDataBinder
を使うことで型違反時にtypeMismatchエラーが検出されるので、error
としてtypeMismatch
を指定して型違反自体をテストすることができます。これにより、安心してString
以外の適切な型を使うことができるようになります。
- 従来の直接代入方式の場合は、代入時点でキャストエラーになってしまいテストできません。しかし、
- 実際にリクエストからデータバインディングされるのと同じ挙動でテストできる
- 特に、application.yml上の
grails.databinding.convertEmptyStringsToNull
(空文字をnull
と見なすかどうか)、grails.databinding.trimStrings
(前後の空白文字列をトリムするかどうか) の設定を読み込んでいるため、設定値でカスタマイズした挙動に対しても正しくテストできます。
- 特に、application.yml上の
おわりに
今回のサンプルコードは、GitHubに公開してあります(Grails 3バージョンのみです)。
コメントや不具合等があればIssuesやPull Requestでお願いいたします。
JVM上で動作する動的型付け言語であるGroovyと、Groovyで記述するWebアプリケーションフレームワークのGrailsを社内外へ推進するために日々奮闘している。 Groovyスクリプトの起動時間を短縮するGroovyServや、GroovyスクリプトでのExcel操作を劇的に楽にするGExcelAPIなどのOSSを業務/プライベートで開発、 一般に公開。Groovy/Grails/GradleなどのOSSへのバグフィックスや機能パッチの提供などにも積極的に貢献している。 また、国内外(JJUG CCC、Java Day Tokyo, Gr8conf EUなど)での講演や書籍執筆などでも活動中。 著書に『プログラミングGroovy』(技術評論社/共著)がある。自他共に認めるビール党。