Unit-Tests für zeitabhängigen Code

30. Juli 2018 in Tests

Jedem guten Software-Entwickler ist klar, dass Unit-Tests einen sehr wichtigen Beitrag zur Qualität von Software liefern und außerdem dem Entwickler ein gutes Gefühl geben. Oft ist es aber auf den ersten Blick nicht ganz einfach, den richtigen Weg zu finden, um Code automatisiert zu testen. Es gibt z.B. immer wieder Code, dessen Verhalten in irgendeiner Weise von der Systemzeit abhängt. Wenn man diesen Code nun testen will, müsste der Test eigentlich die Systemzeit manipulieren. In diesem Artikel möchte ich Wege zeigen, wie man solchen Code testen kann.

Beispiel-Szenario

Als Beispiel nutze ich hier ein Szenario, in dem Tokens für eine Authentifizierung erzeugt werden sollen, deren Gültigkeit dann später auf Basis eines Gültigkeitszeitraums geprüft werden kann. Die Token-Klasse könnte in Java dann z.B. so aussehen:

Token.java:

 1public class Token {
 2  private final String id;
 3  private final long   expiresAtMillis;
 4
 5  public Token (final String id, final long expiresAtMillis) {
 6    this.id              = id;
 7    this.expiresAtMillis = expiresAtMillis;
 8  }
 9
10  public String getId () {
11    return id;
12  }
13
14  public long getExpiresAtMillis () {
15    return expiresAtMillis;
16  }
17
18  @Override
19  public String toString () {
20    return "Token {id = '" + id + "', expiresAtMillis = " + expiresAtMillis + '}';
21  }
22}

Ein TokenService, der solche Tokens erzeugt und später ihre Gültigkeit prüft, sieht dann z.B. so aus:

TokenService.java:

 1public class TokenService {
 2  // token expires after 30 minutes
 3  static final long TOKEN_EXPIRE_AFTER_MILLI_SECONDS = TimeUnit.MINUTES.toMillis (30);
 4
 5  public Token createToken () {
 6    return new Token (UUID.randomUUID ().toString (), System.currentTimeMillis () + TOKEN_EXPIRE_AFTER_MILLI_SECONDS);
 7  }
 8
 9  public boolean isExpired (final Token token) {
10    return token.getExpiresAtMillis () < System.currentTimeMillis ();
11  }
12}

Dieser Service macht das Testen nun besonders durch die beiden Zeilen 6 und 10 sehr unpraktikabel. Die offensichtliche, einfache Implementierung eines JUnit-Tests sähe dann z.B. so aus:

TokenServiceTest.java:

 1public class TokenServiceTest {
 2  private TokenService tokenService;
 3
 4  @Before
 5  public void setUp () {
 6    tokenService = new TokenService ();
 7  }
 8
 9  @Test
10  public void createTokenReturnsExpectedToken () {
11    final Token token              = tokenService.createToken ();
12    final long  expectedExpireTime = System.currentTimeMillis () + TOKEN_EXPIRE_AFTER_MILLI_SECONDS;
13    assertTrue (Math.abs (expectedExpireTime - token.getExpiresAtMillis ()) < 100);
14  }
15
16  @Test
17  public void tokenIsStillValidSomeTimeBeforeItExpiresAndInvalidSomeTimeAfterItExpires () throws InterruptedException {
18    final Token token = tokenService.createToken ();
19    // wait until 100 ms before token expires
20    Thread.sleep (TOKEN_EXPIRE_AFTER_MILLI_SECONDS - 100);
21    // token should still be valid
22    assertFalse (tokenService.isExpired (token));
23    // wait for additional 200 ms
24    Thread.sleep (200);
25    // token should be invalid now
26    assertTrue (tokenService.isExpired (token));
27  }
28}

Probleme

Es ergeben sich zwei wesentliche Probleme:

  • der Test-Code in Zeile 13 darf die erwartete Ablaufzeit nicht direkt mit der echten Ablaufzeit vergleichen sondern muss mit einer Toleranz arbeiten (in diesem Fall: 100 ms). Die nötige Toleranz ist aber abhängig vom System, auf dem der Test ausgeführt wird. Z.B. kann auf dem Entwicklungsrechner eine Toleranz von 50 ms ausreichen, auf einem Continuous Integration Server, der zudem unter Last steht, können aber erhebliche größere Werte notwendig sein. Der Test wird also fragil.
  • der Test-Code in den Zeilen 20 und 24 wartet (durch Thread.sleep ()) bis die Systemzeit so weit fortgeschritten ist, dass ein erwartetes Verhalten geprüft werden kann. Im Beispiel muss nun aber 30 Minuten gewartet werden, bis das Token nicht mehr gültig ist - jeder Testlauf würde also mehr als 30 Minuten laufen!

Das zweite Problem könnte man zwar lösen indem man die Zeit TOKEN_EXPIRE_AFTER_MILLI_SECONDS konfigurierbar gestaltet und in der Produktionsumgebung 30 Minuten verwendet während man in der Test-Umgebung nur z.B. 5 Sekunden verwendet. Der Test würde aber immer noch 5 Sekunden untätig warten und das erste Problem würde immer noch bestehen.

Idee

Es gibt eine bessere Idee: anstatt im TokenService die Systemzeit direkt auszulesen wird ein eigener TimeService implementiert, der dann durch Dependency-Injection für den TokenService nutzbar gemacht wird.

Der TimeService ist denkbar einfach - er enthält nur eine Methode, die die aktuelle Systemzeit liefert:

TimeService.java:

1public class TimeService {
2  public long currentTimeMillis () {
3    return System.currentTimeMillis ();
4  }
5}

Der TokenService sieht nach der Anpassung so aus:

TokenService.java:

 1public class TokenService {
 2  private final TimeService timeService;
 3
 4  // token expires after 30 minutes
 5  static final long TOKEN_EXPIRE_AFTER_MILLI_SECONDS = TimeUnit.SECONDS.toMillis (30);
 6
 7  public TokenService (final TimeService timeService) {
 8    this.timeService = timeService;
 9  }
10
11  public Token createToken () {
12    return new Token (UUID.randomUUID ().toString (), timeService.currentTimeMillis () + TOKEN_EXPIRE_AFTER_MILLI_SECONDS);
13  }
14
15  public boolean isExpired (final Token token) {
16    return token.getExpiresAtMillis () < timeService.currentTimeMillis ();
17  }
18}

Alle Zugriffe auf System.currentTimeMillis() wurden durch Aufrufe von TimeService.currentTimeMillis() ersetzt.

Durch diese Änderung kann der Unit-Test nun einen Stub des TimeService nutzen, der die Systemzeit nur simuliert anstatt sie wirklich zu nutzen:

TimeServiceTest.java:

 1public class TokenServiceTest {
 2  private TokenService tokenService;
 3  private long         virtualTime;
 4
 5  @Before
 6  public void setUp () {
 7    virtualTime = 0;
 8    final TimeService timeService = mock (TimeService.class);
 9    when (timeService.currentTimeMillis ()).then ((invocationOnMock) -> virtualTime);
10    tokenService = new TokenService (timeService);
11  }
12
13  @Test
14  public void createTokenReturnsExpectedToken () {
15    virtualTime = 1234;
16    final Token token = tokenService.createToken ();
17    assertEquals (virtualTime + TOKEN_EXPIRE_AFTER_MILLI_SECONDS, token.getExpiresAtMillis ());
18  }
19
20  @Test
21  public void tokenIsStillValidSomeTimeBeforeItExpiresAndInvalidSomeTimeAfterItExpires () {
22    virtualTime = 0;
23    final Token token = tokenService.createToken ();
24    // wait until 1 ms before token expires
25    virtualTime = TOKEN_EXPIRE_AFTER_MILLI_SECONDS - 1;
26    // token should still be valid
27    assertFalse (tokenService.isExpired (token));
28    // wait for additional 2 ms
29    virtualTime += 2;
30    // token should be invalid now
31    assertTrue (tokenService.isExpired (token));
32  }
33}

In den Zeilen 8 und 9 wird ein Mock des TimeService erzeugt und in Zeile 10 per Constructor-Injection dem TokenService bekannt gemacht. Immer, wenn der TokenService den Mock des TimeService nach der Systemzeit fragt, wird die virtuelle Zeit aus der Variablen virtualTime zurückgegeben. Dadurch kann die virtuelle Zeit einfach durch eine Zuweisung an diese Variable manipuliert werden. Innerhalb der Tests können so immer die Voraussetzungen für Prüfungen geschaffen werden.

Damit sind die beiden zu Beginn erkannten Probleme behoben:

  • die Tests sind nach der Änderung nun nicht mehr fragil, da sie nicht mehr von der Systemzeit und damit auch nicht mehr von der Performance und Last des Rechners abhängen
  • die Ausführungszeit der Tests ist extrem kurz und hängt nicht von der Gültigkeitsdauer des Tokens ab

Spock

Die Tests werden meiner Meinung nach besser lesbar, wenn man sie mit dem Spock Framework anstatt mit JUnit umsetzt:

TimeServiceSpec.groovy:

 1class TokenServiceSpec extends Specification {
 2  private TokenService tokenService
 3  private long         virtualTime
 4
 5  def setup () {
 6    virtualTime = 0
 7    final TimeService timeService = Stub (TimeService) {
 8      currentTimeMillis () >> {return virtualTime}
 9    }
10    tokenService = new TokenService (timeService)
11  }
12
13  def 'create token returns expected token' () {
14    given: 'some virtual time'
15      virtualTime = 1234
16
17    when: 'a token is created'
18      final Token token = tokenService.createToken ()
19
20    then: 'the token expires at the virtual time + some fixed time until token expires'
21      virtualTime + TOKEN_EXPIRE_AFTER_MILLI_SECONDS == token.expiresAtMillis
22  }
23
24  def 'token is still valid just before it expires and invalid just after it expires' () {
25    given: 'some virtual time'
26      virtualTime = 0
27
28    when: 'a token is created'
29      final Token token = tokenService.createToken ()
30
31    and: 'some time passed until 1 ms before token expires'
32      virtualTime = TOKEN_EXPIRE_AFTER_MILLI_SECONDS - 1
33
34    then: 'the token should still be valid'
35      !tokenService.isExpired (token)
36
37    when: 'time advances by additional 2 ms'
38      virtualTime += 2
39
40    then: 'the token should be invalid now'
41      tokenService.isExpired (token)
42  }
43}

Bonus: Beispiel-Szenario mit Kotlin

Als kleines Demonstrationsbeispiel möchte ich noch eine alternative Implementierung mit Kotlin und ohne Services und Dependency-Injection zeigen. Das Token enthält die Logik nun selbst anstatt sie in einen Token-Service auszulagern:

Token.kt:

 1class Token (private val time: SystemTime) {
 2  companion object {
 3    val TOKEN_EXPIRE_AFTER_MILLI_SECONDS = TimeUnit.SECONDS.toMillis (30)
 4  }
 5
 6  private val id:              String = UUID.randomUUID ().toString ()
 7  private val expiresAtMillis: Long   = time.currentTimeMillis + TOKEN_EXPIRE_AFTER_MILLI_SECONDS
 8
 9  fun isExpired () = time.currentTimeMillis >= expiresAtMillis
10
11  override fun toString () = "Token {id = '$id', expiresAtMillis = $expiresAtMillis}"
12}

Die Systemzeit, die im Konstruktor angegeben wird, ist nun ein Interface, für das es zwei Implementierungen gibt, die alle Zugriffe auf die Systemzeit explizit modellieren:

SystemTime.kt:

 1interface SystemTime {
 2  fun sleep             (time: Long, timeUnit: TimeUnit)
 3  val currentTimeMillis: Long
 4}
 5
 6class RealSystemTime : SystemTime {
 7  override fun sleep (time: Long, timeUnit: TimeUnit) = Thread.sleep (timeUnit.toMillis (time))
 8
 9  override val currentTimeMillis: Long get () = System.currentTimeMillis()
10}
11
12class VirtualSystemTime : SystemTime {
13  private var virtualTime: Long = 0
14
15  override fun sleep (time: Long, timeUnit: TimeUnit) {
16    virtualTime += timeUnit.toMillis (time)
17  }
18
19  override val currentTimeMillis: Long get () = virtualTime
20}

Der zugehörige Spock-Test benötigt nun keine Mocks oder Stubs mehr sondern verwendet einfach die für die Tests passende Implementierung:

TokenSpec.groovy:

 1class TokenSpec extends Specification {
 2  private SystemTime time
 3
 4  void setup () {
 5    time = new VirtualSystemTime ()
 6  }
 7
 8  void 'created token has expected properties' () {
 9    given: 'some time'
10      time.sleep (1234, TimeUnit.MILLISECONDS)
11
12    when: 'a token is created'
13      final Token token = new Token (time)
14
15    then: 'the token expires at the virtual time + some fixed time until token expires'
16      time.currentTimeMillis + Token.TOKEN_EXPIRE_AFTER_MILLI_SECONDS == token.expiresAtMillis
17  }
18
19  void 'token is still valid some time before it expires and invalid some time after it expires' () {
20    when: 'a token is created'
21      final Token token = new Token (time)
22
23    and: 'some time passed until 1 ms before token expires'
24      time.sleep (Token.TOKEN_EXPIRE_AFTER_MILLI_SECONDS - 1, TimeUnit.MILLISECONDS)
25
26    then: 'the token should still be valid'
27      !token.isExpired ()
28
29    when: 'time advances by additional 2 ms'
30      time.sleep (2, TimeUnit.MILLISECONDS)
31
32    then: 'the token should be invalid now'
33      token.isExpired ()
34  }
35}

Fazit

Man kann Code, dessen Verhalten von der Systemzeit abhängt sehr einfach, zuverlässig, nachvollziehbar und performant testen, wenn man die richtige Implementierung wählt. Es zeigt sich wie so oft, dass es sinnvoll ist, bereits beim Design einer Lösung an die Testbarkeit zu denken und dass das Testen dann auch kein Problem mehr ist.

Getaggt in:
Unit-Tests JUnit Spock Java Kotlin Zeit
comments powered by Disqus