使用Java 8 Clock对类进行单元测试


69

引入了Java 8 java.time.Clock,可以将其用作许多其他java.time对象的参数,从而使您可以向其中注入真实或虚假的时钟。例如,我知道您可以创建一个Clock.fixed()然后调用Instant.now(clock),它将返回Instant您提供的固定内容。这听起来很适合单元测试!

但是,我很难弄清楚如何最好地使用它。我有一个课,类似于以下内容:

public class MyClass {
    private Clock clock = Clock.systemUTC();

    public void method1() {
        Instant now = Instant.now(clock);
        // Do something with 'now'
    }
}

现在,我要对该代码进行单元测试。我需要能够设置clock固定的时间,以便可以method()在不同的时间进行测试。显然,我可以使用反射将clock成员设置为特定值,但是如果我不必求助于反射,那就太好了。我可以创建一个公共setClock()方法,但这感觉不对。我不想Clock在方法中添加参数,因为真正的代码不必关心传递时钟。

处理此问题的最佳方法是什么?这是新代码,因此我可以重新组织该类。

编辑:为澄清起见,我需要能够构造一个MyClass对象,但必须能够使一个对象看到两个不同的时钟值(好像是常规系统时钟一样)。因此,我无法将固定时钟传递给构造函数。


现在,我要对该代码进行单元测试。您应该指出您期望什么样的行为MyClass。这将为此处遵循的方法提供依据。
jub0bs

2
接下来:我认为基本上可以归结为,您不能真正Clock.fixed按照我希望的方式使用单元测试。需要正常的模拟方法。
迈克”

Answers:


51

让我将Jon Skeet的答案和注释放入代码中:

被测课程:

public class Foo {
    private final Clock clock;
    public Foo(Clock clock) {
        this.clock = clock;
    }

    public void someMethod() {
        Instant now = clock.instant();   // this is changed to make test easier
        System.out.println(now);   // Do something with 'now'
    }
}

单元测试:

public class FooTest() {

    private Foo foo;
    private Clock mock;

    @Before
    public void setUp() {
        mock = mock(Clock.class);
        foo = new Foo(mock);
    }

    @Test
    public void ensureDifferentValuesWhenMockIsCalled() {
        Instant first = Instant.now();                  // e.g. 12:00:00
        Instant second = first.plusSeconds(1);          // 12:00:01
        Instant thirdAndAfter = second.plusSeconds(1);  // 12:00:02

        when(mock.instant()).thenReturn(first, second, thirdAndAfter);

        foo.someMethod();   // string of first
        foo.someMethod();   // string of second
        foo.someMethod();   // string of thirdAndAfter 
        foo.someMethod();   // string of thirdAndAfter 
    }
}

1
你可能想改变firstsecond等等,以满足您的需求(例如时间需要在过去的),但你的想法。
2014年

31
实际上,上面的示例测试将在NPE中运行,因为用户代码很可能会调用LocalDateTime.now(clock)而不是clock.instant(); 一NullPointerException会抛出的瞬间LocalDateTime调用clock.getZone().getRules()。相反,应使用调用创建实际 Clock对象Clock.fixed(Instant,ZoneId)
罗杰里奥

2
@Rogério我对这个问题的解释是“我如何模拟在连续调用中返回不同的值”,因此就是答案。我不知道有足够的了解Java的8时间LocalDateTime.now()Instant.now()部分,但如果这里需要静态方法,它可能需要被裹成一类,因为PowerMock / EasyMock的不支持 Stubbing consecutive calls的Mockito

2
正如@Rogério所指出的那样,这是基于以下假设的:测试中的类查询Clock.instant()。如果将其更改为query Clock.millis(),它将始终返回0。不好 有一个需要模拟的clock对象,无论实际调用的方法如何,它都会返回模拟的时间。
Piotr Findeisen

11
使用真实时钟Clock.fixed代替嘲笑!
MariuszS

57

我不想在该方法中添加Clock参数,因为真正的代码不必关心传递时钟。

否...但是您可能希望将其视为构造函数参数。基本上,您是在说您的班级需要一个与之配合工作的时钟...所以这是一个依赖项。像对待其他任何依赖项一样对待它,然后将其注入到构造函数中或通过方法注入。(我个人更喜欢构造函数注入,但是YMMV。)

一旦停止将其视为可以轻松构建自己的事物,并开始将其视为“仅仅是另一个依赖项”,那么您就可以使用熟悉的技术。(诚​​然,我假设您通常对依赖注入感到满意。)


2
是的,我考虑过这一点。但是,问题在于,通过构造函数传递时钟可以让我将其设置为单个固定时钟。我想我没有说清楚,但我需要能够在单个测试中将时钟设置为多个值,而无需构造新对象。
迈克

1
@Mike请更新您的示例以弄清楚您的用例是什么。
Puce 2014年

3
@Mike您应该能够模拟Clock作为参数的传递(例如,使用Mockito),而不是提供固定的Clock。这样,您可以在不同的调用中返回不同的值。
Puce 2014年

3
@Mike:您可以编写自己的可重复使用的伪时钟,而不用模拟,可以使用任何所需的行为-例如每次调用增加1秒。但是到那时,您将取决于生产代码中对时钟的调用次数,这并不理想。
乔恩·斯基特

2
在深褐色的回答上面,你应该能够使每个模拟返回不同的值mockito.googlecode.com/svn/tags/1.8.5/javadoc/org/mockito/...

28

我在这里玩游戏有些迟了,但是要添加其他建议使用时钟的答案-确实可以,并且通过使用Mockito的doAnswer,您可以创建一个时钟,您可以在测试过程中对其进行动态调整。

假定该类经过了修改,以在构造函数中使用Clock,并在Instant.now(clock)调用时引用时钟。

public class TimePrinter() {
    private final Clock clock; // init in constructor

    // ...

    public void printTheTime() {
        System.out.println(Instant.now(clock));
    }
}

然后,在您的测试设置中:

private Instant currentTime;
private TimePrinter timePrinter;

public void setup() {
   currentTime = Instant.EPOCH; // or Instant.now() or whatever

   // create a mock clock which returns currentTime
   final Clock clock = mock(Clock.class);
   when(clock.instant()).doAnswer((invocation) -> currentTime);

   timePrinter = new TimePrinter(clock);
}

稍后在您的测试中:

@Test
public void myTest() {
    myObjectUnderTest.printTheTime(); // 1970-01-01T00:00:00Z

    // go forward in time a year
    currentTime = currentTime.plus(1, ChronoUnit.YEARS);

    myObjectUnderTest.printTheTime(); // 1971-01-01T00:00:00Z
}

您要告诉Mockito始终运行一个函数,该函数会在调用Instant()时返回currentTime的当前值。 Instant.now(clock)会打电话给clock.instant()。现在,您可以比DeLorean快进,倒带,并且通常来说,时光旅行更好。


这个答案是完美的。
埃里克·哈特福德

我的用例是在某些测试中快进几秒钟,以验证一些基于时间的验证规则。对于我来说,这似乎是最简单,最优雅的解决方案。太好了,非常感谢。DeLorean的另一个+1。
Max

非常好的解决方案:) +1供DeLorean参考。
Bohsen

5
我正在使用Mockito 1.10.19,doAnswer但不存在。我将其替换为thenAnswer
Wim Deblauwe

7

创建一个可变时钟而不是模拟

首先,一定要Clock按照@Jon Skeet的建议,将a注入要测试的班级。如果您的课程只需要一次,那么只需传递一个Clock.fixed(...)值即可。但是,如果您的类在时间上的行为有所不同,例如,它在时间A处执行某些操作,然后在时间B处执行其他操作,则请注意,Java创建的时钟是不可变的,因此无法通过测试将其更改为在时间返回时间A一个时间,然后是另一个时间B。

根据公认的答案,模拟是一种选择,但确实将测试与实现紧密耦合。例如,正如一位评论者所指出的,如果被测类调用LocalDateTime.now(clock)clock.millis()而不是clock.instant()

另一种方法就是有点更明确,更容易理解,而且可能比模拟更强大的,是创建一个真正的实现Clock可变的,所以测试可以注入,并修改是必要的。这并不难实现,或者这里有两个现成的实现:

这是在测试中可能会使用以下内容的方式:

MutableClock c = new MutableClock(Instant.EPOCH, ZoneId.systemDefault());
ClassUnderTest classUnderTest = new ClassUnderTest(c);

classUnderTest.doSomething()
assertTrue(...)

c.instant(Instant.EPOCH.plusSeconds(60))

classUnderTest.doSomething()
assertTrue(...)

1
另一种实现,已发布到Maven回购中:github.com/Mercateo/test-clock
Bartek Jablonski

1

正如其他人指出的那样,您需要以某种方式对其进行模拟-但是,自己动手很容易:

    class UnitTestClock extends Clock {
        Instant instant;
        public void setInstant(Instant instant) {
            this.instant = instant;
        }

        @Override
        public Instant instant() {
            return instant;
        }

        @Override
        public ZoneId getZone() {
            return ZoneOffset.UTC;
        }

        @Override
        public Clock withZone(ZoneId zoneId) {
            throw new UnsupportedOperationException();
        }
    }

2
JDK中已经提供了该类,请参阅Clock.fixed()
Krzysztof Wolny

@KrzysztofWolny,我想您已经错过了要点-这实际上是在创建一个可变的时钟,就像拉曼的回答一样。
DaveyDaveDave

0

我遇到了同样的问题,无法使用既简单又有效的现有解决方案,因此我最终遵循了代码。当然,它可以更好,具有可配置的TZ等,但是我需要在测试中易于使用的东西,在这里我想检查我的被测类如何处理时钟:

  1. 我不想在乎该Clock调用什么特定方法,因此模拟并不是一种可行的方法。
  2. 我想预先定义米莉

注意:此类是Groovy用于Spock测试中的类,但是可以很容易地转换成Java。

class FixedTicksClock extends Clock {
    private ZoneId systemDefault = ZoneId.systemDefault()
    private long[] ticks
    private int current = 0

    FixedTicksClock(long ... ticks) {
        this.ticks = ticks
    }

    @Override
    ZoneId getZone() {
        systemDefault
    }

    @Override
    Clock withZone(final ZoneId zone) {
        systemDefault = zone
        this
    }

    @Override
    Instant instant() {
        ofEpochMilli(getNextTick())
    }

    @Override
    long millis() {
        getNextTick()
    }

    private long getNextTick() {
        if (current >= ticks.length) {
            throw new IllegalStateException('No more ticks provided')
        } else {
            ticks[current++]
        }
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.