Service
延續上一篇的Spring Boot TDD 測試驅動開發完成 Controller 。接著實作 Service 層。Service 層負責處理核心業務邏輯,在這個範例中主要處理三個任務:
- 檢查帳號、密碼格式
- 檢查帳號是否存在
- 新增帳號
初始重構
在開始新功能開發前,先重構 Service 的介面設計。原本 Service 的signup參數直接使用 SignupRequest:
@Service
public class UserService implements IUserService {
@Override
//參數是SignupRequest與Controller的參數共用
public SignupResult signup(SignupRequest request) {
throw new UnsupportedOperationException("Not implemented yet");
}
}
為了避免 Service 與 Controller 共用 Request 物件,我們改為使用基本參數:
@Service
public class UserService implements IUserService {
@Override
//改成傳2個參數,也可建立新的model
public SignupResult signup(String userId, String password) {
throw new UnsupportedOperationException("Not implemented yet");
}
}
注意:在進行任何重構後,都要重新執行之前的 Controller 測試,確保功能正常。
開始撰寫測試案例
測試案例 1:輸入格式驗證
首先建立 UserServiceTest,驗證輸入格式錯誤時的處理:
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService();
}
@Test
void signup_ShouldFailWithInvalidInput() {
var result = userService.signup("ab", "short");
assertThat(result.isSuccess()).isFalse();
assertThat(result.error()).hasSize(2);
}
}
執行測試,得到紅燈。
Not implemented yet
java.lang.UnsupportedOperationException: Not implemented yet
實作 Service 的驗證邏輯後通過測試。
@Service
public class UserService implements IUserService {
@Override
public SignupResult signup(String userId, String password) {
//格式檢查
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");
}
if (!signupValidations.isEmpty()){
return new SignupResult(null, signupValidations);
}else{
throw new UnsupportedOperationException("Not implemented yet");
}
}
}
接著我們要加更多案例來驗證錯誤,所以我們改成用參數化的方式。
@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)
);
}
執行測試後,通過測試。
測試案例 2:帳號重複檢查
測試帳號已存在的情況:
@Test
void signup_UsernameExists() {
when(userRepository.existsByUserId(USER_ID)).thenReturn(true);
var result = userService.signup(USER_ID, PASSWORD);
assertFalse(result.isSuccess());
assertThat(result.error()).hasSize(1);
}
注入userRepository
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
private UserService userService;
@Mock
private UserRepository userRepository;
@BeforeEach
void setUp() {
userService = new UserService(userRepository);
}
}
建立 UserRepository,在這裡不需實作,直接throw UnsupportedOperationException;
public class UserRepository {
public Boolean existsByUserId(String userId) {
throw new UnsupportedOperationException("Not implemented yet");
}
}
執行測試會是紅燈
Not implemented yet
java.lang.UnsupportedOperationException: Not implemented yet
實作 Service 功能
在UserService 加上檢查帳號是否存在的邏輯,通過測試。
@Service
public class UserService implements IUserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public SignupResult signup(String userId, String password) {
//格式檢查
...
//檢查帳號是否存在
boolean exists = userRepository.existsByUserId(userId);
if (exists) {
return new SignupResult(null, List.of("User already exists"));
}
..
if (!signupValidations.isEmpty()){
return new SignupResult(null, signupValidations);
}else{
throw new UnsupportedOperationException("Not implemented yet");
}
}
}
測試案例 3:註冊成功
這裡要模擬Repository新增帳號成功。
@Mock
private PasswordEncoder passwordEncoder;
@Test
void signup_Success() {
when(userRepository.existsByUserId(USER_ID)).thenReturn(false);
when(userRepository.insert(any(UserAccount.class))).thenReturn(createdUser);
var result = userService.signup(USER_ID, PASSWORD);
assertThat(result.isSuccess()).isTrue();
assertThat(result.createdUser()).isEqualTo(createdUser);
}
UserRepository 加入insert方法。
public class UserRepository {
public CreatedUser insert(UserAccount user) {
throw new UnsupportedOperationException("Not implemented yet");
}
}
同時產生表示資料庫UserAccount的DTO。
public record UserAccount(
Long id,
UUID uuid,
@Field("user_id")
String userId,
String passwordHash
) {}
接著執行測試:
Not implemented yet
java.lang.UnsupportedOperationException: Not implemented yet
完成 Service 實作:
新增PasswordEncoderConfig
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
在 signup 加上呼叫userRepository.insert及處理
public SignupResult signup(String userId, String password) {
if (!signupValidations.isEmpty()){
return new SignupResult(null, signupValidations);
}else{
//新增帳號
CreatedUser savedUser = userRepository.insert(
new UserAccount(null, UUID.randomUUID(), userId, passwordEncoder.encode(password))
);
return new SignupResult(savedUser, null);
}
}
至此,Service 層的 TDD 開發完成,實現了所有必要的業務邏輯。每個功能都有對應的測試案例保護,確保程式碼品質。
Repository
最後,我們要來處理Repository。Repository 層是應用程式中負責資料持久化的重要元件。在這個範例中,我們使用JdbcTemplate與 MySQL 資料庫互動,並提供兩個主要功能:
- 檢查使用者是否存在
- 新增帳號
測試環境準備
首先建立測試環境,使用 Testcontainers 來管理測試用的 MySQL 容器:
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestHelper testHelper;
@BeforeAll
static void setUp() {
TestDatabaseConfig.startContainer();
}
@BeforeEach
void setUpEach() {
testHelper.createTestTable();
testHelper.clearDatabase();
}
@AfterAll
static void tearDown(@Autowired TestHelper testHelper) {
testHelper.clearDatabase();
}
}
測試環境設定檔
建立測試資料庫配置:
public class TestDatabaseConfig {
public static final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
public static void startContainer() {
mysqlContainer.start();
}
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
applicationContext,
"spring.datasource.url=" + mysqlContainer.getJdbcUrl(),
"spring.datasource.username=" + mysqlContainer.getUsername(),
"spring.datasource.password=" + mysqlContainer.getPassword()
);
}
}
}
測試輔助類別
建立測試用的資料庫操作輔助類別:
@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);
}
}
資料表結構
在 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;
測試案例開發
測試案例 1:新增帳號
驗證新增帳號的功能:
@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@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());
});
}
}
實作 Repository 的新增功能:
@Component
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
@Autowired
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
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());
}
}
測試案例 2:檢查帳號不存在
驗證查詢不存在帳號的情況:
@Test
void testFindByUserIdNotFound() {
boolean exist = userRepository.existsByUserId("nonexistent");
assertThat(exist).isFalse();
}
執行測試
Not implemented yet
java.lang.UnsupportedOperationException: Not implemented yet
實作檢查帳號存在的功能:
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));
}
測試案例 3:檢查帳號存在
驗證查詢存在帳號的情況:
@Test
void testFindByUserIdFound() {
userRepository.insert(fakeUserAccount);
assertThat(userRepository.existsByUserId(USER_ID)).isTrue();
}
由於之前的實作已經完整處理了帳號查詢邏輯,這個測試案例可以直接通過。這驗證了我們的實作可以正確處理不同的使用情境。
小結
TDD 的步驟紅燈、綠燈、重構循環:
- 紅燈:撰寫失敗的測試案例。
- 綠燈:快速實作功能讓測試案例通過。
- 重構:在不改變功能的前提下,修改程式碼來改善可維護性。
而這個循環,不是先完成一層的所有功能,而是按照 API 需求進行垂直切分。例如註冊帳號API從Controller、Service、Repository都完成,才會進行下一登入API。而不是先完成Controller的每一個功能。
而每個 TDD 循環包含:
- 一個 Controller API 端點
- 對應的 Service 業務邏輯
- 必要的 Repository 資料存取
這種垂直切分的方式有助於我們:
- 快速驗證完整功能
- 及早發現各層整合問題
- 避免開發方向偏離需求
最後,TDD的好處:
- 開發前必須先清楚理解需求。
- 增量式開發,每次只處理一個小功能,可以避免過度設計和不必要的複雜性。
- 減少後期除錯和維護的時間成本。
- 提升開發效率,雖然需要撰寫測試,但實際開發速度並不會變慢。