撰寫Service的單元測試
延續上一篇的Spring Boot 單元測試。接下來我們要進行Service層的單元測試。由於Service負責處理核心業務邏輯,且通常會依賴Repository進行資料存取,因此測試時我們需要使用Mock技術來模擬Repository的行為,以確保測試的隔離性與穩定性。這樣可以專注於驗證Service的業務邏輯是否正確,而不受資料庫等外部因素影響。
回到 Service 層,Service 主要負責處理商業邏輯。在這個範例中,我們的 Service 主要處理三個任務:
- 檢查帳號、密碼格式
- 檢查帳號是否存在
- 新增帳號
讓我們看看實際的程式碼,請開啟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 進行資料庫整合測試:
- 使用容器建立獨立的測試環境
- 透過 schema.sql 維護資料表結構
- 使用 TestHelper 管理測試資料
- 撰寫完整的測試案例驗證功能
通過這種方式,我們可以確保資料庫操作的正確性,同時不會影響到正式環境的資料。
以上,介紹了在Spring boot ,以註冊會員為範例的API功能,撰寫Controller、Service、Repository的測試。
接著介紹如何使用TDD來開發。