2026年6月3日水曜日

SpringBoot で Redis を使った非同期処理をやってみる

SpringBoot で Redis を使った非同期処理をやってみる

概要

デフォルトで使用できる Redis クライアントがありこれを使って簡単に非同期処理が実現できるので試してみました
メッセージのやり取りには DTO クラスを使い Json メッセージを DTO に変換してエンキュー/デキュー時に使用するようにしてみます

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

build.gradle

SpringBoot から Redis に接続するためのライブラリとメッセージのやり取りをしやすくするための jackson-databind を追加でインストールします

  • org.springframework.boot:spring-boot-starter-json
  • org.springframework.boot:spring-boot-starter-data-redis
  • com.fasterxml.jackson.core:jackson-databind
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.springframework.boot:spring-boot-starter-json'
	implementation 'com.fasterxml.jackson.core:jackson-databind'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:4.0.4'
	developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testImplementation 'org.springframework.boot:spring-boot-testcontainers'
	testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
	testImplementation 'org.testcontainers:testcontainers-mysql'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

ワーカーの作成(デキュー)

まずはワーカーを作成します
Json メッセージを受け取り処理する部分です
今回は Json -> DTO に変換してから使います

  • vim src/main/java/com/example/demo/worker/RedisMessageReceiver.java
package com.example.demo.worker;

import java.util.concurrent.atomic.AtomicInteger;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.example.demo.dto.RedisMessageRequest;
import com.fasterxml.jackson.databind.ObjectMapper;

public class RedisMessageReceiver {
    private static final Logger LOGGER = LoggerFactory.getLogger(RedisMessageReceiver.class);

    private AtomicInteger counter = new AtomicInteger();
    private ObjectMapper objectMapper;

    public RedisMessageReceiver(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public void receiveMessage(String message) {
        try {
            // JSON から RedisMessageRequest に変換
            RedisMessageRequest request = objectMapper.readValue(message, RedisMessageRequest.class);

            // ワーカー側でメッセージ情報を使用
            LOGGER.info("Received message - Name: {}, Email: {}", request.getName(), request.getEmail());
            counter.incrementAndGet();
        } catch (Exception e) {
            LOGGER.error("Failed to parse Redis message", e);
        }
    }

    public int getCount() {
        return counter.get();
    }
}

DTO の作成

メッセージ情報管理する DTO クラスです
ビジネスロジックを含まないバリデーションや加工処理が必要な場合はここで行います

  • vim src/main/java/com/example/demo/dto/RedisMessageRequest.java
package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;

public class RedisMessageRequest {

	@NotBlank
	private String name;

	@NotBlank
	private String email;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}
}

Redis接続設定

SpringBoot では Redis への接続情報は @Bean を使うのが定石です
@Bean の定義は @SpringBootApplication または @Configuration 配下で使えます

@Bean としてオブジェクトを定義しておくと各種クラウで @Autowired できるようになります

送信トピックやワーカーの登録などを行います

  • vim src/main/java/com/example/demo/config/RedisConfig.java
package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

import com.example.demo.worker.RedisMessageReceiver;
import com.fasterxml.jackson.databind.ObjectMapper;

@Configuration
public class RedisConfig {

	@Bean
	ObjectMapper objectMapper() {
		return new ObjectMapper();
	}

	@Bean
	RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
			MessageListenerAdapter listenerAdapter) {

		RedisMessageListenerContainer container = new RedisMessageListenerContainer();
		container.setConnectionFactory(connectionFactory);
		container.addMessageListener(listenerAdapter, new PatternTopic("chat"));

		return container;
	}

	@Bean
	MessageListenerAdapter listenerAdapter(RedisMessageReceiver receiver) {
		return new MessageListenerAdapter(receiver, "receiveMessage");
	}

	@Bean
	RedisMessageReceiver receiver(ObjectMapper objectMapper) {
		return new RedisMessageReceiver(objectMapper);
	}
}

application.properties の修正

Redis の接続先情報はここで定義します

  • vim src/main/resources/application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379

サービス側でエンキュー処理

エンキューはサービス側で行います
StringRedisTemplate を DI するだけで使えます
今回はメッセージをエンキューする際も RedisMessageRequest を使いますが本当はエンキュー時とデキュー時は別の DTO クラスを定義するほうがキレイです

jackson を使って DTO クラスを自動的に Json に形式に変換しエンキューします

  • vim src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import com.example.demo.dto.RedisMessageRequest;
import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private StringRedisTemplate redisTemplate;

	@Autowired
	private ObjectMapper objectMapper;

	public void createUser(UserRequest req) {

		// 例:ビジネスルール
		if (userRepository.existsByEmail(req.getEmail())) {
			throw new IllegalArgumentException("既に登録されています");
		}

		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);

		// Redis キューにメッセージを JSON 形式で送信
		try {
			RedisMessageRequest message = new RedisMessageRequest();
			message.setName(req.getName());
			message.setEmail(req.getEmail());

			String jsonMessage = objectMapper.writeValueAsString(message);
			redisTemplate.convertAndSend("chat", jsonMessage);
		} catch (Exception e) {
			throw new RuntimeException("Failed to send Redis message", e);
		}
	}

	public List<UserResponse> getAllUsers() {
		List<User> users = new ArrayList<>();
		userRepository.findAll().forEach(users::add);

		return users.stream().map(this::toResponse).toList();
	}

	private UserResponse toResponse(User user) {
		UserResponse response = new UserResponse();
		response.setName(user.getName());
		return response;
	}
}

動作確認

アプリを起動し curl を実行します
アプリ側のログにワーカーで出力しているメッセージが表示されれば OK です

redis が必要なのでローカルで起動しておきます (もしくは compose.yaml の docker compose plugin でも対応できます)

  • brew services run redis
  • ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'
  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail5@someemailprovider.com"}' -H 'content-type: application/json'
2026-05-25T10:17:20.254+09:00  INFO 34105 --- [demo] [    container-1] c.e.demo.worker.RedisMessageReceiver     : Received message - Name: First, Email: someemail5@someemailprovider.com

おまけ: テストの修正

サービスのテストで Redis 部分 (StringRedisTemplate) をモックするように書き換えます

  • vim src/test/java/com/example/demo/service/UserServiceTest.java
package com.example.demo.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.util.List;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

	@Mock
	private UserRepository userRepository;

	@Mock
	private StringRedisTemplate redisTemplate;

	@Mock
	private ObjectMapper objectMapper;

	@InjectMocks
	private UserService userService;

	@Test
	void ユーザー作成できる() throws Exception {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		when(objectMapper.writeValueAsString(any())).thenReturn("{\"name\":\"taro\",\"email\":\"taro@example.com\"}");
		when(redisTemplate.convertAndSend(eq("chat"), anyString())).thenReturn(1L);

		userService.createUser(req);

		verify(userRepository).save(any(User.class));
		verify(redisTemplate).convertAndSend(eq("chat"), anyString());
	}

	@Test
	void findAllメソッドが呼ばれたか() {
		userService.getAllUsers();

		verify(userRepository).findAll();
	}

	@Test
	void ユーザーの一覧が取得できる() {
		User user = new User();
		user.setName("taro");
		user.setEmail("taro@example.com");

		when(userRepository.findAll()).thenReturn(List.of(user));

		var result = userService.getAllUsers();

		assertEquals(1, result.size());
		assertEquals("taro", result.get(0).getName());
	}

	@Test
	void メールが重複している場合はエラー() {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		// モックの振る舞いを定義
		when(userRepository.existsByEmail("taro@example.com")).thenReturn(true);

		// 例外が投げられることを検証
		IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(req));

		assertEquals("既に登録されています", ex.getMessage());

		// saveが呼ばれていないことも重要
		verify(userRepository, never()).save(any());
	}
}

最後に

SpringBoot で非同期処理を行ってみました
簡単にできますがいくつか課題があります

  • プロセス分離できていない
  • 管理画面がない
  • リトライやデッドレターキューなどがない

などなど非同期処理に欠かせない機能が単純に欠如しています
本当に簡単な非同期処理なら良いですがそうでない場合はやはりデフォルトの機能ではなく外部のライブラリを使う他なさそうです

参考サイト

2026年6月2日火曜日

SpringBoot で Filter を使う方法

SpringBoot で Filter を使う方法

概要

Servlet の Filter は SpringBoot でも使えます

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

Filter の作成

Filter クラスを継承し @Component を使用するだけで SpringBoot が自動的に読み込んでくれます

SpringBoot では web.xml を準備して明示的に Filter を定義する必要はありません

  • vim src/main/java/com/example/demo/filter/LoggingFilter.java
package com.example.demo.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class LoggingFilter implements Filter {

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest req = (HttpServletRequest) request;

		System.out.println(">>> Request: " + req.getMethod() + " " + req.getRequestURI());

		chain.doFilter(request, response);

		System.out.println("<<< Response finished");
	}
}

最後に

フィルタは http 全体に対する共通処理に使います
ロギングや認証、共通ヘッダの操作などにフィルタを使います

同じような機能に Interpretor がありますがこれは複数登録できる他基本的にはコントローラ単位で使用する機能になります

2026年6月1日月曜日

SpringBoot でエラーハンドリングする方法

SpringBoot でエラーハンドリングする方法

概要

これまではエラーをそのまま返却しており SpringBoot 側はハンドリングされていないエラーはすべて 500 エラーで返します

今回は特定のエラーをハンドリングしエラーレスポンスをカスタムする方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

カスタムエラーの作成

まずはカスタムエラーを作成します
独自の Exception になります

  • vim src/main/java/com/example/demo/exception/BusinessException.java
package com.example.demo.exception;

public class BusinessException extends RuntimeException {

	private final String code;

	public BusinessException(String code, String message) {
		super(message);
		this.code = code;
	}

	public String getCode() {
		return code;
	}
}

エラーレスポンス用の DTO の作成

レスポンス用の DTO を作成します
エラーレスポンスとして返却したい情報を管理します

  • vim src/main/java/com/example/demo/exception/dto/ErrorResponse.java
package com.example.demo.exception.dto;

import java.time.LocalDateTime;

public class ErrorResponse {

	private String code;
	private String message;
	private LocalDateTime timestamp;

	public ErrorResponse(String code, String message, LocalDateTime timestamp) {
		this.code = code;
		this.message = message;
		this.timestamp = timestamp;
	}

	public String getCode() {
		return code;
	}

	public String getMessage() {
		return message;
	}

	public LocalDateTime getTimestamp() {
		return timestamp;
	}
}

エラーハンドラの作成

これがメイン部分です
SpringBoot では @RestControllerAdvice を基本的に使います
このアノテーションがあるクラスを自動的に読み込みエラーハンドリングしてくれます

  • vim src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.example.demo.exception.dto.ErrorResponse;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(BusinessException.class)
	@ResponseStatus(HttpStatus.BAD_REQUEST)
	public ErrorResponse handleBusinessException(BusinessException ex) {
		return new ErrorResponse(ex.getCode(), ex.getMessage(), LocalDateTime.now());
	}

	@ExceptionHandler(Exception.class)
	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
	public ErrorResponse handleException(Exception ex) {
		return new ErrorResponse("INTERNAL_SERVER_ERROR", "予期しないエラーが発生しました", LocalDateTime.now());
	}
}

サービス側修正

サービス側は作成したカスタム Exception を返却するようにします

  • vim src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import com.example.demo.dto.RedisMessageRequest;
import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.entity.User;
import com.example.demo.exception.BusinessException;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private StringRedisTemplate redisTemplate;

	@Autowired
	private ObjectMapper objectMapper;

	public void createUser(UserRequest req) {

		// 例:ビジネスルール
		if (userRepository.existsByEmail(req.getEmail())) {
			throw new BusinessException("USER_ALREADY_EXISTS", "既に登録されています");
		}

		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);

		// Redis キューにメッセージを JSON 形式で送信
		try {
			RedisMessageRequest message = new RedisMessageRequest();
			message.setName(req.getName());
			message.setEmail(req.getEmail());

			String jsonMessage = objectMapper.writeValueAsString(message);
			redisTemplate.convertAndSend("chat", jsonMessage);
		} catch (Exception e) {
			throw new RuntimeException("Failed to send Redis message", e);
		}
	}

	public List<UserResponse> getAllUsers() {
		List<User> users = new ArrayList<>();
		userRepository.findAll().forEach(users::add);

		return users.stream().map(this::toResponse).toList();
	}

	private UserResponse toResponse(User user) {
		UserResponse response = new UserResponse();
		response.setName(user.getName());
		return response;
	}
}

動作確認

  • ./gradlew clean && ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'
  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail5@someemailprovider.com"}' -H 'content-type: application/json'
{"code":"USER_ALREADY_EXISTS","message":"既に登録されています","timestamp":"2026-05-25T13:32:16.13214"}

最後に

SpringBoot でエラーハンドリングする方法を紹介しました
@RestControllerAdvice を使いましょう

2026年5月31日日曜日

SpringBoot で環境ごとに設定を変える方法

SpringBoot で環境ごとに設定を変える方法

概要

SpringBoot にはデフォルトで環境ごとに設定を出し分ける機能があるのでそれを使うのが一番簡単です
--spring.profiles.active オプションを使います

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

src/main/resources/application.properties

spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/mydatabase
spring.datasource.username=root
spring.datasource.password=

src/main/resources/application-dev.properties

spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/mydatabase_dev
spring.datasource.username=root
spring.datasource.password=

build や bootRun 時にオプションを付与する

上を使う場合には

  • ./gradlew build
  • ./gradlew bootRun

下を使う場合には

  • ./gradlew build
  • java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
  • ./gradlew bootRun --args='--spring.profiles.active=dev'

という感じで出し分けることができます

環境変数でもいける

  • SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun

build 時の -P オプションでもいける

ただこれの場合は工夫が必要です
build.gradle で -P オプションで指定された引数の値を受け取り出し分けます

  • vim build.gradle
def env = project.findProperty("env") ?: "dev"

tasks.withType(JavaExec) {
    systemProperty "spring.profiles.active", env
}
  • ./gradlew build -Penv=dev

おまけ: ログにansiカラーを入れる場合

  • ./gradlew bootRun --args='--spring.profiles.active=dev --spring.output.ansi.enabled=always'

最後に

基本は spring.profiles.active オプションを使うのが良いと思います

参考サイト

2026年5月29日金曜日

SpringBoot + VSCode 環境で補完してくれないときの対処方法

SpringBoot + VSCode 環境で補完してくれないときの対処方法

概要

Gradle プロジェクトの場合には追加で拡張をインストールする必要があります

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

すでにインストールしている拡張

  • Extension Pack for Java
  • Spring Boot Extension Pack

追加でインストールする拡張

  • Extension Pack for Java Auto Config
  • Gradle Language Support

もしくは settings.json を見直す

{
    "java.jdt.ls.java.home": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home",
    "java.configuration.runtimes": [
        {
            "name": "JavaSE-26",
            "path": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home",
            "default": true
        }
    ],
    "java.import.gradle.home": "/opt/homebrew/Cellar/gradle/9.5.1/",
    "java.import.gradle.java.home": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home"
}

動作確認

VSCodeを再起動するかコマンドパレットで「Clean Java Language Server Workspace」すれば補完ができるようになるはずです

最後に

おそらく Gradle Language Server 拡張は自動でプロジェクトの java/gradle を使ってくれるようです

逆に Language Server for Java は組み込まれている java/gradle を使うのでインストールした gradle のファイルなどが読み込まれていないので補完できていなかったのかなと思います

参考サイト

2026年5月28日木曜日

SpringBoot でユニットテストとコンテナを使ったインテグレーションテストを作成する

SpringBoot でユニットテストとコンテナを使ったインテグレーションテストを作成する

概要

前回 DTO + Service レイヤーを追加しました
アプリがこの辺りまで大きくなったら一度テストを追加することをオススメします

SpringBoot にはコンテナを使って MySQL を起動しそれをインテグレーションテスト用として使えます

今回は MySQL を使わないでモックするユニットテストと MySQL コンテナを起動し実際にデータベースを使ったインテグレーションテストを追加する方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

コントローラのユニットテストの追加

コントローラでは Service をモックします
Service が期待する値をモックにセットしあとは実際にリクエストを送信しその値が返ってくるか検証します

テスト内では検証用の static method を大量に使うのでそこはアスタリスクでインポートしています

  • vim src/test/java/com/example/demo/MainControllerTest.java
package com.example.demo;

import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import com.example.demo.dto.UserResponse;
import com.example.demo.service.UserService;

@WebMvcTest(MainController.class)
class MainControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@MockitoBean
	private UserService userService;

	@Test
	void ユーザー作成が成功する() throws Exception {
		String json = """
				{
				    "name": "taro",
				    "email": "taro@example.com"
				}
				""";

		mockMvc.perform(post("/demo/add").contentType("application/json").content(json)).andExpect(status().isOk())
				.andExpect(content().string("Saved"));
	}

	@Test
	void ユーザー一覧取得() throws Exception {
		UserResponse user = new UserResponse();
		user.setName("taro");
		List<UserResponse> mock = List.of(user);

		when(userService.getAllUsers()).thenReturn(mock);

		mockMvc.perform(get("/demo/all")).andExpect(status().isOk()).andExpect(jsonPath("$[0].name").value("taro"));
	}
}

サービスのユニットテストの追加

サービスでは Repository をモックします
これも同様に Repository が返却時に期待する値をモックにセットしサービス内のメソッドをコールすることでテストします

verify はメソッドがコールされたかの振る舞いをテストするためのアサーションです

  • vim src/test/java/com/example/demo/service/UserServiceTest.java
package com.example.demo.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.util.List;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

	@Mock
	private UserRepository userRepository;

	@InjectMocks
	private UserService userService;

	@Test
	void ユーザー作成できる() {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		userService.createUser(req);

		verify(userRepository).save(any(User.class));
	}

	@Test
	void findAllメソッドが呼ばれたか() {
		userService.getAllUsers();

		verify(userRepository).findAll();
	}

	@Test
	void ユーザーの一覧が取得できる() {
		User user = new User();
		user.setName("taro");
		user.setEmail("taro@example.com");

		when(userRepository.findAll()).thenReturn(List.of(user));

		var result = userService.getAllUsers();

		assertEquals(1, result.size());
		assertEquals("taro", result.get(0).getName());
	}

	@Test
	void メールが重複している場合はエラー() {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		// モックの振る舞いを定義
		when(userRepository.existsByEmail("taro@example.com")).thenReturn(true);

		// 例外が投げられることを検証
		IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(req));

		assertEquals("既に登録されています", ex.getMessage());

		// saveが呼ばれていないことも重要
		verify(userRepository, never()).save(any());
	}
}

リポジトリのインテグレーションテストの追加

実際に MySQL コンテナに接続しテストします
MySQL を起動するのは org.testcontainers.mysql.MySQLContainer を使います

接続先情報やデータベースの作成はテスト時に自動で行ってくれます
コンテナを起動したりマイグレーションするので少しテストに時間がかかります

  • vim src/test/java/com/example/demo/repository/UserRepositoryIntegrationTest.java
package com.example.demo.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;

import com.example.demo.entity.User;

@Import(TestcontainersConfiguration.class)
@SpringBootTest
class UserRepositoryIntegrationTest {

	@Autowired
	private UserRepository userRepository;

	@Test
	void DBに保存できる() {
		User user = new User();
		user.setName("taro");
		user.setEmail("taro@example.com");

		userRepository.save(user);

		Iterable<User> users = userRepository.findAll();
		assertThat(users).hasSize(1);
	}
}

MySQL コンテナを起動するテストの設定は以下で行います

  • vim src/test/java/com/example/demo/repository/TestcontainersConfiguration.java
package com.example.demo.repository;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

	@Bean
	@ServiceConnection
	MySQLContainer mysqlContainer() {
		return new MySQLContainer(DockerImageName.parse("mysql:latest"));
	}

}

動作確認

  • JASYPT_PASSWORD="xxx" ./gradlew test

JASYPT_PASSWORD は jasypt で application.properties を暗号化している場合に指定してください
テストが完了すれば OK です
2回目以降はキャッシュを参照してしまいテストが速攻で終了してしまうのでその場合は

  • JASYPT_PASSWORD="xxx" ./gradlew cleanTest test

最後に

SpringBoot でユニットテストとインテグレーションテストを作成する方法を紹介しました
ある程度アプリが大きくなる前に全体を通してテストするケースは作成しておきましょう

値の検証や境界値などテストパターンが不十分なのでその辺りはいろいろ追加してください

また異常系のテストも不足しているので必要に応じて追加してください