Spring Boot 單元測試 (2)

Evan Chen

--

撰寫Service的單元測試

延續上一篇的Spring Boot 單元測試。接下來我們要進行Service層的單元測試。由於Service負責處理核心業務邏輯,且通常會依賴Repository進行資料存取,因此測試時我們需要使用Mock技術來模擬Repository的行為,以確保測試的隔離性與穩定性。這樣可以專注於驗證Service的業務邏輯是否正確,而不受資料庫等外部因素影響。

回到 Service 層,Service 主要負責處理商業邏輯。在這個範例中,我們的 Service 主要處理三個任務:

  1. 檢查帳號、密碼格式
  2. 檢查帳號是否存在
  3. 新增帳號

讓我們看看實際的程式碼,請開啟service/UserService

public SignupResult signup(String userId, String password) {
//1.格式檢查
List<String> signupValidations = new ArrayList<>();
if (userId == null || userId.length() < 3 || userId.length() > 20) {
signupValidations.add("User id must be between 3 and 20 characters long");
}
if (password == null || password.length() < 8 || password.length() > 30) {
signupValidations.add("Password must be between 8 and 30 characters long");
}
//2.檢查帳號是否存在
if (userRepository.existsByUserId(userId)) {
return new SignupResult(null, List.of("User already exists"));
}
if (!signupValidations.isEmpty()) {
return new SignupResult(null, signupValidations);
}
//3.新增帳號
CreatedUser savedUser = userRepository.insert(
new UserAccount(null, UUID.randomUUID(), userId, passwordEncoder.encode(password))
);
return new SignupResult(savedUser, signupValidations);
}

開始撰寫測試

請開啟UserServiceTest。在開始撰寫測試之前,需要注意的是:Service 的單元測試不應該使用 @Autowired 來注入 UserService。我們應該手動建立 UserService 實體,這樣能確保 Service 可以被獨立測試,不需依賴 Spring 框架。

private UserService userService;

@BeforeEach
void setUp() {
userService = new UserService(userRepository);
}

基本格式驗證測試:

正確格式userId與password可通過檢查

@Test
void signup_ShouldFailWithInvalidInput(){
String userId = "userId";
String password = "password123";
var result = userService.signup(userId, password);
assertThat(result.error()).isEmpty();
}

參數化測試:無效輸入處理

接下來,我們使用參數化測試來驗證各種無效輸入的情況。這種測試方法可以透過多組測試資料驗證,有效的驗證 signup 方法對各種無效輸入的處理能力。

@ParameterizedTest
@MethodSource("invalidSignupRequests")
void signup_ShouldFailWithInvalidInput(String userId, String password, int expectedErrorCount) {
var result = userService.signup(userId, password);
assertThat(result.isSuccess()).isFalse();
assertThat(result.error()).hasSize(expectedErrorCount);
}

private static Stream<Arguments> invalidSignupRequests() {
return Stream.of(
// 測試無效的userId - 預期1個錯誤
Arguments.of(null, PASSWORD, 1),
Arguments.of("", PASSWORD, 1),
Arguments.of("ab", PASSWORD, 1),
Arguments.of("a".repeat(21), PASSWORD, 1),
// 測試無效的password - 預期1個錯誤
Arguments.of(USER_ID, null, 1),
Arguments.of(USER_ID, "", 1),
Arguments.of(USER_ID, "short", 1),
Arguments.of(USER_ID, "a".repeat(31), 1),
// 測試userId和password同時無效 - 預期2個錯誤
Arguments.of("ab", "short", 2)
);
}

測試重複帳號註冊

這個測試的重點是驗證當帳號已存在時,signup 方法的行為:

@Mock
private UserRepository userRepository;

@Test
void signup_UserExists() {
//模擬帳號已存在
when(userRepository.existsByUserId(USER_ID)).thenReturn(true);

var result = userService.signup(USER_ID, PASSWORD);

assertFalse(result.isSuccess());
assertThat(result.error()).hasSize(1);
}

測試成功註冊流程

最後,成功註冊的情況:

private final CreatedUser createdUser = new CreatedUser(USER_UUID, USER_ID);

@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Test
void signup_Success() {
//模擬沒有已存在的帳號
when(userRepository.existsByUserId(USER_ID)).thenReturn(false);
//模擬Repository新增帳號成功
when(userRepository.insert(any(UserAccount.class))).thenReturn(createdUser);

var result = userService.signup(USER_ID, PASSWORD);
assertThat(result.isSuccess()).isTrue();
assertThat(result.createdUser()).isEqualTo(createdUser);
}

撰寫Repository的測試

Repository 層是應用程式中負責資料持久化的重要元件。在這個範例中,我們的 Repository使用JdbcTemplate與 MySQL 資料庫的直接互動,並提供兩個主要功能:

  • 檢查使用者是否存在
  • 新增帳號

請開啟repository/UserRepository,被測試程式碼如下:

@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;

public Boolean existsByUserId(String userId) {
String sql = "SELECT EXISTS(SELECT 1 FROM user_account WHERE user_id = ?)";
return Boolean.TRUE.equals(jdbcTemplate.queryForObject(sql, Boolean.class, userId));
}

public CreatedUser insert(UserAccount user) {
String sql = "INSERT INTO user_account (uuid, user_id, password_hash) VALUES (?, ?, ?)";
int rowsAffected = jdbcTemplate.update(sql,
user.uuid().toString(),
user.userId(),
user.passwordHash());
if (rowsAffected != 1) {
throw new RuntimeException("Failed to insert user account");
}
return new CreatedUser(user.uuid(), user.userId());
}
}

設定測試環境

在開始撰寫測試之前,我們需要一個獨立的測試環境,避免影響到正式資料庫。這裡使用 Testcontainers 來建立測試用的 MySQL 容器。

首先建立測試類別並加上@Testcontainers

@SpringBootTest
@Testcontainers
class UserRepositoryTest {
}

資料庫初始化

建立資料表結構

test/resources 目錄下建立 schema.sql

-- Create user_account table
CREATE TABLE IF NOT EXISTS `user_account` (
`id` bigint NOT NULL AUTO_INCREMENT,
`uuid` varchar(36) NOT NULL,
`user_id` varchar(255) DEFAULT NULL,
`password_hash` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uuid` (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

建立測試輔助類別

建立 TestHelper 類別來管理測試資料庫:

@Component
public class TestHelper {

@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
public void clearDatabase() {
jdbcTemplate.execute("DELETE FROM user_account");
}
public void createTestTable() {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.addScript(new ClassPathResource("schema.sql"));
populator.execute(dataSource);
}
}

撰寫測試案例

設定測試環境

回到UserRepositoryTest 在測試類別中加入初始化設定:

  • 在setup確認資料庫建立成功
  • 在setUpEach每次刪除資料
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@BeforeAll
static void setUp() {
TestDatabaseConfig.startContainer();
}

@BeforeEach
void setUpEach() {
testHelper.createTestTable();
testHelper.clearDatabase();
}
}

測試新增帳號功能

@Test
void testInsert() {
var userCreatedResponse = userRepository.insert(fakeUserAccount);

assertThat(userCreatedResponse)
.isNotNull()
.satisfies(inserted -> {
assertThat(inserted.uuid()).isEqualTo(fakeUserAccount.uuid());
assertThat(inserted.userId()).isEqualTo(fakeUserAccount.userId());
});
}

測試帳號不存在

@Test
void testFindByUserIdNotFound() {
boolean exist = userRepository.existsByUserId("nonexistent");
assertThat(exist).isFalse();
}

測試帳號已存在,在驗證前需先在資料庫新增一筆資料

@Test
void testFindByUserIdFound() {
//在資料庫新增資料
userRepository.insert(fakeUserAccount);
assertThat(userRepository.existsByUserId(USER_ID)).isTrue();
}

這個範例展示了如何使用 Testcontainers 進行資料庫整合測試:

  1. 使用容器建立獨立的測試環境
  2. 透過 schema.sql 維護資料表結構
  3. 使用 TestHelper 管理測試資料
  4. 撰寫完整的測試案例驗證功能

通過這種方式,我們可以確保資料庫操作的正確性,同時不會影響到正式環境的資料。

以上,介紹了在Spring boot ,以註冊會員為範例的API功能,撰寫Controller、Service、Repository的測試。

接著介紹如何使用TDD來開發。

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response