使用StaticLoggerBinder进行类的单元测试

15

我有一个像这样简单的类:

package com.example.howtomocktest

import groovy.util.logging.Slf4j
import java.nio.channels.NotYetBoundException

@Slf4j
class ErrorLogger {
    static void handleExceptions(Closure closure) {
        try {
            closure()
        }catch (UnsupportedOperationException|NotYetBoundException ex) {
            log.error ex.message
        } catch (Exception ex) {
            log.error 'Processing exception {}', ex
        }
    }
}

我想为此编写一个测试,以下是一个框架:

package com.example.howtomocktest

import org.slf4j.Logger
import spock.lang.Specification
import java.nio.channels.NotYetBoundException
import static com.example.howtomocktest.ErrorLogger.handleExceptions

class ErrorLoggerSpec extends Specification {

   private static final UNSUPPORTED_EXCEPTION = { throw UnsupportedOperationException }
   private static final NOT_YET_BOUND = { throw NotYetBoundException }
   private static final STANDARD_EXCEPTION = { throw Exception }
   private Logger logger = Mock(Logger.class)
   def setup() {

   }

   def "Message logged when UnsupportedOperationException is thrown"() {
      when:
      handleExceptions {UNSUPPORTED_EXCEPTION}

      then:
      notThrown(UnsupportedOperationException)
      1 * logger.error(_ as String) // doesn't work
   }

   def "Message logged when NotYetBoundException is thrown"() {
      when:
      handleExceptions {NOT_YET_BOUND}

      then:
      notThrown(NotYetBoundException)
      1 * logger.error(_ as String) // doesn't work
   }

   def "Message about processing exception is logged when standard Exception is thrown"() {
      when:
      handleExceptions {STANDARD_EXCEPTION}

      then:
      notThrown(STANDARD_EXCEPTION)
      1 * logger.error(_ as String) // doesn't work
   }
}

ErrorLogger类中的记录器是通过StaticLoggerBinder提供的,所以我的问题是 - 如何使其工作,以便这些检查"1 * logger.error(_ as String)"能够工作?我找不到在ErrorLogger类内部模拟该记录器的正确方法。我考虑过反射和某种方式访问它,此外还有一个使用mockito注入的想法(但由于Slf4j注释而在该类中甚至没有对象的引用,如何做到呢!)感谢您提前给出所有反馈和建议。
编辑:这是测试的输出,即使1*logger.error(_)也不起作用。
Too few invocations for:

1*logger.error()   (0 invocations)

Unmatched invocations (ordered by similarity):

你尝试过只使用1*logger.error(_)? 如果您将输出添加到这些测试中,也会很有帮助。 - Fran García
我添加了调用。不幸的是,1*logger.error(_)也不能正常工作。 - Mateusz Chrzaszcz
一些用例的替代方案(可能是上面的示例)是使用http://docs.groovy-lang.org/next/html/gapi/groovy/lang/GroovyLogTestCase.html。 - Matt Whipple
2个回答

28
你需要做的是用你的模拟替换由@Slf4j AST转换生成的log字段。
然而,这并不容易实现,因为生成的代码并不真正适合测试。
快速查看生成的代码可以发现,它对应于类似于以下内容:
class ErrorLogger {
    private final static transient org.slf4j.Logger log =
            org.slf4j.LoggerFactory.getLogger(ErrorLogger)
}

由于log字段被声明为private final,因此不容易用您的模拟值替换它。实际上,这归结为与here描述的完全相同的问题。此外,对该字段的使用被包装在isEnabled()方法中,因此例如每次调用log.error(msg)都会被替换为:

if (log.isErrorEnabled()) {
    log.error(msg)
}

所以,该如何解决呢?我建议您在groovy问题跟踪器注册一个问题,请求更多测试友好的AST转换实现。然而,这并不能立即帮助您。
有几种解决方法可以考虑一下。
1.使用“可怕的hack”在测试中设置新字段值,如上面提到的stack overflow问题。即使用反射使字段可访问并设置值。记得在清理期间将值重置为原始值。
2.向您的ErrorLogger类添加getLog()方法,并使用该方法进行访问,而不是直接访问字段。然后,您可以操作metaClass来覆盖getLog()实现。这种方法的问题是您需要修改生产代码并添加getter,这有点违背了首先使用@Slf4j的目的。
我想指出你的ErrorLoggerSpec类存在几个问题。这些问题被你已经遇到的问题所掩盖,因此当它们显现出来时,你可能会自己找到解决方法。
虽然这是一个hack,但我只提供了第一个建议的代码示例,因为第二个建议修改了生产代码。
为了隔离这个hack,实现简单的重用并避免忘记重置值,我将其编写为一个JUnit规则(也可以在Spock中使用)。
import org.junit.rules.ExternalResource
import org.slf4j.Logger
import java.lang.reflect.Field
import java.lang.reflect.Modifier

public class ReplaceSlf4jLogger extends ExternalResource {
    Field logField
    Logger logger
    Logger originalLogger

    ReplaceSlf4jLogger(Class logClass, Logger logger) {
        logField = logClass.getDeclaredField("log");
        this.logger = logger
    }

    @Override
    protected void before() throws Throwable {
        logField.accessible = true

        Field modifiersField = Field.getDeclaredField("modifiers")
        modifiersField.accessible = true
        modifiersField.setInt(logField, logField.getModifiers() & ~Modifier.FINAL)

        originalLogger = (Logger) logField.get(null)
        logField.set(null, logger)
    }

    @Override
    protected void after() {
        logField.set(null, originalLogger)
    }

}

在修复了所有小错误并添加了此规则后,这里是规范。更改已在代码中进行了注释:

import org.junit.Rule
import org.slf4j.Logger
import spock.lang.Specification
import java.nio.channels.NotYetBoundException
import static ErrorLogger.handleExceptions

class ErrorLoggerSpec extends Specification {

    // NOTE: These three closures are changed to actually throw new instances of the exceptions
    private static final UNSUPPORTED_EXCEPTION = { throw new UnsupportedOperationException() }
    private static final NOT_YET_BOUND = { throw new NotYetBoundException() }
    private static final STANDARD_EXCEPTION = { throw new Exception() }

    private Logger logger = Mock(Logger.class)

    @Rule ReplaceSlf4jLogger replaceSlf4jLogger = new ReplaceSlf4jLogger(ErrorLogger, logger)

    def "Message logged when UnsupportedOperationException is thrown"() {
        when:
        handleExceptions UNSUPPORTED_EXCEPTION  // Changed: used to be a closure within a closure!
        then:
        notThrown(UnsupportedOperationException)
        1 * logger.isErrorEnabled() >> true     // this call is added by the AST transformation
        1 * logger.error(null)                  // no message is specified, results in a null message: _ as String does not match null
    }

    def "Message logged when NotYetBoundException is thrown"() {
        when:
        handleExceptions NOT_YET_BOUND          // Changed: used to be a closure within a closure!
        then:
        notThrown(NotYetBoundException)
        1 * logger.isErrorEnabled() >> true     // this call is added by the AST transformation
        1 * logger.error(null)                  // no message is specified, results in a null message: _ as String does not match null
    }

    def "Message about processing exception is logged when standard Exception is thrown"() {
        when:
        handleExceptions STANDARD_EXCEPTION     // Changed: used to be a closure within a closure!
        then:
        notThrown(Exception)                    // Changed: you added the closure field instead of the class here
        //1 * logger.isErrorEnabled() >> true   // this call is NOT added by the AST transformation -- perhaps a bug?
        1 * logger.error(_ as String, _ as Exception) // in this case, both a message and the exception is specified
    }
}

@Opal 谢谢!深入研究这个问题既有趣又有意思。 :) - Steinar
非常感谢您的出色回答!我知道我的代码中有一些错误,因为这只是一个虚拟的、假的代码,展示了我遇到的问题 :) - Mateusz Chrzaszcz
1
是的MateuszChrzaszcz,我知道代码是为了呈现问题而创建的。既然我必须修复错误才能测试解决方案,我选择包含修复后的Spec版本 :) - Steinar
@Steinar 这是一个非常好的答案,将解决许多人的问题。如果您不介意,我建议这个hack成为Grails 3.3.x测试框架的一部分,并将发起PR,因为它可以防止大量的测试代码破坏。 - sbglasius
厉害!在 Grails 4 中仍然可以使用 :) - Zorobay

2
如果您正在使用Spring,您可以使用OutputCaptureRule。"最初的回答"
@Rule
OutputCaptureRule outputCaptureRule = new OutputCaptureRule()

def test(){
outputCaptureRule.getAll().contains("<your test output>")
}

这正是我所需要的。谢谢! - Forrest

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接