Skip to content

บทที่ 4 — Testing (Unit + Integration — เทสต์โค้ดทีละชิ้น และทดสอบหลายชิ้นทำงานร่วมกัน)

← บทที่ 3 | สารบัญ | บทที่ 5: Production →

โค้ดที่ไม่มี test = ระเบิดเวลา — แก้ feature หนึ่ง พังอีก 3 ที่ ไม่รู้ตัว

บทนี้:

  1. JUnit 5 basics — เขียน test แรก
  2. Mockito — mock dependency
  3. AssertJ — assertion ที่อ่านได้
  4. Test layer แต่ละชั้น: Repository, Service, Controller
  5. Integration test — test ทั้ง app
  6. 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 เราสร้าง mock UserRepository แล้วกำหนดเองได้ว่า 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ความหมาย
@Testmethod นี้เป็น test
@BeforeEachrun ก่อนทุก test
@AfterEachrun หลังทุก test
@BeforeAllrun ครั้งเดียวก่อน test ทั้งหมด (ต้อง static)
@AfterAllrun ครั้งเดียวหลัง test ทั้งหมด
@Disabledข้าม test นี้
@ParameterizedTestrun หลายค่า 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 ของมัน

ลำดับ:

  1. @Mock UserRepository repo → Mockito สร้าง repo ปลอม
  2. @InjectMocks UserService service → Mockito สร้าง UserService แล้วฉีด repo (mock) ใส่ใน constructor
  3. เราตั้งให้ 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" ทางแก้: เพิ่ม method findByEmail เองใน 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 วิธี:

  1. ใส่ @WithMockUser บน test method (แนะนำ — อธิบายเต็มๆ ใน section 9)
  2. ใส่ @AutoConfigureMockMvc(addFilters = false) บน class เพื่อ disable security filter ทั้งหมด
  3. ทำ 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@DataJpaTest
controller / HTTP / JSON@WebMvcTest
full 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 error cannot 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 + withNetworkAliasescontainer คุยกันด้วย 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 ContractPact
Styleproducer-firstconsumer-first
ภาษาJava/Groovy/Kotlinpolyglot — รองรับหลายภาษา (JS, Go, .NET, Ruby)
Spring nativeดี
Best forwholly-Spring stackmixed-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 (package org.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

→ ไปบทที่ 5: Production Readiness