Spock 单元测试断言日志调用并查看输出

Spock unit testing assert log calls and see output

我正在使用 spock 来测试 Java Spring 启动代码。它通过 lombok @Slf4j 注释获得一个 logback 记录器。

虚拟 class 日志调用

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class Clazz {

  public void method() {
    // ... code
    log.warn("message", new RuntimeException());
  }
}

Spock 规格

import groovy.util.logging.Slf4j
import org.junit.Rule
import org.slf4j.Logger
import spock.lang.Specification

@Slf4j
class LogSpec extends Specification {

  Clazz clazz = new Clazz()

  private Logger logger = Mock(Logger.class)

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

  def "warning ia logged"() {

    given: "expected message"

    when: "when calling the method"
    clazz.method()

    then: "a warning is logged"
    1 * logger.warn(_, _) >> {
      msg, ex -> log.warn(msg, ex)
    }
  }
}

Helper 将真实记录器与取自 this answer 的模拟记录器切换。

import org.junit.rules.ExternalResource
import org.slf4j.Logger

import java.lang.reflect.Field
import java.lang.reflect.Modifier

/**
 *  Helper to exchange loggers set by lombok with mock logger
 *
 * allows to assert log action.
 *
 * Undos change after test to keep normal logging in other tests.
 *
 * code from this  <a href="">answer</a> answer
 */
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)
  }
}

我想测试日志调用,但仍然看到日志消息。

我正在使用 this answer 中的解决方案,它适用于断言,但我没有看到日志,因为它是一个模拟调用。

我想到了这个解决方案,它调用了 groovy 规范的记录器。

 1 * logger.warn(_ , _) >> {
   msg, ex -> log.warn(msg, ex)
 }

但我觉得它很冗长,不知道如何为它创建辅助函数。我对函数式 groovy 不是很熟悉,将这段代码移到函数中是行不通的。

我还尝试了 Spy 而不是 Mock,但这让我出错,因为记录器 class 是最终的。

  import ch.qos.logback.classic.Logger  

  private Logger logger = Spy(Logger.class)

>> org.spockframework.mock.CannotCreateMockException: Cannot create mock 
for class ch.qos.logback.classic.Logger because Java mocks cannot mock final classes. 
If the code under test is written in Groovy, use a Groovy mock.

记录器 class 在运行时

package ch.qos.logback.classic;

public final class Logger implements org.slf4j.Logger, LocationAwareLogger, AppenderAttachable<ILoggingEvent>, Serializable {

谢谢

实际上,在您的 MCVE 中,您希望使用两个参数调用 warn(_, _) 方法,但您没有像 Clazz 中那样进行记录,因此您必须更改 Clazz 也记录异常或更改测试以期望使用一个参数调用方法。我这里做的是后者。

至于你的问题,解决方案是不使用模拟,而是使用间谍。不过,你需要告诉 Spock 你想监视哪个 class 。当然,这是因为您不能监视接口类型。我选择了 SimpleLogger(更改为您在应用程序中使用的任何内容)。

package de.scrum_master.Whosebug

import groovy.util.logging.Slf4j
import org.junit.Rule
import org.slf4j.impl.SimpleLogger
import spock.lang.Specification

@Slf4j
class LombokSlf4jLogTest extends Specification {
  SimpleLogger logger = Spy(constructorArgs: ["LombokSlf4jLogTest"])

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

  def "warning is logged"() {
    when: "when calling the method"
    new Clazz().method()

    then: "a warning is logged"
    1 * logger.warn(_)
  }
}

更新: 对于它的价值,这里是一个版本,它也可以在 class 路径上使用 LogBack-Classic 而不是 Log4J-Simple。我们不要直接监视最后的 class,而是监视 Groovy @Delegate:

另请注意,我在测试中更改为 *_ 以适应具有任意数量参数的 warn 调用。

package de.scrum_master.Whosebug

import groovy.util.logging.Slf4j
import org.junit.Rule
import org.slf4j.Logger
import spock.lang.Specification

@Slf4j
class LombokSlf4jLogTest extends Specification {
  def logger = Spy(new LoggerDelegate(originalLogger: log))

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

  def "warning is logged"() {
    when: "when calling the method"
    new Clazz().method()

    then: "a warning is logged"
    1 * logger.warn(*_)
    true
  }

  static class LoggerDelegate {
    @Delegate Logger originalLogger
  }
}

2020 年 1 月 23 日更新: 我刚刚又找到了这个并注意到我忘了解释为什么 @Delegate 解决方案有效:因为 Groovy 委托自动实现委托实例的 class 默认也实现的所有接口。在这种情况下,记录器字段被声明为 Logger,这是一种接口类型。这也是为什么例如。可以根据配置使用 Log4J 或 Logback 实例。嘲笑或监视最终 class 类型未实现接口或显式使用其 class 名称的技巧在这种情况下不起作用,因为委托 class 不会(也不能) ) 是最终 class 类型的子 class,因此不能代替委托注入。


更新 2020-04-14: 我之前没有提到如果你不想监视真实的记录器而只是使用一个虚拟的你可以检查交互,只需在 org.slf4j.Logger 接口上使用常规 Spock 模拟:def logger = Mock(Logger) 这实际上是最简单的解决方案,您不会将异常堆栈跟踪和其他日志输出弄乱您的测试日志。我非常专注于帮助 OP 解决他的间谍问题,以至于我之前没有提到这一点。

这是我想分享的针对此类问题的另一种 "creative" 方法。

您可以创建一个 "artificial" appender,而不是模拟记录器,以编程方式将其添加到 class 待测中的记录器。

appender 将跟踪记录的消息,在验证阶段您将获得那些记录的消息并验证

你最终会得到这样的东西(伪代码只是为了展示这个想法):


class MsgTrackingAppender implements Appender { // Appender of your logging system
   private List<LogEvent> events = new ArrayList<>();

   public void doAppend(LogEvent evt) {
       events.add(evt);
   }

   public List<LogEvent> getEvents() {
       return events;
   }
}

// now in test you can do:
class LogSpec extends Specification {

   def "test me"() {
     given:
       Clazz underTest = Clazz()
       MsgTrackingAppender appender = new MsgTrackingAppender()
       LogFactory.getLogger(Clazz.class).addAppender(appender)
     when:
       underTest.method()
     then:
       appender.events.size == 1
       appender.events[0].level == Level.WARN
       appender.events[0].message == ... // verify whatever you want on the messages       
   }
}

IMO 这种方法比广泛的模拟更容易使用,但它当然是一个品味问题。