InstantSourceでJavaのシステム時刻の作成を抽象化しテスタビリティを上げる

Javaでシステム時刻(現在時刻)を取得する際にInstant.now()LocalDateTime.now()などを使うことが一般的です。しかし、これらのメソッドはOSのシステムクロックに依存しているため、テスト時にシステム時刻を制御することが難しくなります。 システム時刻の作成を抽象化するためのクラスとしてJDKには

  • java.time.Clock - JDK 8で追加
  • java.time.InstantSource - JDK 17で追加

が用意されています。ClockInstantSourceとの違いは前者はタイムゾーンを保持していることです。InstantSourcejava.time.Instant生成のみを扱います。 また、Clockはabstractクラスですが、InstantSourceはinterfaceです。

Clockを使う場合は、次のように日付・時刻を取得します。

Clock clock = Clock.systemUTC();
Clock clock = Clock.systemDefaultZone();

Instant now = clock.instant();
OffsetDateTime now = OffsetDateTime.now(clock);
LocalDateTime now = LocalDateTime.now(clock);
LocalDate now = LocalDate.now(clock);

InstantSourceを使う場合は、次のように時刻を取得します。

InstantSource instantSource = InstantSource.system();

Instant now = instantSource.instant();

システム時刻(Instant)とユーザーのローカライゼーション(ZoneId)は本来別々の関心毎ですが、Clockではこれらが結合しています。 シンプルにシステム時刻だけを取得するインタフェースがあるべきだ、ということでJDK 17でInstantSourceが追加されました。 より詳細な経緯はこちらから確認できます。

以下ではInstantSourceを使った例を示しますが、ClockInstantSourceインタフェースを実装しており、ClockのインスタンスはInstantSourceとしても利用できます。 おそらく、日本国内でのみ利用されるシステムのように、タイムゾーンが固定されるケースではClockを使った方がLocalDateの生成など便利な場合が多いかもしれません。 なお、InstantSourceからClockへの変換は次のように行えます。

Clock clock = instantSource.withZone(ZoneId.systemDefault());

さて、実際のアプリケーションにおいてはInstantSourceはDependency Injection(DI)コンテナなどを使って注入することが一般的です。例えば、Spring Bootを使っている場合は次のようにBean定義を行います:

import java.time.InstantSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class AppConfig {

	@Bean
	InstantSource instantSource() {
		return InstantSource.system();
	}

}

InstantSourceはFunctional Interfaceなので、次のようにラムダ式で実装することも可能です。

	@Bean
	InstantSource instantSource() {
		return Instant::now;
	}

コード中でInstantを作成したい場合は、InstantSourceをinjectして利用します。

@Service
public class MessageService {
	private final InstantSource instantSource;

	public MessageService(InstantSource instantSource) {
		this.instantSource = instantSource;
	}

	public Message createMessage(String content) {
		Instant now = instantSource.instant();
		return new Message(content, now);
	}
}

InstantSourceはインタフェースなので、テスト時に差し替えるのが楽です。 テストコードの例を示します。ここではMockitoを使ってInstantSourceをモック化し、特定の時刻を返却するようにしています。

import java.time.Instant;
import java.time.InstantSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

@ExtendWith(SpringExtension.class)
@Import(MessageService.class)
class MessageServiceTest {

	@Autowired
	MessageService messageService;

	@MockitoBean
	InstantSource instantSource;

	@Test
	void createMessage() {
		given(instantSource.instant()).willReturn(Instant.parse("2026-01-01T00:00:00.00Z"));
		Message message = messageService.createMessage("Hello, World!");
		assertThat(message.toString()).isEqualTo("Message[content=Hello, World!, timestamp=2026-01-01T00:00:00Z]");
	}

}

LocalDateを作成したい場合は、次のようにします。

		ZoneId zoneId = ZoneId.systemDefault(); // or ZoneId.of("Asia/Tokyo");
		LocalDate now = instantSource.instant().atZone(zoneId).toLocalDate();

あるいは、injectionのタイミングでタイムゾーンを設定する方が良いかもしれません。

@Service
public class MessageService {
	private final Clock clock;

	public MessageService(InstantSource instantSource) {
		this.clock = instantSource.withZone(ZoneId.systemDefault());
	}

	public Message createMessage(String content) {
		LocalDate now = LocalDate.now(this.clock);
		return new Message(content, now);
	}
}

ユーザー毎によってZoneIdを変えたいという場合にはorg.springframework.format.datetime.standard.DateTimeContextHolderでスレッドローカルにZoneIdを保持する方法などもあります。

エンタープライズ開発でよく見られるのはシステム時刻をデータベースから取得するケースです。システムテストなどで、特定の時間のテストを行いたい場合などに有効です。 データベースからシステム時刻を取得する例を示します。ここではPostgreSQLを前提とします。

次のように、特定のシステム時刻を設定するテーブルがあるとします。

CREATE TABLE IF NOT EXISTS system_date
(
    date_time TIMESTAMP WITH TIME ZONE
);

このテーブルにデータが存在する場合はその日時を、存在しない場合はデータベースの現在時刻をシステム時刻として取得する例を示します。

import java.time.InstantSource;
import java.time.OffsetDateTime;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;

@Configuration(proxyBeanMethods = false)
class AppConfig {

	@Bean
	InstantSource jdbcInstantSource(JdbcClient jdbcClient) {
		return () -> jdbcClient.sql("SELECT COALESCE((SELECT date_time FROM system_date), NOW())")
			.query(OffsetDateTime.class)
			.single()
			.toInstant();
	}

}

あるいは、データベース上に、現在時刻からのオフセット(分)を保持するテーブルがある場合は、次のように実装できます。

CREATE TABLE system_date
(
    offset_minutes INT NOT NULL
);

このテーブルにデータが存在する場合はその値を分に、存在しない場合は0をシステム時刻に追加する例を示します。

import java.time.InstantSource;
import java.time.OffsetDateTime;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;

@Configuration(proxyBeanMethods = false)
class AppConfig {

	@Bean
	InstantSource jdbcInstantSource(JdbcClient jdbcClient) {
		return () -> jdbcClient.sql("""
				SELECT
				    NOW() + MAKE_INTERVAL(
				        mins => COALESCE(MAX(offset_minutes), 0)
				    )
				FROM
				    system_date
				""").query(OffsetDateTime.class).single().toInstant();
	}

}

例えば、1時間先の時刻でテストしたい場合は、次のようなレコードを挿入すれば良いです。

INSERT INTO system_date(offset_minutes) VALUES (60);

テストが終わり、このレコードを削除すると、通常の現在時刻を返すようになります。

これらを使うことで、アプリケーションを起動し直すことなく、データベースの内容を変更するだけでシステム時刻を変更できるようになります。


InstantSourceを使うことで、システム時刻の取得を抽象化し、テスト時に時刻を自由に制御できるようになります。 Instant.now()LocalDate.now()を直接使用している場合は、InstantSourceを経由するように変更して、よりテスタブルなコードを書くことをお勧めします。