Unit testing Grails domains that (over?) extend a

2019-08-03 15:46发布

问题:

So, I have a base class that I want to extend most (but not all) of my domain classes from. The goal is that I can add the six audit information columns I need to any domain class with a simple extends. For both creation and updates, I want to log the date, user, and program (based on request URI). It's using a Grails service (called CasService) to find the currently logged on user. The CasService then uses Spring Security and a database call to get the relevant user information for that field.

The trouble is, if I do this, then I'm going to have to Mock the CasService and request object in any unit test that tests a domain that uses these classes. That will also impact unit tests for services and controllers that use these domains. That's going to make unit testing a bit of a pain, and increase boiler plate code, which is what I was trying to avoid.

I'm fishing for better design options, and I'm open to suggestion. My current default is to simply add the same boiler plate to all my domain classes and be done with it. See below for source code and what I've tried so far.

Source

Common Audit Domain Class

package com.mine.common

import grails.util.Holders
import org.springframework.web.context.request.RequestContextHolder

class AuditDomain implements GroovyInterceptable {

    def casService = Holders.grailsApplication.mainContext.getBean('casService')
    def request = RequestContextHolder?.getRequestAttributes()?.getRequest()

    String creator
    String creatorProgram
    String lastUpdater
    String lastUpdateProgram

    Date dateCreated
    Date lastUpdated

    def beforeValidate() {
        beforeInsert()
        beforeUpdate()
    }

    def beforeInsert() {
        if (this.creator == null) {
            this.creator = casService?.getUser() ?: 'unknown'
        }

        if (this.creatorProgram == null) {
            this.creatorProgram = request?.requestURI ?: 'unknown'
        }
    }

    def beforeUpdate() {
        this.lastUpdater = casService?.getUser() ?: 'unknown'
        this.lastUpdateProgram = request?.requestURI ?: 'unknown'
    }

    static constraints = {
        creator nullable:true, blank: true
        lastUpdater nullable:true, blank: true
        creatorProgram nullable:true, blank: true
        lastUpdateProgram nullable:true, blank: true
     }
}

CasService

package com.mine.common

import groovy.sql.Sql

class CasService {
    def springSecurityService, sqlService, personService

    def getUser() {
        if (isLoggedIn()) {
            def loginId = springSecurityService.authentication.name.toLowerCase()
            def query = "select USER_UNIQUE_ID from some_table where USER_LOGIN = ?"
            def parameters = [loginId]
            return sqlService.call(query, parameters)
        } else {
            return null
        }
    }

    def private isLoggedIn() {
        if (springSecurityService.isLoggedIn()) {
            return true
        } else {
            log.info "User is not logged in"
            return false
        }
    }

    //...
}

What I've Tried

Creating a Test Utilities Class to do the setup logic

I've tried building a class like this:

class AuditTestUtils {

    def setup() {
        println "Tell AuditDomain to sit down and shut up"
        AuditDomain.metaClass.casService = null
        AuditDomain.metaClass.request = null
        AuditDomain.metaClass.beforeInsert = {}
        AuditDomain.metaClass.beforeUpdate = {}
    }

    def manipulateClass(classToTest) {
        classToTest.metaClass.beforeInsert = {println "Yo mama"}
        classToTest.metaClass.beforeUpdate = {println "Yo mamak"}
    }
}

And then calling it in my Unit Test's setup() and setupSpec() blocks:

def setupSpec() {
    def au = new AuditTestUtils()
    au.setup()
}

OR

def setupSpec() {
    def au = new AuditTestUtils()
    au.manipulateClass(TheDomainIAmTesting)
}

No dice. That errors out with a NullPointerException on the CasService as soon as I try to save the domain class that extends the AuditDomain.

java.lang.NullPointerException: Cannot invoke method isLoggedIn() on null object
    at com.mine.common.CasService.isLoggedIn(CasService.groovy:127)
    at com.mine.common.CasService.getPidm(CasService.groovy:9)
    at com.mine.common.AuditDomain.beforeInsert(AuditDomain.groovy:26)
    //...
    at org.grails.datastore.gorm.GormInstanceApi.save(GormInstanceApi.groovy:161)
    at com.mine.common.SomeDomainSpec.test creation(SomeDomainSpec:30)

I'm open to alternate ways of approaching the issue of DRYing the Audit Information out my Domains. I'd settled on inheritance, but there are other ways. Traits aren't available in my environment (Grails 2.3.6), but maybe that's just a reason to get cracking on updating to the latest version.

I'm also open to suggestions about how to test these domains differently. Maybe I should have to contend with the audit columns in the unit tests of every domain class that has them, though I'd rather not. I'm okay with dealing with that in integration tests, but I can unit test the AuditDomain class easily enough on its own. I'd prefer that unit tests on my domains tested the specific things those domains bring to the table, not the common stuff that they all have.

回答1:

So, ultimately, I've gone with Groovy delegation to meet this need.

The validation logic all lives in

class AuditDomainValidator extends AuditDomainProperties {

    def domainClassInstance 

    public AuditDomainValidator(dci) {
        domainClassInstance = dci
    }

    def beforeValidate() {
        def user = defaultUser()
        def program = defaultProgram()
        if (this.creator == null) {
            this.creator = user
        }

        if (this.creatorProgram == null) {
            this.creatorProgram = program
        }
        this.lastUpdater = user
        this.lastUpdateProgram = program
    }

    private def defaultProgram() {
        domainClassInstance.getClass().getCanonicalName()
    }

    private def defaultUser() {
        domainClassInstance.casService?.getUser() ?: 'unknown'
    }
}

I created this abstract class to hold the properties while trying various solutions. It could probably be folded into the validator class with no problems, but I'm just lazy enough to leave it in there since it's working.

abstract class AuditDomainProperties {
    String creator
    String creatorProgram
    String lastUpdater
    String lastUpdateProgram

    Date dateCreated
    Date lastUpdated
}

And finally, here's how to implement the validator class in a Grails domain class.

import my.company.CasService
import my.company.AuditDomainValidator

class MyClass {
    def casService

    @Delegate AuditDomainValidator adv = new AuditDomainValidator(this)
    static transients = ['adv']
    //...domain class code

    static mapping = {
        //..domain column mapping
        creator column: 'CREATOR'
        lastUpdater column: 'LAST_UPADTER'
        creatorProgram column: 'CREATOR_PGM'
        lastUpdateProgram column: 'LAST_UPDATE_PGM'
        dateCreated column: 'DATE_CREATED'
        lastUpdated column: 'LAST_UPDATED'
    }
}

This approach doesn't work perfectly for Unit and Integration tests, it seems. Trying to access the dateCreated column in either fails with an error that there is no such property for the domain class in question. Thats odd, since running the application works fine. With the Unit tests, I would think it was a mocking issue, but I wouldn't expect that to be a problem in the integration tests.