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

5

我正在使用Spock来测试Java Spring Boot代码。它通过lombok的@Slf4j注解获取logback日志记录器。

带有日志调用的虚拟类

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函数可以用来切换从这个答案中获取的真实记录器和模拟记录器。

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="https://dev59.com/lV8f5IYBdhLWcg3wHPiN#25031713">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)
  }
}

我想测试日志调用,但仍然想看到日志消息。
我使用了这个答案中的解决方案,它对于断言有效,但由于是模拟调用,我看不到日志。
我想出了这个解决方案,它使用groovy规范的记录器进行调用。
 1 * logger.warn(_ , _) >> {
   msg, ex -> log.warn(msg, ex)
 }

但我觉得这个代码有点啰嗦,你有什么想法可以创建一个辅助函数吗?我对函数式Groovy不是很熟悉,将此代码移入一个函数中也行不通。

我还尝试使用Spy而不是Mock,但由于logger类是final的,所以出现了错误。

  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.

运行时的日志记录器类

package ch.qos.logback.classic;

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

谢谢


请分享一个 MCVE。"我正在做类似于其他人的事情,但它并没有达到我的预期" 几乎不能成为一个可回答的问题。我希望看到您的 Spock 规范以及测试代码,以便能够重现和理解您的问题。如果这要求过高,请自行解决您的问题。 - kriegaex
2个回答

5
实际上,在您的MCVE中,您期望warn(_, _)方法被调用两个参数,但是您在Clazz中没有记录这样的日志,因此您必须更改Clazz以记录异常或更改测试以期望使用一个参数调用方法。我在这里做了后者。
至于您的问题,解决方案是不使用mock而使用spy。不过,您需要告诉Spock要对哪个确切的类进行监视。这是因为您当然无法对接口类型进行监视。我选择了一个SimpleLogger(根据您在应用程序中使用的内容进行更改)。
package de.scrum_master.stackoverflow

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(_)
  }
}

更新:值得一提的是,这个版本也适用于在类路径上使用LogBack-Classic而不是Log4J-Simple。我们不必直接监视最终类,而是可以监视Groovy中的@Delegate。请注意,我在测试中改为*_以适应带有任意数量参数的warn调用。
package de.scrum_master.stackoverflow

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-01-23:我再次发现这个问题,并注意到我忘记解释为什么@Delegate解决方案可行:因为Groovy委托默认会自动实现委托实例类所实现的所有接口。在这种情况下,logger字段声明为Logger,它是一个接口类型。这也是为什么可以基于配置使用Log4J或Logback实例的原因。模拟或监视不实现接口或明确使用其类名的最终类类型的技巧在这种情况下不起作用,因为委托类不会(也不能)是最终类类型的子类,因此无法注入代替委托的对象。
更新于2020-04-14: 我之前没有提到,如果你不想监听真正的记录器而只是使用一个虚拟的来检查交互,只需在org.slf4j.Logger接口上使用常规的Spock模拟:def logger = Mock(Logger) 这实际上是最简单的解决方案,你不会用异常堆栈跟踪和其他日志输出来混淆你的测试日志。我太专注于帮助OP解决间谍解决方案,所以之前没有提到这一点。

2
我使用了你的代码,但是没有看到任何Logback日志记录器,只有一个Log4J日志记录器。你只在与MCVE无关的小片段中提到它。看起来这个MCVE实际上并不是真正的一个。所以除非你能让问题可重现,否则我怎么能帮助你呢?我不喜欢浪费时间。我的解决方案可以与你的代码一起工作!所以如果你的代码有所不同,那么你的问题就是问题,而不是我的回答。顺便说一句:我回答之后你编辑了你的代码。 - kriegaex
1
另外一个要点:如果输出不同对于你的测试来说并不重要。重要的是 logger 方法是否使用了预期的参数进行调用,而这一点可以通过我的方法进行测试。此外,LogBack Classic 和 Slf5J-Simple 都实现了相同的接口,因此它们可以互换使用。而且你自己的解决方案也直接模拟了接口,而不是将 LogBack-Classic 实例注入到 Clazz 中。 - kriegaex
1
更新:如果您想知道如何在 Groovy 的 @Delegate 上使用 Spock Spy 而不是直接对最终类进行监视,请参阅我的第二个示例测试。我希望您现在很高兴。 - kriegaex
非常好的答案,谢谢。特别是@Delegate部分,很棒。 - davidfrancis
嗯,我不记得这个问题的细节了,我需要重新阅读它和我的整个答案才能回忆起来。考虑到规则是JUnit4的东西,而Spock2基于JUnit5(尽管现在有一个兼容模块可以让您使用规则),也许思考一下Spock扩展会更有趣。但请写一个新问题,展示一个MCVE,尽可能详细地解释哪些部分不起作用。然后,Spock的维护者或我可以看一下,看谁更快。 - kriegaex
显示剩余5条评论

3
以下是与该问题相关的另一种“创意”解决方法:
不要嘲笑记录器,可以创建一个“人工”的 appender,在测试类中以编程方式将其添加到记录器中。
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       
   }
}

我认为这种方法比大量的模拟更易于使用,当然这只是个人口味问题。


我之所以注意到这个答案,是因为我的回答上有活动。我喜欢创造性的方法,特别是如果它们很干净。你基本上构建自己的测试替身(在 Spock 中技术上称为存根),并将其作为依赖项(或协作者,选择一个术语)注入到记录器中。虽然我仍然更喜欢尽可能“平坦”地“模拟”依赖项,但你的方法更深入一层,使用了真实的记录器(这也需要其余的日志记录基础设施),注入了你的“诊断附加器”。非常好。 - kriegaex

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