How to test Domain class constraints?
Authors: Sergio del Amo
Grails Version: 3.3.8
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
In this guide, you are going to learn how to define constraints for your Domain classes and test those constraints in isolation.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-test-domain-class-constraints.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-test-domain-class-constraints/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-test-domain-class-constraints/complete
|
3 Writing the Application
3.1 Domain Class
Create a persistent entity to store Hotel entities. Most common way to handle persistence in Grails is the use of Grails Domain Classes:
A domain class fulfills the M in the Model View Controller (MVC) pattern and represents a persistent entity that is mapped onto an underlying database table. In Grails a domain is a class that lives in the grails-app/domain directory.
./grailsw create-domain-class Hotel
CONFIGURE SUCCESSFUL
| Created grails-app/domain/demo/Hotel.groovy
| Created src/test/groovy/demo/HotelSpec.groovy
The Hotel
domain class is our data model. We define different properties to store the Hotel
characteristics.
package demo
@SuppressWarnings('DuplicateNumberLiteral')
class Hotel {
String name
String url
String email
String about
BigDecimal latitude
BigDecimal longitude
static constraints = {
name blank: false, maxSize: 255
url nullable: true, url: true, maxSize: 255
email nullable: true, email: true, unique: true
about nullable: true
// tag::latitudeCustomValidator[]
latitude nullable: true, validator: { val, obj, errors ->
if ( val == null ) {
return true
}
if (val < -90.0) {
errors.rejectValue('latitude', 'range.toosmall')
return false
}
if (val > 90.0) {
errors.rejectValue('latitude', 'range.toobig')
return false
}
true
}
// end::latitudeCustomValidator[]
longitude nullable: true, validator: { val, obj, errors ->
if ( val == null ) {
return true
}
if (val < -180.0) {
errors.rejectValue('longitude', 'range.toosmall')
return false
}
if (val > 180.0) {
errors.rejectValue('longitude', 'range.toobig')
return false
}
true
}
}
// tag::hotelMapping[]
static mapping = {
about type: 'text'
}
// end::hotelMapping[]
}
We define multiple validation constraints
Constraints provide Grails with a declarative DSL for defining validation rules, schema generation and CRUD generation meta data.
Grails comes with multiple constraints ready to use:
You can define your custom validators if needed. |
3.2 Unit Tests
We want to unit test our validation constraints.
Implement the DomainUnitTest
trait. It indicates we are testing a Grails artifact; a domain class.
class HotelSpec extends Specification implements DomainUnitTest<Hotel> {
This is the key take-away of this guide:
When you pass a list of Strings to the validate method, the validate method will only validate the properties you pass. A domain class may have many properties. This allows you to tests properties validation on isolation.
Name property constraints testing
We want to map our name
property to a relational database with a VARCHAR(255). We
want this property to be required and with a max length of 255 characters.
void 'test name cannot be null'() {
when:
domain.name = null
then:
!domain.validate(['name'])
domain.errors['name'].code == 'nullable'
}
void 'test name cannot be blank'() {
when:
domain.name = ''
then:
!domain.validate(['name'])
}
void 'test name can have a maximum of 255 characters'() {
when: 'for a string of 256 characters'
String str = 'a' * 256
domain.name = str
then: 'name validation fails'
!domain.validate(['name'])
domain.errors['name'].code == 'maxSize.exceeded'
when: 'for a string of 256 characters'
str = 'a' * 255
domain.name = str
then: 'name validation passes'
domain.validate(['name'])
}
Url property constraints testing
We want to map our url
property to a relational database with a VARCHAR(255). We
want this property to be either a valid url or a null or blank value.
@Ignore
void 'test url can have a maximum of 255 characters'() {
when: 'for a string of 256 characters'
String urlprefifx = 'http://'
String urlsufifx = '.com'
String str = 'a' * (256 - (urlprefifx.size() + urlsufifx.size()))
str = urlprefifx + str + urlsufifx
domain.url = str
then: 'url validation fails'
!domain.validate(['url'])
domain.errors['url'].code == 'maxSize.exceeded'
when: 'for a string of 256 characters'
str = "${urlprefifx}${'a' * (255 - (urlprefifx.size() + urlsufifx.size()))}${urlsufifx}"
domain.url = str
then: 'url validation passes'
domain.validate(['url'])
}
@Unroll('Hotel.validate() with url: #value should have returned #expected with errorCode: #expectedErrorCode')
void "test url validation"() {
when:
domain.url = value
then:
expected == domain.validate(['url'])
domain.errors['url']?.code == expectedErrorCode
where:
value | expected | expectedErrorCode
null | true | null
'' | true | null
'http://hilton.com' | true | null
'hilton' | false | 'url.invalid'
}
Email property constraints testing
We want email
to be either a valid email or a null or blank value.
@Unroll('Hotel.validate() with email: #value should have returned #expected with errorCode: #expectedErrorCode')
void "test email validation"() {
when:
domain.email = value
then:
expected == domain.validate(['email'])
domain.errors['email']?.code == expectedErrorCode
where:
value | expected | expectedErrorCode
null | true | null
'' | true | null
'[email protected]' | true | null
'hilton' | false | 'email.invalid'
}
Email validator ensures it has less than 255 characters |
Email Unique constraint testing
We have added a unique
constraint to the Hotel’s email address.
Unique: It constrains a property as unique at the database level. Unique is a persistent call and will query the database.
You can test a unique constraint with such a test:
package demo
import grails.test.hibernate.HibernateSpec
@SuppressWarnings('MethodName')
class HotelEmailUniqueConstraintSpec extends HibernateSpec {
List<Class> getDomainClasses() { [Hotel] }
def "hotel's email unique constraint"() {
when: 'You instantiate a hotel with name and an email address which has been never used before'
def hotel = new Hotel(name: 'Hotel Transilvania', email: '[email protected]')
then: 'hotel is valid instance'
hotel.validate()
and: 'we can save it, and we get back a not null GORM Entity'
hotel.save()
and: 'there is one additional Hotel'
Hotel.count() == old(Hotel.count()) + 1
when: 'instanting a different hotel with the same email address'
def hilton = new Hotel(name: 'Hilton Hotel', email: '[email protected]')
then: 'the hotel instance is not valid'
!hilton.validate(['email'])
and: 'unique error code is populated'
hilton.errors['email']?.code == 'unique'
and: 'trying to save fails too'
!hilton.save()
and: 'no hotel has been added'
Hotel.count() == old(Hotel.count())
}
}
About property constraints testing
We want about
to be a null, blank or a block of text. We don’t want about
to be constrained
to 255 characters. We used the mapping
block in our domain class to indicate this characteristic.
static mapping = {
about type: 'text'
}
These are the tests to validate about
constraints.
void 'test about can be null'() {
when:
domain.about = null
then:
domain.validate(['about'])
}
void 'test about can be blank'() {
when:
domain.about = ''
then:
domain.validate(['about'])
}
void 'test about can have a more than 255 characters'() {
when: 'for a string of 256 characters'
String str = 'a' * 256
domain.about = str
then: 'about validation passes'
domain.validate(['about'])
}
Latitude and longitude properties constraints testing
We want latitude
to be null or a value between -90 to 90 degrees.
We want longitude
to be null or a value between -180 to 180 degrees.
You maybe tempted to use:
latitude nullable: true, range: -90..90
longitude nullable: true, range: -180..180
However, a value such as latitude
of 90.1 will be a valid value using the Range Constraint
Set to a Groovy range which can contain numbers in the form of an IntRange, dates or any object that implements Comparable and provides next and previous methods for navigation.
Instead we are using a custom validation.
latitude nullable: true, validator: { val, obj, errors ->
if ( val == null ) {
return true
}
if (val < -90.0) {
errors.rejectValue('latitude', 'range.toosmall')
return false
}
if (val > 90.0) {
errors.rejectValue('latitude', 'range.toobig')
return false
}
true
}
These are the tests to validate latitude
and longitude
constraints.
@Unroll('Hotel.validate() with latitude: #value should have returned #expected with errorCode: #expectedErrorCode')
void 'test latitude validation'() {
when:
domain.latitude = value
then:
expected == domain.validate(['latitude'])
domain.errors['latitude']?.code == expectedErrorCode
where:
value | expected | expectedErrorCode
null | true | null
0 | true | null
0.5 | true | null
90 | true | null
90.5 | false | 'range.toobig'
-90 | true | null
-180 | false | 'range.toosmall'
180 | false | 'range.toobig'
}
@Unroll('Hotel.longitude() with latitude: #value should have returned #expected with error code: #expectedErrorCode')
void 'test longitude validation'() {
when:
domain.longitude = value
then:
expected == domain.validate(['longitude'])
domain.errors['longitude']?.code == expectedErrorCode
where:
value | expected | expectedErrorCode
null | true | null
0 | true | null
90 | true | null
90.1 | true | null
-90 | true | null
-180 | true | null
180 | true | null
180.1 | false | 'range.toobig'
-180.1 | false | 'range.toosmall'
}
4 Testing the Application
To run the tests:
./grailsw
grails> test-app
grails> open test-report
or
./gradlew check
open build/reports/tests/index.html