Friday, May 4, 2012

Building a URL Shortener in Grails - Pt 2 - Unit Testing Constraints

In the next few tutorials we will use unit, integration and web tests to address some of the defects in a Grails URL Shortener we created in a previous post.

This time we are focusing on testing and fixing domain class constraints with unit tests.

During these tutorials we will take a Test Driven Development (TDD) approach by creating tests first to find our defects before fixing the problems and watching our tests pass.

The Grails User Guide has a very nice section on testing if you need more information.

This tutorial has the same pre-requisites as the previous tutorial. We're starting with our slink application at the same point it was when we left it last time. If you haven't done the previous tutorial or don't have a copy, you can find it tagged as of the end of the last tutorial on GitHub.

The Story so Far

Our URL shortener consists of a single redirector controller to handle the redirection to the target URLs and a set of scaffolded admin screens to manage the creation, update and delete of short link definitions.

To get started, open a command prompt or terminal and navigate to the folder containing slink. Once there, enter the grails command line and run the application.

Jupiter:slink bendavies$ grails
| Downloading: plugins-list.xml
grails> run-app
| Server running. Browse to http://localhost:8080/
| Application loaded in interactive mode. Type 'exit' to shutdown.
| Enter a script name to run. Use TAB for completion: 
grails> 

Return to the command prompt and test the application using the test-app command:

grails> test-app
| Running 3 unit tests... 1 of 3
| Failure:  testSomething(slink.AdminControllerTests)
|  java.lang.AssertionError: Implement me
 at org.junit.Assert.fail(Assert.java:93)
 at slink.AdminControllerTests.testSomething(AdminControllerTests.groovy:15)
| Running 3 unit tests... 2 of 3
| Failure:  testSomething(slink.RedirectorControllerTests)
|  java.lang.AssertionError: Implement me
 at org.junit.Assert.fail(Assert.java:93)
 at slink.RedirectorControllerTests.testSomething(RedirectorControllerTests.groovy:15)
| Running 3 unit tests... 3 of 3
| Failure:  testSomething(slink.ShortlinkTests)
|  java.lang.AssertionError: Implement me
 at org.junit.Assert.fail(Assert.java:93)
 at slink.ShortlinkTests.testSomething(ShortlinkTests.groovy:15)
| Completed 3 unit tests, 3 failed in 1315ms
| Packaging Grails application.....
| Tests FAILED  - view reports in target/test-reports

When called without parameters, the test-app command runs all the tests - unit integration and functional tests. You might recall that when we created our admin controller, redirector controller and short link domain class a test was automatically generated for all three.

The above output of the test-app command shows that all three unit tests fail. Grails creates each unit test case with a single test that will that forces a test failure.

If you would like to see a pretty HTML report of your most recent test executions, type open test-report at the Grails command prompt.

Grails has a home for all tests, separate from the source code of the application, in a folder named test immediately below the application's home folder. Here is what the domain class Shortlink's unit test looks like, unchanged from when it was generated with our create-domain-class Shortlink command in the first tutorial, and found under /test/unit/slink as ShortlinkTests.groovy:

package slink
import grails.test.mixin.*
import org.junit.*

/**
 * See the API for {@link grails.test.mixin.domain.DomainClassUnitTestMixin} for usage instructions
 */
@TestFor(Shortlink)
class ShortlinkTests {

    void testSomething() {
       fail "Implement me"
    }
}

From this we can see only one test is defined, testSomething(), and it uses the fail() method to force a failure lest we forget to implement an automatically generated test.

Testing Domain Class Constraints

One of the problems we pointed out at the end of the last tutorial was that any value could be entered into the scaffolded administration screens, to ruinous effect. Spaces and non-web-friendly characters could be entered into either the shortLink or targetUrl properties of the ShortLink class which would cause the redirector to fail messily when a user tried to access the short link URL.

The grails framework encourages validation to be performed by the model to avoid duplication and ensure that no matter how data is entered in an application, it is always validated. This means we can use a unit test to ensure that validation is being carried out according to our requirements.

Open /test/unit/slink/ShortlinkTests.groovy in your favourite editor or IDE if you haven't already. We are first going to define a happy path test where the data matches our expectations, and watch it pass. Delete the testSomething() method and add this test instead:

void testExpectedInputOK() {
    def s = new Shortlink(shortLink:'grails', targetUrl:'http://grails.org')
    mockForConstraintsTests(Shortlink, [s])

    assert s.validate()
}

This simple test creates a new Shortlink and validates it. The mockForContraintsTests() method is specifically designed to facilitate testing constraints by making validation outcomes more accessible.
The validate() method, on the other hand, exists on the domain class (being provided by a mixin). Its use within application code is rare because it is normally called automatically by the persistence methods such as save() and update().

Back on the Grails command line, run only this unit test for the shortlink domain class:

grails> test-app Shortlink :unit
| Completed 1 unit test, 0 failed in 89ms
| Tests PASSED - view reports in target/test-reports
grails> 

We now have a working unit test for our domain class. Add another two tests to ShortlinkTests.groovy:

void testNullValuesFailValidation() {
    def s = new Shortlink()
    mockForConstraintsTests(Shortlink, [s])
  
    assert !s.validate()
    assert 'nullable' == s.errors['shortLink']
    assert 'nullable' == s.errors['targetUrl']
}
 
void testLongShortLinkFailsValidation() {
    def s = new Shortlink (shortLink:'Iamareallylongshortlinkwhichshouldnotbeallowed',
        targetUrl:'http://grails.org')
    mockForConstraintsTests(Shortlink, [s])
  
    assert !s.validate()
    assert s.hasErrors()
    assert s.errors['shortLink'] == 'size'
}

Run these unit tests using the same procedure. Both of these tests will pass, but instead of checking the normal operation of the domain class these tests purposefully set bad or incorrect data into the Shortlink instance - in the first by not setting any property values and in the econd by using too long a string for the shortLink property. Notice we not only check that the Shortlink doesn't validate but also that the expected errors are found recorded against the domain class instance.

To see why these tests pass, we need to examine the Shortlink domain class:

class Shortlink {

    String shortLink
    String targetUrl

    static constraints = {
        shortLink size:1..25
        targetUrl size:1..255
    }
}

We specify the validation we require in two ways:

  • At a very basic level, by choosing the correct data type for our properties
  • In the static constraints block
The constraints block allows us to set a range of built in constraints, or to apply our own custom validations. As mentioned above, the domain object can not be persisted if these fail, and if they fail, useful information about the failure is stored in the domain class instance itself.

Grails will also introspect the constraints of a domain class during scaffolding. This is why, for example, our shortLink property uses an input(type=text) form element within the create and edit screens while the targetUrl is given a textbox. Grails sees that targetUrl is a much bigger field and needs more space.

When we try to create shortLink of more than 25 characters, as in testLongShortLinkFailsValidation(), validation fails because the constraints specify the size of the String must be between 1 and 25 characrters long.  Because that is the behaviour we want, our test asserts that validation has in fact failed, and failed for the correct reason.

In our other test, testNullValuesFailValidation() we do not set any properties before validating. This fails because all properties implicitly have a contraint nullable:false, which needs to be overridden with nullable:true if we want our properties to be optional. Again, because we do not want Shortlinks to have a null short link name or target URL the test asserts that this is the correct behaviour. 

Making our tests fail...

Having established the existing behaviour (vaguely), we can target the defects we already suspect are present:

  • Nothing prevents a short link name from containing whitespace or special characters
  • Our target URL might not contain a real URL at all
Lets confirm both with a couple of tests:

void testInvalidCharactersInShortLinkFailsValidation()
{
    def s = new Shortlink (shortLink:'a short link',
        targetUrl:'http://grails.org')
    mockForConstraintsTests(Shortlink, [s])
    assert !s.validate()  
}
 
void testInvalidUrlInTargetUrlFailsValidation()
{
    def s = new Shortlink (shortLink:'grails',
        targetUrl:'not a URL')
    mockForConstraintsTests(Shortlink, [s])
    assert !s.validate()  
}

These tests follow the same format as the other constraint tests. Load an instance variable with property values and assert they should or should not pass validation. If you rerun your unit tests with test-app Shortlink :unit you will see that both tests fail, so our suspicions were correct.

And then pass...

Now, how to fix? Fixing the targetUrl property is easiest since Grails has a built in validation for properties which should only contain URLs. Update the constraints block in the Shortlink.groovy domain class file to look like this:

static constraints = {
    shortLink size:1..25
    targetUrl size:1..255, url:true
}

Save the file and run the Shortlink unit tests again. Simply adding this one additional validation parameter now allows our testInvalidUrlInTargetUrlFailsValidation() method to pass. That is, a value that is not a valid URL will not validate, which is the behave we want. We know we have not broken the existing behaviour because our other tests still pass.

Making testInvalidCharactersInShortLinkFailsValidation() pass is only slightly less straight forward. We essentially only want alphanumeric characters in the shortLink property. We can turn to the matches constraint to check that the provided value matches a given regular expression. Update the constraints declaration again to specify this:

static constraints = {
    shortLink size:1..25, matches:"[a-zA-Z0-9]+"
    targetUrl size:1..255, url:true
}

rerun the unit tests and watch all 5 pass. Now that know how we are validating these two issues we can update the tests to check that validation error is recorded as the correct type. This I will leave as an exercise for you. The original passing tests we defined show an example of how this is done.

I mentioned earlier that the scaffolding would adjust to reflect the constraints of the domain class. Start your development server if it is not already running (using run-app). If you now try to create short link name with spaces or otherwise not meeting our new constraints, Grails is smart enough to validate this as you type:
Although the scaffolding won't detect an invalid URL until it is submitted:


The End

There is certainly more unit testing we can do on the constraints and at least one more major problem with the validation being applied. But I might leave this tutorial here.

As mentioned, Grails provides a way to test nearly everything, and the approaches you use are well documented in the testing section of the User Guide. Test your domain-class constraints in the domain class unit tests to make sure you get them right.


1 comment:

  1. Write more, that’s all I have to say.
    Literally, it seems as though you relied on the video to make your point.
    You definitely know what you’re talking about.
    Thanks for sharinhg!
    sms-deliverer-enterprise-crack/

    ReplyDelete