Mock thời gian trong Java: cách đơn giản để test logic phụ thuộc vào thời gian

Khi viết code Java, có lẽ ai cũng từng gặp tình huống: logic nghiệp vụ phụ thuộc vào thời gian hiện tại — đơn hàng hết hạn sau 30 phút, voucher chỉ áp dụng trong giờ vàng, cron job chạy lúc nửa đêm, token refresh sau 1 giờ… Khi viết unit test cho những đoạn code này, bạn làm sao để “tua” thời gian tới trước hay lùi lại?

Bài này mình chia sẻ cách mình đang dùng trong dự án mitisrv — cực kỳ đơn giản, không cần framework nặng nề nào. Cảm hứng từ bài viết Mock Java Date/Time for Testing trên DZone.

Vấn đề

Giả sử bạn có đoạn code như này:

1
2
3
public boolean isExpired(Order order) {
  return LocalDateTime.now().isAfter(order.getExpiredAt());
}

Khi test, bạn sẽ phải:

  • Tạo order với expiredAt là quá khứ → test case expired ✓
  • Tạo order với expiredAt là tương lai → test case còn hạn ✓
  • Nhưng còn test case “đúng thời điểm hết hạn” thì sao? Hoặc test logic chạy vào đúng 00:00 ngày mai?

Việc gọi trực tiếp LocalDateTime.now() biến code thành non-deterministic — test chạy lúc 11h59 có thể pass, nhưng chạy lúc 00h01 lại fail. Đây là cơn ác mộng của CI/CD.

Các cách tiếp cận phổ biến

1. Inject java.time.Clock

Java 8+ khuyến nghị dùng Clock và inject vào chỗ cần:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService {
  private final Clock clock;

  public OrderService(Clock clock) {
    this.clock = clock;
  }

  public boolean isExpired(Order order) {
    return LocalDateTime.now(clock).isAfter(order.getExpiredAt());
  }
}

Trong test, dùng Clock.fixed(...) để cố định thời gian. Ưu điểm: sạch sẽ, đúng chuẩn. Nhược điểm: phải sửa toàn bộ code base để inject Clock vào từng class, rất phiền nếu dự án đã lớn.

2. Mock static với PowerMock/Mockito

1
2
3
4
try (MockedStatic<LocalDateTime> mocked = mockStatic(LocalDateTime.class)) {
  mocked.when(LocalDateTime::now).thenReturn(fixedTime);
  // test code
}

Ưu điểm: không cần sửa code production. Nhược điểm: static mocking chậm, dễ leak giữa các test, và mockStatic chỉ support từ Mockito 3.4+. Nếu code gọi LocalDate.now(), Instant.now(), ZonedDateTime.now() xen kẽ thì phải mock từng cái.

3. Custom Time utility với delta offset

Đây là cách mà mình chọn cho mitisrv. Ý tưởng: tạo một class Time thay thế cho tất cả cách lấy thời gian hiện tại, và bên trong lưu một deltaMillis — độ lệch giữa “thời gian giả” và thời gian thật của hệ thống.

Implementation trong mitisrv

Đây là toàn bộ file Time.java:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.miti99.mitisrv.util.time;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.TimeZone;

public class Time {
  private static final TimeZone TIME_ZONE = TimeZone.getDefault();
  private static final ZoneId ZONE_ID = TIME_ZONE.toZoneId();
  private static volatile long deltaMillis = 0L;

  public static LocalDate currentDate() {
    return LocalDate.ofInstant(currentInstant(), ZONE_ID);
  }

  public static LocalTime currentTime() {
    return LocalTime.ofInstant(currentInstant(), ZONE_ID);
  }

  public static LocalDateTime currentDateTime() {
    return LocalDateTime.ofInstant(currentInstant(), ZONE_ID);
  }

  public static OffsetDateTime currentOffsetDateTime() {
    return OffsetDateTime.ofInstant(currentInstant(), ZONE_ID);
  }

  public static ZonedDateTime currentZonedDateTime() {
    return ZonedDateTime.ofInstant(currentInstant(), ZONE_ID);
  }

  public static Instant currentInstant() {
    return Instant.ofEpochMilli(currentTimeMillis());
  }

  public static long currentTimeMillis() {
    return System.currentTimeMillis() + deltaMillis;
  }

  public static void useMockTime(LocalDateTime dateTime, ZoneId zoneId) {
    deltaMillis = dateTime.atZone(zoneId).toInstant().toEpochMilli() - System.currentTimeMillis();
  }

  public static void useSystemDefaultZoneClock() {
    deltaMillis = 0L;
  }
}

Một vài điểm đáng chú ý:

  • Tất cả các API lấy thời gian (currentDate, currentDateTime, currentInstant, v.v.) đều chạy qua một hàm duy nhất: currentTimeMillis(). Chỉ cần một chỗ “mock” là cả hệ thống đồng bộ.
  • deltaMillisvolatile để đảm bảo thay đổi được thấy ngay lập tức giữa các thread — quan trọng khi test trên môi trường multi-thread.
  • useMockTime() không đóng băng thời gian, mà chỉ dịch chuyển nó. Nghĩa là sau khi set mock tới 2026-01-01 00:00:00, nếu chờ 5 giây thật thì Time.currentDateTime() sẽ trả về 2026-01-01 00:00:05. Đây là điểm khác biệt quan trọng so với Clock.fixed() — time vẫn “chảy” nhưng từ một điểm bắt đầu khác.
  • useSystemDefaultZoneClock() reset về thời gian thật, gọi ở @AfterEach để test case không ảnh hưởng nhau.

Cách dùng

Trong code production, thay vì gọi LocalDateTime.now() thì gọi Time.currentDateTime():

1
2
3
public boolean isExpired(Order order) {
  return Time.currentDateTime().isAfter(order.getExpiredAt());
}

Trong test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void orderShouldBeExpiredAfter30Minutes() {
  Order order = new Order();
  order.setExpiredAt(LocalDateTime.of(2026, 1, 1, 12, 30, 0));

  // "Tua" thời gian tới 12:31
  Time.useMockTime(
      LocalDateTime.of(2026, 1, 1, 12, 31, 0),
      ZoneId.systemDefault()
  );

  assertTrue(service.isExpired(order));

  Time.useSystemDefaultZoneClock(); // reset
}

Dùng @AfterEach để đảm bảo reset đúng cách:

1
2
3
4
@AfterEach
void tearDown() {
  Time.useSystemDefaultZoneClock();
}

So sánh các approach

Tiêu chíClock injectmockStaticCustom Time util
Cần sửa code productionNhiều (inject Clock)KhôngMột lần (replace .now())
Phụ thuộc frameworkKhôngMockito 3.4+Không
Test speedNhanhChậmNhanh
Hỗ trợ time trôiKhông (Clock.fixed)Không
Thread-safeCó (trong scope)Có (volatile)
Đúng “Java way”✓✓✓

Nhược điểm của custom Time util

Không có cách nào hoàn hảo. Cách này cũng có vài điểm yếu:

  • Global state: deltaMillisstatic, nên nếu chạy test song song trong cùng JVM (parallel test), các test sẽ “giẫm chân” nhau. Nếu cần parallel, phải cấu hình test framework chạy mỗi class trong JVM riêng, hoặc dùng ThreadLocal thay cho volatile.
  • Phải sửa toàn bộ code để không gọi trực tiếp LocalDateTime.now() nữa. Nếu lỡ có thư viện bên thứ ba gọi .now() thì util này không control được.
  • Không chuẩn Java: người mới vào dự án có thể ngạc nhiên khi thấy Time.currentDateTime() thay vì LocalDateTime.now().

Khi nào nên dùng cách nào?

  • Dự án mới, nhỏ, team quen DI: dùng Clock inject cho đúng chuẩn.
  • Dự án legacy, không muốn refactor lớn: dùng mockStatic cục bộ cho từng test, chấp nhận chậm.
  • Dự án muốn vừa đơn giản vừa kiểm soát được time flow, chạy test sequential: dùng custom Time util như mitisrv.

Với mình, Time util đơn giản, dễ hiểu, dễ maintain, và không cần học thêm framework. Trade-off là phải kỷ luật khi viết code — luôn gọi Time.xxx() thay vì LocalDateTime.now(). Một lint rule đơn giản trong PR review là đủ để giữ được quy tắc này.

Tham khảo

Made by miti99 with ❤️
Built with Hugo
Theme Stack thiết kế bởi Jimmy