unit-test-application-events について
Springのイベント発行元とリスナーをJUnit 5、Mockitoで検証する開発者向けのテストスキルです。Springコンテキスト全体を起動せずに行う単体テストを中心に、非同期ハンドラーも扱います。
主な機能
- 01イベント発行元のモック化
- 02イベントペイロードの取得と検査
- 03リスナーの分離テスト
- 04非同期ハンドラーの検証
- 05リスナーのエラー経路のテスト
活用シーン
- 01発行イベントの型とフィールドを確認
- 02モック依存先でリスナーの副作用をテスト
- 03非同期イベント処理を検証
- 04イベント処理のエラーケースをテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill unit-test-application-eventsインストール後、Claude Code で/unit-test-application-eventsを入力して読み込まれたか確認します。
詳細
アプリケーションイベントの単体テスト
概要
Spring の ApplicationEvent パブリッシャーと @EventListener コンシューマーを、Spring コンテキスト全体を起動せずに JUnit 5 と Mockito でテストするための、実践的なパターンを紹介します。
使用する場面
- イベントのパブリッシャーやリスナーの単体テストを作成する場合
- 正しいペイロードでイベントが発行されたことを検証する場合
@EventListenerメソッドの呼び出しや副作用をテストする場合- 複数のリスナーを介したイベント伝播をテストする場合
- 非同期イベント処理(
@Async+@EventListener)を検証する場合 - サービスのテストで
ApplicationEventPublisherをモックする場合
手順
- テスト依存関係を追加する:
spring-boot-starter、JUnit 5、Mockito、AssertJ - ApplicationEventPublisher をモックする:テスト対象のサービスのパブリッシャーフィールドに
@Mockを使用します - ArgumentCaptor でイベントをキャプチャする:
ArgumentCaptor.forClass(EventType.class)を使って、発行されたペイロードを調べます - リスナーの副作用を検証する:モックした依存関係を使用して、リスナーを直接呼び出します
- 非同期ハンドラーをテストする:
Thread.sleep()または Awaitility を使用し、非同期処理が呼び出されたことを確認します - 検証チェックポイントを追加する:
- イベントをキャプチャした後、フィールドを検証する前に
eventCaptor.getValue()が null でないことを確認します - リスナーが呼び出されない場合は、正しいイベント型で
publishEvent()が呼び出されたことを検証します - 非同期処理のアサーションが失敗する場合は、待機時間を延ばし、エグゼキュータープールが飽和していないことを確認します
- イベントをキャプチャした後、フィールドを検証する前に
- エラーケースを網羅する:リスナーが例外を適切に処理することをアサートします
例
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Gradle
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core")
testImplementation("org.assertj:assertj-core")
}
カスタムイベントとパブリッシャーのテスト
public class UserCreatedEvent extends ApplicationEvent {
private final User user;
public UserCreatedEvent(Object source, User user) {
super(source);
this.user = user;
}
public User getUser() { return user; }
}
@Service
public class UserService {
private final ApplicationEventPublisher eventPublisher;
private final UserRepository userRepository;
public UserService(ApplicationEventPublisher eventPublisher, UserRepository userRepository) {
this.eventPublisher = eventPublisher;
this.userRepository = userRepository;
}
public User createUser(String name, String email) {
User savedUser = userRepository.save(new User(name, email));
eventPublisher.publishEvent(new UserCreatedEvent(this, savedUser));
return savedUser;
}
}
イベント発行の単体テスト
@ExtendWith(MockitoExtension.class)
class UserServiceEventTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldPublishUserCreatedEvent() {
User newUser = new User(1L, "Alice", "[email protected]");
when(userRepository.save(any(User.class))).thenReturn(newUser);
ArgumentCaptor<UserCreatedEvent> eventCaptor = ArgumentCaptor.forClass(UserCreatedEvent.class);
userService.createUser("Alice", "[email protected]");
verify(eventPublisher).publishEvent(eventCaptor.capture());
assertThat(eventCaptor.getValue().getUser()).isEqualTo(newUser);
}
}
リスナーの直接テスト
@Component
public class UserEventListener {
private final EmailService emailService;
public UserEventListener(EmailService emailService) { this.emailService = emailService; }
@EventListener
public void onUserCreated(UserCreatedEvent event) {
emailService.sendWelcomeEmail(event.getUser().getEmail());
}
}
class UserEventListenerTest {
@Test
void shouldSendWelcomeEmailOnUserCreated() {
EmailService emailService = mock(EmailService.class);
UserEventListener listener = new UserEventListener(emailService);
User user = new User(1L, "Alice", "[email protected]");
listener.onUserCreated(new UserCreatedEvent(this, user));
verify(emailService).sendWelcomeEmail("[email protected]");
}
@Test
void shouldNotThrowWhenEmailServiceFails() {
EmailService emailService = mock(EmailService.class);
doThrow(new RuntimeException("down")).when(emailService).sendWelcomeEmail(any());
UserEventListener listener = new UserEventListener(emailService);
User user = new User(1L, "Alice", "[email protected]");
assertThatCode(() -> listener.onUserCreated(new UserCreatedEvent(this, user)))
.doesNotThrowAnyException();
}
}
非同期リスナーのテスト
@Component
public class AsyncEventListener {
private final SlowService slowService;
@EventListener
@Async
public void onUserCreatedAsync(UserCreatedEvent event) {
slowService.processUser(event.getUser());
}
}
class AsyncEventListenerTest {
@Test
void shouldProcessEventAsynchronously() throws Exception {
SlowService slowService = mock(SlowService.class);
AsyncEventListener listener = new AsyncEventListener(slowService);
User user = new User(1L, "Alice", "[email protected]");
listener.onUserCreatedAsync(new UserCreatedEvent(this, user));
Thread.sleep(200); // checkpoint: allow async executor to run
verify(slowService).processUser(user);
}
}
ベストプラクティス
ApplicationEventPublisherをモックします。単体テストで実際のコンテキストにイベントを発行させないでくださいArgumentCaptorでイベントをキャプチャし、型だけでなくフィールド単位で等しいことをアサートします- リスナーを個別にテストします。モックした依存関係を使ってインスタンスを作成し、ハンドラーメソッドを直接呼び出します
- エラーパスを網羅します。リスナーからパブリッシャーへ例外を伝播させてはいけません
- 非同期リスナーでは、決定的な待機のために
Thread.sleep()より Awaitility を優先します - イベントは不変かつシリアライズ可能に保ちます。イベントが JVM の境界を越える場合は、両方をテストします
制約と警告
- Spring 自体のイベントインフラストラクチャをテストしないでください。ビジネスロジックとイベントペイロードに焦点を当てます
@Asyncには@EnableAsyncが必要です。Thread.sleep を使用するテストは、テストで非同期プロキシが設定されていなくても通る場合があります。代わりにモックの検証を使用してください- Spring はリスナーの順序を保証しません。
@Orderを追加しない限り、実行順序に依存するテストは作成しないでください - CI 環境では
Thread.sleep()を避けてください。負荷がかかるとテストが不安定になります。Awaitility の.atMost()ブロックに置き換えてください - JVM の境界を越えるイベントにはシリアライズのテストが必要です。リモートリスナーのフィールドが null の場合、
Serializableの指定漏れが原因であることがよくあります
参考資料
コメント
Markdown 対応