情報畑でつかまえてロゴ
本サイトは NTTテクノロスが旬の IT をキーワードに
IT 部門が今知っておきたい最新テクノロジーに関する情報をお届けするサイトです

中野靖治のGroovy活用術 第9回

Grailsの制約によるバリデーションを効率的にテストする

今回は、Grailsのドメインクラスやコマンドオブジェクトで大変便利な「制約」によるバリデーション機能を、Spockによるデータ駆動テスト(パラメタライズドテスト)で効率的にテストする方法です。

はじめに

こんにちは。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.notmeterror に指定すれば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界隈ではそれなりに知られたテクニックです。

今回の方法がこれらと違うのは、GrailsWebDataBinder を使ってデータバインディングしている点です。 これにより以下のメリットが得られます。

  • String以外の型を定義したプロパティに対して、typeMismatchエラーをテストできる
    • 従来の直接代入方式の場合は、代入時点でキャストエラーになってしまいテストできません。しかし、GrailsWebDataBinder を使うことで型違反時にtypeMismatchエラーが検出されるので、error として typeMismatch を指定して型違反自体をテストすることができます。これにより、安心して String 以外の適切な型を使うことができるようになります。
  • 実際にリクエストからデータバインディングされるのと同じ挙動でテストできる
    • 特に、application.yml上の grails.databinding.convertEmptyStringsToNull (空文字を null と見なすかどうか)、grails.databinding.trimStrings (前後の空白文字列をトリムするかどうか) の設定を読み込んでいるため、設定値でカスタマイズした挙動に対しても正しくテストできます。

おわりに

今回のサンプルコードは、GitHubに公開してあります(Grails 3バージョンのみです)。

コメントや不具合等があればIssuesやPull Requestでお願いいたします。

連載シリーズ
中野靖治のGroovy活用術
著者プロフィール
中野 靖治

ソフトウェア生産技術センター Grails推進室(GGAO)

中野 靖治