โหมดมืด
บทที่ 4 — Testing (Unit + Integration — เทสต์โค้ดทีละชิ้น และทดสอบหลายชิ้นทำงานร่วมกัน)
โค้ดที่ไม่มี test = ระเบิดเวลา — แก้ feature หนึ่ง พังอีก 3 ที่ ไม่รู้ตัว
บทนี้:
- JUnit 5 basics — เขียน test แรก
- Mockito — mock dependency
- AssertJ — assertion ที่อ่านได้
- Test layer แต่ละชั้น: Repository, Service, Controller
- Integration test — test ทั้ง app
- Testcontainers — test กับ DB จริง
📋 ก่อนเริ่ม — ต้องผ่านอะไรมาก่อน?
- ✅ Spring Boot บทที่ 1–2 (REST + JPA) — บทนี้เขียน test ให้โค้ดแบบนั้น
- ✅ Java พื้นฐาน (Java บทที่ 1–8) · ไม่ต้องผ่าน Java บทที่ 15 ก่อน — บทนี้อธิบาย testing concept ครบจาก 0 (ถ้าเคยอ่านมาแล้วก็จะยิ่งง่าย)
- 🔑 ศัพท์ที่จะเจอบ่อย:
- unit test (ยูนิต เทสต์) = ทดสอบโค้ดทีละชิ้นเล็ก ๆ (1 method/1 class) แยกจากของอื่น
- integration test (อินทิเกรชัน) = ทดสอบหลายชิ้นทำงานร่วมกันจริง (เช่น service + DB จริง)
- mock (ม็อก) = ของปลอมที่เราสร้างขึ้นแทน dependency จริง เพื่อคุมพฤติกรรมตอน test — เช่น แทนที่จะใช้
UserRepositoryจริงที่ต่อ database เราสร้าง mockUserRepositoryแล้วกำหนดเองได้ว่าfindById(1L)จะคืนค่าอะไร- assertion (แอส-เซอร์-ชัน) = บรรทัดที่ "ยืนยัน" ว่าผลลัพธ์ตรงกับที่คาด ถ้าไม่ตรง test fail
1. ทำไมต้อง test
java
// แก้ feature
public double calculatePrice(Item item) {
return item.getPrice() * 1.07; // เพิ่ม VAT 7%
}ดูเหมือนแก้ง่าย — แต่:
- แล้ว item ที่ free shipping ล่ะ?
- discount ตอน promo ทำงานไหม?
- bulk order discount?
ถ้ามี test:
java
@Test void shouldAddVat() { ... }
@Test void shouldNotAddVatForFreeItem() { ... }
@Test void shouldApplyPromoDiscount() { ... }run test → เห็นทันทีว่าอะไรพัง
Pyramid ของ test
text
/\
/ \
/ E2E\ ← ช้า, แพง, ทำน้อย (E2E = End-to-End — เทสต์ทั้ง flow ผ่าน UI (หน้าจอที่ผู้ใช้โต้ตอบ)+API+DB+network จริง)
/------\
/ \
/Integration\ ← ปานกลาง
/------------\
/ \
/ Unit \ ← เร็ว, ทำเยอะ
/------------------\- Unit test — test method / class แยก (mock dependency)
- Integration test — test หลาย component รวมกัน + DB จริง
- E2E — test ทั้ง flow ผ่าน UI/API (บทนี้ไม่ครอบคลุม E2E — แค่แสดงว่ามีอยู่ใน pyramid)
2. JUnit 5 — framework test ของ Java
Spring Boot Starter Test ใส่ JUnit 5 + Mockito + AssertJ มาให้ฟรี
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>2.1 Test แรก
สมมติว่าเรามี class Calculator (ตัวอย่างเล็กๆ สำหรับเรียน test):
📌 สร้างไฟล์ใน project เดิมจากบทที่ 1:
src/main/java/com/example/firstapp/Calculator.java(ถ้าชื่อ package ต่างกัน ให้ใช้ package ของตัวเอง)
java
// src/main/java/com/example/firstapp/Calculator.java
package com.example.firstapp;
class Calculator {
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
}src/test/java/com/example/firstapp/CalculatorTest.java:
java
package com.example.firstapp;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertThat(result).isEqualTo(5);
}
@Test
void shouldSubtractNumbers() {
Calculator calc = new Calculator();
assertThat(calc.subtract(10, 4)).isEqualTo(6);
}
}run: คลิกขวาที่ไฟล์ CalculatorTest.java → Run — จะรัน test เฉพาะไฟล์นี้ หรือ mvn test จะรัน test ทั้งโปรเจกต์
💡 บรรทัด
assertThat(...)มาจาก AssertJ — เดี๋ยวอธิบายเต็มๆ ใน section 3 ตอนนี้อ่านว่า "ยืนยันว่า result เท่ากับ 5" พอ
2.2 Annotation พื้นฐาน
| Annotation | ความหมาย |
|---|---|
@Test | method นี้เป็น test |
@BeforeEach | run ก่อนทุก test |
@AfterEach | run หลังทุก test |
@BeforeAll | run ครั้งเดียวก่อน test ทั้งหมด (ต้อง static) |
@AfterAll | run ครั้งเดียวหลัง test ทั้งหมด |
@Disabled | ข้าม test นี้ |
@ParameterizedTest | run หลายค่า input |
@DisplayName("...") | ตั้งชื่อ test ใน report |
2.3 Setup + teardown
java
class CalculatorTest {
private Calculator calc;
@BeforeEach
void setup() {
calc = new Calculator();
}
@Test
void shouldAdd() {
assertThat(calc.add(1, 2)).isEqualTo(3);
}
}2.4 Parameterized test
java
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"10, -5, 5"
})
void shouldAddCorrectly(int a, int b, int expected) {
assertThat(calc.add(a, b)).isEqualTo(expected);
}3. AssertJ — fluent assertion
AssertJ เป็น library ที่ทำให้ assertion อ่านเป็นประโยคได้ (assertThat(x).isEqualTo(5)) — อ่านง่ายกว่า assertEquals ของ JUnit, error message ชัดกว่า และ chain เงื่อนไขซับซ้อนได้ มากับ Spring Boot Test อยู่แล้ว เป็น default ที่นิยมใช้:
แทน JUnit's assertEquals(expected, actual):
java
// JUnit
assertEquals(5, result);
assertTrue(list.contains("hello"));
// AssertJ — อ่านดีกว่า
assertThat(result).isEqualTo(5);
assertThat(list).contains("hello");Pattern ที่ใช้บ่อย
java
// numbers
assertThat(x).isEqualTo(5)
.isPositive()
.isLessThan(10);
// strings
assertThat(s).isEqualTo("hello")
.startsWith("h")
.containsIgnoringCase("ELLO")
.isNotBlank();
// boolean
assertThat(isActive).isTrue();
assertThat(isInvalid).isFalse();
// null
assertThat(obj).isNull();
assertThat(obj).isNotNull();
// collections
assertThat(list).hasSize(3)
.contains("apple", "banana")
.doesNotContain("cherry")
.containsExactly("apple", "banana", "cherry"); // ตามลำดับ
// map
assertThat(map).containsKey("name")
.containsValue("Anna")
.containsEntry("age", 25);
// object
assertThat(user).extracting(User::getName).isEqualTo("Anna");
// User::getName คือ method reference — เทียบเท่า lambda u -> u.getName() (เรียนใน Java พื้นฐาน)
assertThat(user).hasFieldOrPropertyWithValue("name", "Anna");
// exception
assertThatThrownBy(() -> service.divide(10, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessageContaining("zero");💡 เมื่อ assertion fail — AssertJ แสดง error ชัดเจน เช่น
assertThat(result).isEqualTo(5)แต่ result = 6 จะเห็น:expected: 5 but was: 6ชัดกว่า JUnit
assertEqualsที่แสดงแค่expected:<5> but was:<6>และสำหรับ collection/string AssertJ แสดง diff ที่อ่านได้ง่ายยิ่งกว่า
4. Mockito — mock dependency
📖 Mock คืออะไร — analogy ก่อน: เหมือน stunt double (ตัวแสดงแทน) ในหนัง — ฉากกระโดดตึก, ฉากระเบิด ไม่ใช้ดารา ใช้ stunt double แทน เพราะ:
- ดารา (ของจริง) แพง/เสี่ยง/ใช้เวลานาน → DB จริงในที่นี้
- stunt double ทำท่าเหมือนเป๊ะ ตามที่ผู้กำกับสั่ง → mock ทำตามที่เราตั้ง
Mock = object ที่หน้าตาเหมือนของจริง แต่เราคุมว่ามันจะตอบอะไร — ทำให้ test ของเราไม่ต้องพึ่ง DB/network/อะไรช้า ๆ
ปัญหา: test UserService ที่ใช้ UserRepository — ไม่อยาก hit DB
Mock = "ของปลอม" ที่เราคุมพฤติกรรมได้
เข้าใจ concept ของ mock แล้ว — ต่อไปมาดูว่าเขียนเป็นโค้ดจริงยังไง เริ่มจาก annotation 3 ตัวที่ต้องรู้จักก่อน:
📖 Annotation ทั้ง 3 ตัวที่จะเห็นในโค้ดข้างล่าง (อ่านก่อน):
@ExtendWith(MockitoExtension.class)= บอก JUnit (เฟรมเวิร์คทดสอบของ Java) ให้ "เปิดใช้งาน Mockito (เครื่องมือสร้าง mock)" ใน test class นี้ → JUnit จะรู้จัก@Mock/@InjectMocks(ไม่ใส่ = annotation พวกนี้จะถูกเพิกเฉย)@Mock= สร้าง mock ของ type นี้ให้ (ของปลอม)@InjectMocks= สร้าง object ที่จะทดสอบ + ฉีด (inject — ส่งของเข้า constructor หรือ field โดยอัตโนมัติ)@Mockทุกตัวที่ระบุไว้เข้า constructor/field ของมันลำดับ:
@Mock UserRepository repo→ Mockito สร้าง repo ปลอม@InjectMocks UserService service→ Mockito สร้าง UserService แล้วฉีดrepo(mock) ใส่ใน constructor- เราตั้งให้ repo ปลอมตอบยังไง → ทดสอบว่า service ใช้ผลถูกหรือเปล่า
java
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.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repo;
@InjectMocks
private UserService service;
@Test
void shouldReturnUserWhenExists() {
// Arrange
User user = new User("Anna", "anna@example.com", 25);
when(repo.findById(1L)).thenReturn(Optional.of(user));
// Act
User result = service.getById(1L);
// Assert
assertThat(result.getName()).isEqualTo("Anna");
verify(repo).findById(1L);
}
@Test
void shouldThrowWhenNotFound() {
when(repo.findById(anyLong())).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getById(999L))
.isInstanceOf(UserNotFoundException.class);
}
}Mockito สิ่งที่ใช้บ่อย
java
// stub — บอกว่า mock return อะไรเมื่อเรียก
when(repo.findById(1L)).thenReturn(Optional.of(user));
when(repo.count()).thenReturn(42L);
when(repo.save(any(User.class))).thenReturn(user);
// throw exception
when(repo.findById(1L)).thenThrow(new RuntimeException("DB down"));
// verify — เช็คว่ามีเรียกตามที่คาด
verify(repo).save(any(User.class));
verify(repo, times(2)).findAll();
verify(repo, never()).delete(any());
verify(repo, atLeast(1)).findById(anyLong());
// argument captor — capture argument ที่ส่งเข้า mock
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repo).save(captor.capture());
assertThat(captor.getValue().getEmail()).isEqualTo("anna@example.com");
// argument matcher
verify(repo).save(argThat(u -> u.getAge() > 18));
// times(n) = ต้องถูกเรียก n ครั้งพอดี · argThat(...) = ใช้ predicate (lambda) ตรวจ argument ที่ส่งเข้ามาAAA pattern (สามเอ — Arrange / Act / Assert คือ "เตรียม / ทำ / ตรวจ")
ทุก test ตามโครงสร้าง 3 ส่วน:
java
@Test
void example() {
// Arrange — เตรียมข้อมูล + stub mock
User u = new User(...);
when(repo.findById(1L)).thenReturn(Optional.of(u));
// Act — ทำสิ่งที่ test
User result = service.getById(1L);
// Assert — เช็คผล
assertThat(result).isEqualTo(u);
}5. Test Repository — @DataJpaTest
📌 ตัวอย่างข้างล่างใช้
Userแบบเดียวกับ บทที่ 2 (Database/JPA) — constructor(String name, String email, int age)(ไม่ใช่Userของบทที่ 3 ที่เป็น(username, password, role))⚠️ ถ้า project ของคุณต่อจากบทที่ 3:
Userของคุณอาจมี field ต่างกัน — แก้ constructor ในตัวอย่างนี้ให้ตรงกับUserของคุณก่อน เช่นnew User("Anna", "anna@example.com", "HASHED_PASS", Role.USER)(ตาม field ที่มีจริง)⚠️
findByEmailก็อาจไม่มี: ตัวอย่างนี้เรียกrepo.findByEmail(...)ซึ่งมาจากบทที่ 2 (Database/JPA) — ถ้าUserRepositoryของคุณมาจากบทที่ 3 (auth) เท่านั้น จะมีแค่findByUsernameไม่มีfindByEmail→ เจอ compile error "cannot find symbol" ทางแก้: เพิ่ม methodfindByEmailเองในUserRepositoryหรือเปลี่ยนตัวอย่างนี้ให้ใช้findByUsernameแทนตามที่ project มีจริง
java
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository repo;
@Test
void shouldFindByEmail() {
// Arrange
repo.save(new User("Anna", "anna@example.com", 25)); // User จากบทที่ 2
// Act
Optional<User> found = repo.findByEmail("anna@example.com");
// Assert
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Anna");
}
@Test
void shouldReturnEmptyWhenNotFound() {
Optional<User> found = repo.findByEmail("nope@example.com");
assertThat(found).isEmpty();
}
}@DataJpaTest:
- เปิด context เฉพาะ JPA layer (เร็ว — ไม่โหลด controller)
- ใช้ H2 in-memory อัตโนมัติ
- rollback หลังแต่ละ test (DB สะอาด)
6. Test Controller — @WebMvcTest
⚠️ ถ้าทำบทที่ 3 (Security + JWT) มาแล้ว: endpoint ทุกตัวถูก lock ด้วย Spring Security — test ใน section นี้จะได้
401 Unauthorizedแทน 200/201 ทางแก้มี 3 วิธี:
- ใส่
@WithMockUserบน test method (แนะนำ — อธิบายเต็มๆ ใน section 9)- ใส่
@AutoConfigureMockMvc(addFilters = false)บน class เพื่อ disable security filter ทั้งหมด- ทำ checkpoint บทนี้บนโปรเจกต์ที่จบบทที่ 2 (ยังไม่มี security)
@WebMvcTest โหลดเฉพาะ web layer (controller + JSON + validation) โดย mock service ข้างใต้ — เร็วกว่าโหลดทั้ง context และโฟกัสทดสอบเรื่องของ controller จริง ๆ (routing, status code, request/response mapping) ใช้ MockMvc ยิง request จำลอง:
test แค่ controller (mock service):
java
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean // Spring Boot 3.4+ (ดูหมายเหตุด้านล่าง)
private UserService service;
@Test
void shouldReturnUser() throws Exception {
when(service.getById(1L)).thenReturn(new User("Anna", "anna@example.com", 25));
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Anna"))
.andExpect(jsonPath("$.email").value("anna@example.com"));
}
@Test
void shouldReturn404WhenNotFound() throws Exception {
when(service.getById(999L)).thenThrow(new UserNotFoundException(999L));
mockMvc.perform(get("/users/999"))
.andExpect(status().isNotFound());
}
@Test
void shouldCreateUser() throws Exception {
User created = new User("Ben", "ben@example.com", 30);
when(service.createUser(anyString(), anyInt(), anyString())).thenReturn(created);
// createUser(name, age, email) — ลำดับ argument ต้องตรงกับ method จริงใน UserService
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Ben","email":"ben@example.com","age":30}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Ben"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"","email":"not-email","age":-1}
"""))
.andExpect(status().isBadRequest());
}
}📖
@MockitoBean— version ที่รองรับ:
- Spring Boot 3.4+: ใช้
@MockitoBean(import:org.springframework.test.context.bean.override.mockito.MockitoBean)- Spring Boot 3.3 หรือเก่ากว่า: ต้องใช้
@MockBean(import:org.springframework.boot.test.mock.mockito.MockBean) แทน —@MockitoBeanยังไม่มี class นี้เลย จะ compile error ถ้าใส่บน 3.3💡 เช็ค version Spring Boot ของคุณ: เปิดไฟล์
pom.xml→ ดูที่<parent><version>X.Y.Z</version></parent>— ถ้าตัวเลขตัวที่สอง (Y) น้อยกว่า 4 (เช่น 3.3.x, 3.2.x) ให้ใช้@MockBeanแทน
MockMvc methods
java
// requests
mockMvc.perform(get("/path"))
mockMvc.perform(post("/path").contentType(MediaType.APPLICATION_JSON).content("{...}"))
mockMvc.perform(put("/path/1").content("..."))
mockMvc.perform(delete("/path/1"))
// query params
mockMvc.perform(get("/users").param("page", "0").param("size", "10"))
// headers
mockMvc.perform(get("/users").header("Authorization", "Bearer token"))
// expects
.andExpect(status().isOk())
.andExpect(status().isCreated())
.andExpect(status().isBadRequest())
.andExpect(status().isUnauthorized())
.andExpect(content().string("hello"))
.andExpect(jsonPath("$.name").value("Anna"))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items[0].id").exists())
.andExpect(jsonPath("$.items.length()").value(3))
.andExpect(header().string("Location", "/users/1"))
// debug
.andDo(print())7. Integration test — @SpringBootTest
โหลด context ทั้งหมด — test เหมือนของจริง:
⚠️ ใช้
@SpringBootTestเป็น "ทางสุดท้าย" ไม่ใช่ default: มันโหลด ApplicationContext ทั้งก้อนต่อ test class → ช้า (วินาที-นาที) ค่า default ควรเป็น@WebMvcTest(controller layer) +@DataJpaTest(repository layer) + Mockito (service layer) — ใช้@SpringBootTestเฉพาะตอน test integration end-to-end จริงๆ
ต้องการ test... ใช้ service logic @ExtendWith(MockitoExtension.class)(ไม่มี Spring)repository / SQL @DataJpaTestcontroller / HTTP / JSON @WebMvcTestfull end-to-end flow @SpringBootTest
⚠️ ถ้ามี Spring Security จากบทที่ 3:
@SpringBootTestโหลดSecurityConfigเต็มรูปแบบ —POST /usersจะถูก block ด้วย 401 ต้องใส่@WithMockUserบน test method หรือตั้งpermitAll()ใน test profile ก่อน (ดูตัวอย่างใน section 9)
java
@SpringBootTest
@AutoConfigureMockMvc
class UserApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository repo;
@BeforeEach
void clean() {
repo.deleteAll(); // ⚠️ เริ่มต้นง่ายดี แต่เป็น O(n) DELETE-per-row และพังกับ FK constraint
// ทางเลือกที่ดีกว่า:
// @Sql(scripts = "/cleanup.sql") — annotation ที่ให้ Spring รัน SQL script ก่อน/หลัง test
// วางไฟล์ cleanup.sql ไว้ที่ src/test/resources/cleanup.sql
// ข้อดี: เร็วกว่าและไม่ติด FK (เพราะ script ควบคุม order ได้เอง)
// ⚠️ @Transactional บน @SpringBootTest class ที่ใช้ MockMvc มักไม่ work ตามที่คาด
// เพราะ MockMvc request รันบน thread ต่างหาก → ไม่เห็น transaction ที่ยังไม่ commit
}
@Test
void fullCrudFlow() throws Exception {
// CREATE
String response = mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Anna","email":"anna@test.com","age":25}
"""))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
// JsonPath (เจสัน-พาธ) = library สำหรับอ่านค่าจาก JSON โดยระบุตำแหน่งด้วย path เช่น $.id (คล้าย XPath แต่สำหรับ JSON)
// import com.jayway.jsonpath.JsonPath; (มากับ spring-boot-starter-test อยู่แล้ว)
// JsonPath.read() คืน Integer (ไม่ใช่ Long) สำหรับเลขเล็ก → ต้อง cast ผ่าน Number
Long id = ((Number) JsonPath.read(response, "$.id")).longValue();
// READ
mockMvc.perform(get("/users/" + id))
.andExpect(jsonPath("$.name").value("Anna"));
// UPDATE
mockMvc.perform(put("/users/" + id)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Anna Updated"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Anna Updated"));
// DELETE
mockMvc.perform(delete("/users/" + id))
.andExpect(status().isNoContent());
mockMvc.perform(get("/users/" + id))
.andExpect(status().isNotFound());
}
}ช้ากว่า unit test มาก (10x-100x) — แต่ test สิ่งที่ unit test ไม่จับ เช่น: serialization JSON ผิด, validation ไม่ทำงาน, Spring context configure ผิด, security filter ลำดับผิด
8. Testcontainers — DB จริงใน Docker
H2 ≠ PostgreSQL — บางที test ผ่านบน H2 แต่ fail บน Postgres (ต่าง SQL syntax)
Testcontainers = run Docker container ระหว่าง test → ใช้ Postgres จริง
xml
<!-- ⭐ ถ้าใช้ spring-boot-starter-parent (ค่า default ของ Spring Initializr) ไม่ต้องระบุ <version>
Spring Boot BOM จัดการ version ของ Testcontainers ให้ทั้งหมด -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- ⭐ จำเป็นสำหรับ @ServiceConnection (Spring Boot 3.1+) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>🔴 ขาดไม่ได้:
@ServiceConnectionต้องมีspring-boot-testcontainers— ถ้าไม่ใส่ จะ compile errorcannot resolve @ServiceConnection
java
@SpringBootTest
@Testcontainers
@AutoConfigureMockMvc // ⭐ จำเป็น — ไม่มีนี้ Spring จะไม่สร้าง bean MockMvc
class UserApiTestcontainersTest {
@Container
@ServiceConnection // Spring Boot 3.1+ auto config
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
// 💡 ควรใช้ image version เดียวกับ production เช่น ถ้า prod ใช้ postgres:16 ให้ใช้ "postgres:16-alpine" ใน test ด้วย
@Autowired
private MockMvc mockMvc;
@Test
void shouldWorkWithRealPostgres() throws Exception {
// test ปกติ — แต่หลังฉาก Spring คุย Postgres ใน Docker
mockMvc.perform(get("/users"))
.andExpect(status().isOk());
}
}ต้องมี Docker ติดตั้งและ run อยู่ — ตรวจสอบด้วยคำสั่ง docker ps ถ้าขึ้น error ให้เปิด Docker Desktop ก่อน — Testcontainers start postgres ให้ + cleanup ตอนจบ
💡 ยังไม่มี Docker? เรื่อง Docker อยู่ในบทที่ 5 — ถ้าอยากเรียน test ก่อน ใช้
@DataJpaTest(section 5) ที่ใช้ H2 in-memory ได้เลย ไม่ต้องติด Docker ค่อยกลับมาบทนี้ตอนติด Docker เสร็จ
8.1 Spring Boot 3.1+ Service Connections — เลิกเขียน DynamicPropertySource
ตั้งแต่ Spring Boot 3.1 มี @ServiceConnection ที่ wire URL/credential ให้อัตโนมัติ:
java
@SpringBootTest
@Testcontainers
class UserApiTest {
@Container
@ServiceConnection // ⭐
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
// ⚠️ RedisContainer มาจาก third-party library ที่ไม่ใช่ Testcontainers official
// ทางเลือกที่ stable กว่า (ไม่ต้องพึ่ง third-party):
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@DynamicPropertySource
static void redisProps(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", redis::getHost);
r.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
// ถ้าต้องการใช้ RedisContainer + @ServiceConnection ต้องเพิ่ม dep:
// <groupId>com.redis</groupId><artifactId>testcontainers-redis</artifactId>
// <groupId>com.redis</groupId><artifactId>spring-boot-testcontainers-redis</artifactId>
// (third-party — อาจ outdated; ตรวจ version บน Maven Central ก่อนใช้)
}Spring auto-set spring.datasource.url, spring.data.redis.host ให้ — ไม่ต้อง @DynamicPropertySource แล้ว รองรับ: Postgres/MySQL/MongoDB/Redis/RabbitMQ/Kafka/Cassandra/Elasticsearch...
8.2 Multi-Container Network — test microservices end-to-end
🚧 ขั้นสูง — สำหรับทีมที่ทำ microservices (มือใหม่ข้ามได้)
🔤 ศัพท์ที่จะเจอ:
withNetwork(ผูก container ใน network เดียวกัน เพื่อให้คุยกันได้)withNetworkAliases(ตั้งชื่อ DNS ให้ container เช่นuser-service)withExposedPorts(เปิด port ของ container ออกมาให้ host เรียกได้)waitingFor(รอจนกว่า container พร้อม เช่น รอจน/actuator/healthตอบ 200)ทำไม "ใกล้ production": ทดสอบกับ container จริงๆ ของ downstream service ไม่ได้ mock พฤติกรรม — เลย catch bug ระดับ network/serialization/contract ได้ (ที่ mock จับไม่ได้)
ทดสอบ flow ที่ app เรียก downstream service จริงๆ:
java
@Testcontainers
class OrderEndToEndTest {
static Network network = Network.newNetwork();
@Container
static GenericContainer<?> userService =
new GenericContainer<>(DockerImageName.parse("user-service:test"))
.withNetwork(network)
.withNetworkAliases("user-service")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health"));
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(network);
@DynamicPropertySource
static void wireUserService(DynamicPropertyRegistry r) {
r.add("user-service.url",
() -> "http://" + userService.getHost() + ":" + userService.getMappedPort(8080));
}
}withNetwork + withNetworkAliases → container คุยกันด้วย DNS name (http://user-service:8080) → ทดสอบใกล้ production มาก โดยไม่ต้อง mock
8.3 Shared container — เร่ง test suite
ทุก test class start container ใหม่ → ช้า ใช้ singleton container:
java
abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start(); // start ครั้งเดียว reuse ทั้ง suite
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", POSTGRES::getJdbcUrl);
r.add("spring.datasource.username", POSTGRES::getUsername);
r.add("spring.datasource.password", POSTGRES::getPassword);
}
}เพิ่มเติม: เปิด testcontainers.reuse.enable=true ใน ~/.testcontainers.properties → reuse ข้ามการรัน test แต่ละรอบ (ไม่ว่า mvn test หรือ gradle) (บน Windows: ~ คือ C:\Users\<username>\ หรือใช้ %USERPROFILE%\.testcontainers.properties)
8.4 WebTestClient — สำหรับ WebFlux + ก็ใช้กับ MVC ได้
ถ้า app เป็น WebFlux (บท 10) → ใช้ WebTestClient แทน MockMvc:
java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class ReactiveOrderTest {
@Autowired WebTestClient client;
@Test
void streamsOrders() {
client.get().uri("/orders/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(OrderEvent.class)
.getResponseBody()
.take(3)
.as(StepVerifier::create)
.expectNextCount(3)
.verifyComplete();
}
}→ test streaming, backpressure (กลไกควบคุมไม่ให้ producer ส่งข้อมูลเร็วเกิน capacity ของ consumer), SSE (Server-Sent Events — เทคนิคที่ server push ข้อมูลหา client ต่อเนื่องแบบ one-way stream) ได้ครบ — MockMvc ทำไม่ได้
8.5 WireMock — mock external HTTP service
ปัญหา: app เรียก Stripe API → test จะ hit Stripe จริงทุกครั้งไม่ได้ (slow, costly, fragile — เปราะ/เสียหายง่ายเมื่อ external service เปลี่ยน)
xml
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>java
// WireMock 3.x (groupId: org.wiremock) ยังคง package เดิม com.github.tomakehurst ไว้
// (เปลี่ยนแค่ Maven groupId ไม่ได้เปลี่ยน Java package)
import com.github.tomakehurst.wiremock.WireMockServer;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
// ⚠️ หมายเหตุ: PaymentService, PaymentResult, PaymentTimeoutException ใน section นี้
// เป็น class สมมติประกอบตัวอย่าง — ยังไม่มีในโปรเจกต์ของผู้อ่าน (section นี้ไว้อ่านเข้าใจ pattern)
@SpringBootTest
@TestPropertySource(properties = "stripe.url=http://localhost:8089")
class PaymentServiceTest {
@Autowired
private PaymentService service; // สมมติว่ามี PaymentService ใน project
static WireMockServer wireMock;
@BeforeAll
static void start() {
wireMock = new WireMockServer(8089);
wireMock.start();
}
@AfterAll
static void stop() { wireMock.stop(); }
@BeforeEach
void reset() { wireMock.resetAll(); }
@Test
void shouldHandleStripeSuccess() {
// Stub
wireMock.stubFor(post(urlEqualTo("/charges"))
.withHeader("Authorization", containing("Bearer "))
.withRequestBody(matchingJsonPath("$.amount", equalTo("1000")))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"id":"ch_123","status":"succeeded"}""")));
// Run
PaymentResult r = service.charge(1000);
// Verify
assertThat(r.id()).isEqualTo("ch_123");
wireMock.verify(postRequestedFor(urlEqualTo("/charges")));
}
@Test
void shouldHandleStripeTimeout() {
wireMock.stubFor(post(urlEqualTo("/charges"))
.willReturn(aResponse().withFixedDelay(10_000))); // simulate slow (10s)
assertThatThrownBy(() -> service.charge(1000))
.isInstanceOf(PaymentTimeoutException.class);
// ⚠️ สมมติว่า PaymentService ตั้ง timeout ของ WebClient/RestClient ไว้ < 10s เช่น:
// WebClient.builder().responseTimeout(Duration.ofSeconds(2)).build();
// ถ้าไม่ตั้ง → test จะ timeout ที่ JUnit level แทนที่จะ throw PaymentTimeoutException
}
}จุดเด่น: simulate edge case (timeout, 500, malformed JSON) ที่ external service ทำไม่ได้ + record/replay จริง production traffic ได้ (record/replay = บันทึก HTTP request จริงจาก production แล้วเล่นซ้ำใน test — advanced feature อ่านเพิ่มที่ docs.wiremock.org)
8.6 Contract Testing — สัญญาระหว่าง service
🚧 ขั้นสูงมาก — สำหรับทีมที่ทำ microservices หลาย service (มือใหม่ข้ามได้)
🔤 ศัพท์ที่จะเจอ:
- consumer (ผู้ใช้บริการ) = service ที่ "เรียก" อีก service เช่น
order-serviceเรียกuser-service- producer (ผู้ให้บริการ) = service ที่ "ถูกเรียก" เช่น
user-service- contract (สัญญา) = ข้อตกลงว่า request/response หน้าตาแบบไหน
- stub = mock ปลอมที่ถูก generate จาก contract → consumer ใช้แทน producer จริง
- artifact repo = ที่เก็บไฟล์ build (เช่น Nexus, Artifactory — โปรแกรม server สำหรับเก็บและแชร์ .jar ในทีม)
- polyglot = รองรับหลายภาษา (Java, JS, Go, .NET, ...)
ปัญหา: microservice A เรียก B; B เปลี่ยน API → ตอน deploy A พัง
Contract testing = consumer (A) เขียน "สัญญา" ว่า "คาดให้ B return อะไร" → CI ของ B run test กับ contract → ถ้า break → fail
Spring Cloud Contract
groovy
// Producer ของ B เขียน contract ใน Groovy DSL
Contract.make {
request {
method GET()
url '/users/1'
}
response {
status 200
body([id: 1, name: 'Anna'])
headers { contentType('application/json') }
}
}→ Spring Cloud Contract auto-generate:
- producer test ของ B (verify ว่าตอบตาม contract)
- stub jar (deploy ไป artifact repo) — consumer A ใช้แทน mock
java
// ใน consumer A
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:user-service:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class UserClientTest {
@Test void shouldFetchUser() { /* hit localhost:8090 */ }
}Pact — alternative (consumer-driven contract)
java
// Consumer เขียน "expectation" → generate pact file → producer verify
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service")
class UserClientPactTest {
@Pact(consumer = "order-service")
public V4Pact getUserPact(PactDslWithProvider builder) {
return builder.given("user 1 exists")
.uponReceiving("a request for user 1")
.path("/users/1").method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody().integerType("id", 1).stringType("name"))
.toPact(V4Pact.class);
}
@Test
void test(MockServer mockServer) {
User u = new UserClient(mockServer.getUrl()).fetch(1);
assertThat(u.id()).isEqualTo(1);
}
}เลือกตัวไหน
| Spring Cloud Contract | Pact | |
|---|---|---|
| Style | producer-first | consumer-first |
| ภาษา | Java/Groovy/Kotlin | polyglot — รองรับหลายภาษา (JS, Go, .NET, Ruby) |
| Spring native | ✅ | ดี |
| Best for | wholly-Spring stack | mixed-language org |
8.7 Test Data Builder — สร้าง test fixture (ชุดข้อมูลตัวอย่างสำหรับ test) ที่อ่านง่าย
ปัญหา: test มี boilerplate (โค้ดซ้ำๆ ที่ไม่มีสาระ) new User(null, "Anna", "a@x.com", 25, ...) ซ้ำในทุก test
java
// ❌ อ่านยาก + เปราะ — เพิ่ม field 1 ตัว ต้องแก้ทุก test
User u = new User(1L, "Anna", "a@x.com", 25, true, Instant.now(), Set.of("USER"));
// ✅ builder — สร้างเฉพาะ field ที่สำคัญต่อ test นี้
User u = UserBuilder.aUser()
.withName("Anna")
.withRoles("USER", "ADMIN")
.build();
User adult = UserBuilder.aUser().withAge(25).build();
User minor = UserBuilder.aUser().withAge(15).build();java
// หมายเหตุ: UserBuilder นี้ใช้ User สมมติที่มี field เยอะ (id, name, email, age, active, Instant, roles)
// เพื่อโชว์ว่า constructor ยาวทำให้ test อ่านยาก — ไม่ใช่ User จากบทที่ 2/3 (section นี้ไว้อ่านเข้าใจ pattern)
public class UserBuilder {
private Long id = null;
private String name = "default";
private String email = "default@x.com";
private int age = 30;
private boolean active = true;
private Set<String> roles = Set.of("USER");
public static UserBuilder aUser() { return new UserBuilder(); }
public UserBuilder withName(String n) { this.name = n; return this; }
public UserBuilder withAge(int a) { this.age = a; return this; }
public UserBuilder withRoles(String... r) { this.roles = Set.of(r); return this; }
// ...
public User build() {
return new User(id, name, email, age, active, Instant.now(), roles);
}
}หรือใช้ library:
- Instancio — auto-generate object พร้อม value, custom field ที่สำคัญ
- Java Faker — ค่าจริงๆ (name, email, address)
- Fixture Factory — ใกล้ Ruby/Rails fixture
8.8 @TestConfiguration + @MockitoSpyBean
🔴 API update (Spring Boot 3.4+, พ.ย. 2024):
@MockBeanและ@SpyBeanถูก deprecate และถูกแทนด้วย@MockitoBean/@MockitoSpyBean(packageorg.springframework.test.context.bean.override.mockito) ตัวอย่างทั้งหมดในบทนี้แสดงด้วย annotation ใหม่ — ถ้าโปรเจกต์ที่อ่านอยู่ยังใช้@MockBeanก็ทำงานได้ แต่ควรค่อยปรับ (migrate) ทีละตัว (ทั้งสอง annotation ใช้งานร่วมกันในไฟล์เดียวได้ระหว่างช่วงปรับ)
@TestConfiguration — เปลี่ยน bean (override) ใน test
java
@SpringBootTest
class OrderServiceTest {
@TestConfiguration // ⬅ inner class — apply เฉพาะ test นี้
static class TestConfig {
@Bean
public Clock clock() {
return Clock.fixed(Instant.parse("2026-05-25T10:00:00Z"), ZoneOffset.UTC);
}
}
@Autowired Clock clock; // injected ค่า fixed
}@MockitoSpyBean — ครอบ bean จริงด้วย spy (สอดส่อง — บันทึกการเรียก method + เลือก stub บางตัวได้)
📖 spy vs mock:
- mock = หุ่นจำลอง — ของปลอมทั้งหมด ตอบตามสคริปต์เท่านั้น (method จริงไม่รัน)
- spy = นักสืบที่ซุ่มดูของจริง — ของจริงยังทำงานปกติ แต่เราบันทึกว่ามันถูกเรียกอะไรบ้าง และ override เฉพาะบาง method ได้
java
@SpringBootTest
class EmailServiceTest {
@Autowired
private UserService service;
@MockitoSpyBean // ⬅ real bean + can override (Boot 3.4+)
EmailGateway gateway;
@Test
void shouldSendEmailOnSignup() {
service.signup(new User("Anna", "a@x.com", 25)); // User จากบทที่ 2
verify(gateway).send(argThat(m -> m.to().equals("a@x.com")));
// argThat(...) — predicate lambda ตรวจ argument (ดู section 4 Mockito)
// gateway logic ยังทำงานปกติ — แค่ "ดู" ว่าถูกเรียก
}
}ต่างจาก @MockitoBean ที่ replace bean ด้วย mock เปล่า (ทุก method return default)
9. Test Security
การ test endpoint ที่มี security ต้องจำลอง user ที่ login — @WithMockUser สร้าง authenticated user ปลอม (กำหนด role ได้) ให้ test โดยไม่ต้อง login จริง ทำให้ทดสอบได้ว่า endpoint อนุญาต/ปฏิเสธตาม role ถูกต้อง:
ใช้ @WithMockUser:
java
// AdminController ต้องสร้างเอง (ดูบทที่ 3 section 11 หรือสร้าง controller ใหม่ที่ map /admin/users)
// @Import(SecurityConfig.class) จำเป็น — @WebMvcTest โหลดเฉพาะ web layer
// ถ้าไม่ import SecurityConfig มา role จะไม่มีผล → test shouldReturn403ForNonAdmin จะ fail (ได้ 200 แทน 403)
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
// mock dependency ของ SecurityConfig ที่ต้องการ (JwtAuthFilter, JwtService, UserDetailsService)
@MockitoBean
private JwtService jwtService;
@MockitoBean
private UserDetailsService userDetailsService;
@Test
@WithMockUser(roles = "USER")
void shouldReturn403ForNonAdmin() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdmin() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isOk());
}
}10. Best Practice
✅ ตั้งชื่อ test ชัดเจน
java
@Test void shouldReturnUserById() { ... }
@Test void shouldThrow404WhenUserNotFound() { ... }
@Test void shouldValidateAgeRange() { ... }format: should + ทำอะไร + เงื่อนไข
✅ One assertion per test (เป็น guideline)
test ที่ assert หลายอย่าง = หา bug ยากเมื่อ fail
✅ AAA structure
Arrange → Act → Assert — แต่ละ section คั่นด้วย blank line
✅ ไม่ใช้ randomness ใน test
java
@Test
void bad() {
int x = new Random().nextInt(); // ❌ fail-flaky
...
}✅ Independent test
test ตัวหนึ่งไม่ depends ของอีกตัว — run ลำดับไหนก็ผ่าน
✅ Fast unit test
unit test ที่ดี run < 100ms — เพราะ run บ่อย
❌ Test การทำงานภายใน (private method)
test ผ่าน public API — private method เปลี่ยนได้
11. Coverage — เท่าไหร่ดี
ใช้ JaCoCo วัด:
xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>run: mvn test → ดูที่ target/site/jacoco/index.html
Coverage รวม:
- 70-80% สำหรับ business code = ดี
- 100% = สูญเวลาเปล่า (ได้ test ที่ไม่มีคุณค่า — ต้องเขียน test บาง case ที่ไม่มี logic เพื่อให้ครบ)
- โฟกัส critical path ก่อน (auth, payment, ฯลฯ)
Coverage บอก "code ถูก run" — ไม่ได้บอก "test ดี" — มี test 100% coverage ที่ทำ nothing ก็ได้
12. Checkpoint
🛠️ Checkpoint 4.1 — Unit test service
ทำ test UserService ด้วย Mockito:
- shouldCreateUser
- shouldThrowWhenEmailExists
- shouldGetById
- shouldThrowWhenNotFound
🛠️ Checkpoint 4.2 — Repository test
ทำ @DataJpaTest ของ UserRepository:
- shouldSaveUser
- shouldFindByEmail
- shouldReturnEmptyWhenNotFound
- shouldEnforceUniqueEmail
🛠️ Checkpoint 4.3 — Controller test
ทำ @WebMvcTest ของ UserController:
- GET /users → 200 + list
- POST /users → 201
- POST /users invalid → 400
- GET /users/999 → 404
🛠️ Checkpoint 4.4 — Integration test
ทำ test ครบ flow CRUD ผ่าน @SpringBootTest
13. สรุปบท
✅ Pyramid: unit (เยอะ) → integration → E2E (น้อย) ✅ JUnit 5: @Test, @BeforeEach, @ParameterizedTest ✅ AssertJ: fluent assertion ที่อ่านง่าย ✅ Mockito: mock dependency ด้วย @Mock + when().thenReturn() + verify() ✅ Pattern AAA: Arrange → Act → Assert ✅ Test type ของ Spring:
@DataJpaTest— JPA layer (H2 + rollback)@WebMvcTest— Controller (mock service)@SpringBootTest— full context ✅ MockMvc — test HTTP endpoint ✅ Testcontainers — DB จริงใน Docker (real Postgres) ✅@WithMockUser— test endpoint ที่ require auth ✅ Coverage ~70-80% บน business code, focus critical path