這一篇我們來針對 Service / Dao 層進行測試的撰寫吧,以 MVC 架構下算是 Model 的部分會比較常進行測試程式的撰寫,因為牽涉到主要業務邏輯的運作,所以會比起 Controller 和 View 來說比較常需要進行測試,以 MVC 架構之下我認為個別需要測試的頻率及重點表整理如下

Model(Service/Dao) Controller View
測試頻率
重點 業務邏輯

數據驗證
狀態管理 | 請求/錯誤處理驗證
rounting 邏輯
數據轉換(DTO 轉換) | 數據綁定
模板渲染 |

所以這篇來主要針對最高頻率進行測試部分作介紹,Controller 層會應用到的寫法又會不太一樣,之後有機會可以再補充。

單元測試準備

簡單建立一個之前介紹 Jpa 關聯時使用的類似欄位架構

db schema and data

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
description VARCHAR(255)
);

INSERT INTO product (name, price, description) VALUES
('Switch 2', 11999.99, '任天堂熱門的手持遊戲機'),
('PlayStation 5 pro', 24999.99, '索尼的次世代遊戲主機'),
('Xbox Series X', 10999.99, '微軟高效能的遊戲主機'),
('iPhone 16', 26999.99, '蘋果最新型的智慧型手機'),
('Dell XPS 13', 35999.99, '戴爾輕薄高效能的筆記型電腦');

ProductTest Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@Table(name = "product")
public class ProductTest {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;
private Double price;
private String description;

// Getters and Setters
}

ProductTestDao

1
2
3
4
5
6
@Repository
public interface ProductTestDao extends JpaRepository<ProductTest, Long> {

public Optional<ProductTest> findByName(String name);
}

ProductTestService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class ProductTestService {

@Autowired
private ProductTestDao productTestDao;

public ProductTest saveProduct(ProductTest productTest) {
return productTestDao.save(productTest);
}

public Optional<ProductTest> getProductById(Long id) {
return productTestDao.findById(id);
}

public void deleteProduct(Long id) {
productTestDao.deleteById(id);
}
}

進行 Service 測試撰寫

先加入測試讀取的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootTest
//@DataJpaTest
//@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 使用實際的資料庫
public class ProductTestServiceTest {

@Autowired
private ProductTestDao productTestDao;

@Autowired
private ProductTestService productTestService;

@Test
public void testReadExistingProduct() {
// id = 1 , name = "Switch 2"
Optional<ProductTest> product = productTestService.getProductById(1L);
assertTrue(product.isPresent());
assertEquals(11999.99, product.get().getPrice());
assertEquals("任天堂熱門的手持遊戲機", product.get().getDescription());
}
}

仔細看和先前的測試範例不同,我們需要在測試 Class 上面加入 @SpringBootTest 因為先前是直接針對同一包程式內 Class 進行測試,沒有引用到被 Spring Boot 管理的 Bean 進行,但現在要測試的 Service 由於已經註冊為 Bean 交由 SpringBoot 管理,所以需要這個註解告知 SpringBoot 我們才能使用 @Autowired 將 TestProductService 引用進來

下面再加入新增、修改、刪除的測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Test
@Transactional
public void testUpdateExistingProduct() {
// id = 2, name = "PlayStation 5 pro"
Optional<ProductTest> productOptional = productTestService.getProductById(2L);
assertTrue(productOptional.isPresent());

ProductTest productTest = productOptional.get();
productTest.setPrice(23999.99);
productTest.setDescription("索尼的次世代遊戲主機 - 降價促銷");

ProductTest updatedProductTest = productTestDao.save(productTest);

assertEquals(23999.99, updatedProductTest.getPrice());
assertEquals("索尼的次世代遊戲主機 - 降價促銷", updatedProductTest.getDescription());
}

@Test
@Transactional
public void testDeleteExistingProduct() {
// id = 5, name = "Dell XPS 13"
Optional<ProductTest> productOptional = productTestService.getProductById(5L);
assertTrue(productOptional.isPresent());

ProductTest productTest = productOptional.get();
Long productId = productTest.getId();

productTestDao.deleteById(productId);

Optional<ProductTest> deletedProduct = productTestService.getProductById(productId);
assertFalse(deletedProduct.isPresent());
}

@Test
@Transactional
public void testCreateNewProduct() {
// 新增一個新的產品
ProductTest productTest = new ProductTest();
productTest.setName("三星 Galaxy S22");
productTest.setPrice(20999.99);
productTest.setDescription("三星旗艦智慧型手機");

ProductTest savedProductTest = productTestService.saveProduct(productTest);

assertNotNull(savedProductTest);
assertNotNull(savedProductTest.getId());
assertEquals("三星 Galaxy S22", savedProductTest.getName());
}

這邊需要注意有使用到 @Transactional 註解來幫我們進行事務管理,這個註解會在我們進行完該項測試後將所有資料庫有進行的操作都 rollback 恢復至原本測試前,可以避免影響資料庫內的資料。

補充 @DataJpaTest

因為剛好在查一些測試相關資料找到這個測試註解的應用,之前撰寫時沒有用過,如果你的測試只是針對 Jpa 的操作(CRUD)就可以使用,可以輕量化測試 JPA 的功能,只加載相關的資源來進行測試,如果沒有接資料庫也會提供嵌入式資料庫(H2),如果有需要注入 Service, Controller 或是其他非 JPA Repository 層的測試就沒辦法執行。

這邊也提供一個使用這個註解的版本,因為我有加入自己的資料庫,所以需要多使用 @AutoConfigureTestDatabase 這個註解來告知使用自己的資料庫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 使用實際的資料庫
public class ProductTestServiceTest {

@Autowired
private ProductTestDao productTestDao;

@Test
@Transactional
public void testReadExistingProduct() {
// 假設資料庫已經有 "Switch 2" 這筆資料
Optional<ProductTest> retrievedProduct = productTestDao.findByName("Switch 2");
assertTrue(retrievedProduct.isPresent());
assertEquals(11999.99, retrievedProduct.get().getPrice());
assertEquals("任天堂熱門的手持遊戲機", retrievedProduct.get().getDescription());
}

@Test
@Transactional
public void testUpdateExistingProduct() {
// 假設資料庫已經有 "PlayStation 5 pro" 這筆資料
Optional<ProductTest> productOptional = productTestDao.findByName("PlayStation 5 pro");
assertTrue(productOptional.isPresent());

ProductTest productTest = productOptional.get();
productTest.setPrice(23999.99);
productTest.setDescription("索尼的次世代遊戲主機 - 降價促銷");

ProductTest updatedProductTest = productTestDao.save(productTest);

assertEquals(23999.99, updatedProductTest.getPrice());
assertEquals("索尼的次世代遊戲主機 - 降價促銷", updatedProductTest.getDescription());
}

@Test
@Transactional
public void testDeleteExistingProduct() {
// 假設資料庫已經有 "Dell XPS 13" 這筆資料
Optional<ProductTest> productOptional = productTestDao.findByName("Dell XPS 13");
assertTrue(productOptional.isPresent());

ProductTest productTest = productOptional.get();
Long productId = productTest.getId();

productTestDao.deleteById(productId);

Optional<ProductTest> deletedProduct = productTestDao.findById(productId);
assertFalse(deletedProduct.isPresent());
}

@Test
@Transactional
public void testCreateNewProduct() {
// 新增一個新的產品
ProductTest productTest = new ProductTest();
productTest.setName("三星 Galaxy S22");
productTest.setPrice(20999.99);
productTest.setDescription("三星旗艦智慧型手機");

ProductTest savedProductTest = productTestDao.save(productTest);

assertNotNull(savedProductTest);
assertNotNull(savedProductTest.getId());
assertEquals("三星 Galaxy S22", savedProductTest.getName());
}
}

這邊大致展示基本要進行 Service 層測試要注意的東西,不過這樣的測試寫法會需要依賴到資料庫操作,以及所有該 Service 需要依賴的其他物件。有時候我們可能還沒完成 dao 或是其他依賴的區域時會沒辦法進行測試,下一篇會介紹到 Mock 就可以解決這樣的情形。


參考資料:

  • Java 工程師必備!Spring Boot 零基礎入門 (hahow 課程)
  • JUnit5