Spring Boot Testing Mastery: เทสให้เร็ว เทสให้ตรงจุด และการทำ TDD
"การเขียน Test" คือยาขมของ Developer หลายคน สาเหตุไม่ใช่เพราะมันยาก แต่เพราะเรามักจะ "เริ่มต้นผิดวิธี"
หลายคนพยายาม Start App ทั้งตัวเพื่อเทสฟังก์ชันเล็กๆ หรือพยายามเขียน Test หลังจาก Code เสร็จจนแก้อะไรไม่ได้ วันนี้เราจะมาเปลี่ยน Mindset นี้ใหม่ด้วยเทคนิคที่ถูกต้อง เครื่องมือที่ใช่ และจังหวะการเขียนที่ดีครับ
1. Why Mockito? (ทำไมต้องใช้ตัวแสดงแทน?)
คำถามยอดฮิตคือ: "ทำไมต้องใช้ Library อย่าง Mockito? เขียน Java ธรรมดาไม่ได้เหรอ?"
เพื่อให้เห็นภาพชัดที่สุด ลองจินตนาการว่าคุณต้องเทส PaymentService ที่มีหน้าที่ "ตัดเงินผ่านธนาคาร"
public class PaymentService {
private final BankApi bankApi;
// Dependency Injection: รับ BankApi เข้ามาทาง Constructor
public PaymentService(BankApi bankApi) {
this.bankApi = bankApi;
}
public boolean pay(String cardId, double amount) {
// Business Logic: ถ้า API ล่ม หรือบัตรวงเงินไม่พอ ต้องจัดการยังไง?
return bankApi.charge(cardId, amount);
}
}
ปัญหาคือตอน Test... เราจะเอา BankApi ตัวไหนส่งเข้าไปใน Constructor?
- ตัวจริง: ไม่ได้! เพราะมันจะไปตัดเงินจริง หรือต้องต่อเน็ตจริง ซึ่งทำ Unit Test ไม่ได้
- ตัวปลอม: ต้องสร้างขึ้นมา... นี่แหละคือจุดวัดใจ
ทางเลือก A: ไม่ใช้ Mockito (Manual Stubbing)
ถ้าไม่ใช้ Mockito คุณต้องสร้าง Class ปลอมขึ้นมาเอง (เรียกว่า Manual Stub)
ขั้นตอนความปวดหัว:
- ต้องสร้าง Class ขยะ: คุณต้องสร้างไฟล์
FakeBankApi.javaขึ้นมาเพื่อการ Test โดยเฉพาะ - ต้อง Implement ทุก Method: ถ้า Interface
BankApiมี 20 Methods คุณต้อง@Overrideทั้ง 20 อัน แม้คุณจะใช้แค่อันเดียว! - Hardcode Logic: คุณต้องเขียน Logic หลอกๆ ใส่เข้าไป ซึ่งบางที Logic ใน Test ซับซ้อนกว่า Code จริงซะอีก
// FakeBankApi.java (สร้างเอง ดูแลเอง)
public class FakeBankApi implements BankApi {
@Override
public boolean charge(String cardId, double amount) {
// ต้องมานั่งเขียน Logic เพื่อดักเคสต่างๆ เอง
if ("ERROR_CARD".equals(cardId)) {
throw new RuntimeException("Network Error");
}
if ("NO_MONEY".equals(cardId)) {
return false;
}
return true;
}
// ถ้าวันดีคืนดี Interface หลักเพิ่ม method 'refund'
// Class นี้จะ Compile Error ทันที! คุณต้องตามมาแก้ไฟล์นี้ตลอด
@Override
public void refund(String txnId) {
// ... ต้องมานั่งเขียน implementation ปลอมๆ อีก
}
}
สรุป:
- Maintenance Nightmare: Interface เปลี่ยน -> Test พัง -> ต้องแก้ Class Fake
- Logic Leak: เราต้องเอา Logic การ Test (เช่น
if "ERROR_CARD") ไปฝังไว้ใน Class Fake ซึ่งทำให้จัดการยาก
ทางเลือก B: ใช้ Mockito (The Magic Wand)
Mockito ใช้เทคนิคขั้นสูง (Reflection & Bytecode Generation) เพื่อ "เสก Class ปลอมขึ้นมาใน Memory" ตอนรัน Test เลย โดยที่คุณไม่ต้องสร้างไฟล์ .java สักไฟล์
ความเหนือกว่า 3 มิติ:
1. ควบคุมพฤติกรรมได้ดั่งใจ (Stubbing)
ไม่ต้องเขียน if-else ใน Class Fake แค่ "สั่ง" มันบรรทัดต่อบรรทัด
// Case 1: อยากเทสกรณีผ่านปกติ
Mockito.when(bankApi.charge("1234", 100.0)).thenReturn(true);
// Case 2: อยากเทสกรณีธนาคารล่ม (Exception) -- ทำด้วย Manual Fake ยากมาก
Mockito.when(bankApi.charge(anyString(), anyDouble()))
.thenThrow(new RuntimeException("Connection Timeout"));
2. ตรวจสอบการทำงานได้ (Verification)
นี่คือฟีเจอร์ที่ Manual Fake ทำได้ยากมาก คือการเช็คว่า "Method นี้ถูกเรียกจริงหรือเปล่า?" และ "ถูกเรียกกี่ครั้ง?" (Side Effect Check)
สมมติ Logic คือ "ถ้าตัดเงินไม่ผ่าน ให้ลองซ้ำ 3 ครั้ง" (Retry Logic)
// เช็คว่า PaymentService พยายามเรียก bankApi.charge ครบ 3 ครั้งจริงไหม?
Mockito.verify(bankApi, times(3)).charge(anyString(), anyDouble());
ถ้าเขียนเอง คุณต้องเอาตัวแปร count++ ไปแปะไว้ใน Class Fake วุ่นวาย
3. จับข้อมูลที่ส่งไป (Argument Captor)
เราสามารถล้วงดูได้ว่า PaymentService ส่งค่าอะไรไปให้ BankApi กันแน่
ArgumentCaptor<Double> amountCaptor = ArgumentCaptor.forClass(Double.class);
verify(bankApi).charge(anyString(), amountCaptor.capture());
// เช็คว่ามีการบวก Vat 7% ก่อนส่งไปตัดเงินไหม?
assertEquals(107.0, amountCaptor.getValue());
2. Testing Strategy: อย่าขี่ช้างจับตั๊กแตน
"เทสให้เล็กที่สุดเท่าที่จะทำได้" (Test Slices)
อย่าใช้ @SpringBootTest พร่ำเพรื่อ เพราะมันโหลดทั้ง App ทำให้การรัน Test ช้ามาก
Slice 1: Service Layer (Unit Test)
- เป้าหมาย: เช็ค Logic การคำนวณ if-else
- เครื่องมือ:
JUnit+Mockito(ตามตัวอย่างข้อ 1)
@ExtendWith(MockitoExtension.class) // ไม่ใช้ @SpringBootTest เร็วปรู๊ด!
class UserServiceTest {
@Mock
private UserRepository userRepository; // Mock Database
@InjectMocks
private UserService userService; // Inject Mock เข้า Service
@Test
void shouldCalculateTaxCorrectly() {
// Setup
User user = new User(100000.0); // เงินเดือนแสนนึง
// Action
double tax = userService.calculateTax(user);
// Assert
assertEquals(10000.0, tax); // เช็คว่า Logic คำนวณภาษีถูกไหม
}
}
Slice 2: Controller Layer (Integration Test)
- เป้าหมาย: เช็คว่า URL ถูกไหม, รับส่ง JSON ถูก format ไหม (ไม่ต้องสนใจ Logic ข้างใน)
- เครื่องมือ:
@WebMvcTest(โหลดเฉพาะ Controller ไม่โหลด Service/DB)
// 1. บอก Spring ว่าจะเทสแค่ UserController นะ อย่างอื่นไม่ต้องโหลด
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // ตัวจำลอง Browser/Postman
@MockBean
private UserService userService; // 2. Service ของจริงไม่ต้องใช้ Mock เอา!
@Test
void shouldReturnUser() throws Exception {
// Setup: บอกว่าถ้าเรียก service.findById(1) ให้คืนค่า dummy user นะ
User dummyUser = new User(1L, "Dev A");
Mockito.when(userService.findById(1L)).thenReturn(dummyUser);
// Action & Assert: ยิง API และตรวจผล
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk()) // หวังว่าจะได้ 200 OK
.andExpect(jsonPath("$.name").value("Dev A")); // หวังว่าชื่อตรง
}
}
Slice 3: Repository Layer (Data Test)
- เป้าหมาย: เช็คว่า SQL Query ที่เขียนถูกต้องไหม
- เครื่องมือ:
@DataJpaTest(ใช้ DB จำลอง H2 และ Rollback ข้อมูลทิ้งเมื่อจบ Test)
@DataJpaTest // 1. โหลดเฉพาะส่วน Database + ใช้ In-Memory DB (H2) ให้เองอัตโนมัติ
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldFindActiveUsers() {
// Setup: save ลง db จำลอง
userRepository.save(new User("Dev A", "ACTIVE"));
userRepository.save(new User("Dev B", "INACTIVE"));
// Action
List<User> activeUsers = userRepository.findByStatus("ACTIVE");
// Assert
assertEquals(1, activeUsers.size());
assertEquals("Dev A", activeUsers.get(0).getName());
}
}
3. TDD: เขียน Test ก่อน แล้วชีวิตจะดีขึ้นยังไง?
ถ้าคุณอยากให้ทีม "อยากเขียน Test" คุณต้องแนะนำให้เขารู้จัก TDD (Test Driven Development) เปลี่ยนจาก Test = การตรวจสอบ (ทำเสร็จแล้วค่อยตรวจ) เป็น Test = การออกแบบ (กำหนดสเปคก่อนทำ)
วงจรชีวิต TDD (Red -> Green -> Refactor)
สมมติโจทย์คือ: "เขียนฟังก์ชันคำนวณส่วนลด 10% ถ้าซื้อครบ 1000 บาท"
Step 1: RED (เขียน Test ให้พังก่อน)
เรายังไม่มี Code ฟังก์ชันนั้นด้วยซ้ำ แต่เราเขียนความต้องการลงไปเลย:
@Test
void shouldGiveDiscount_WhenTotalIs1000() {
// จินตนาการว่าอยากเรียกใช้แบบนี้ (แม้ method นี้ยังไม่มีจริง)
double netPrice = service.calculateDiscount(1000.0);
assertEquals(900.0, netPrice);
}
ผลลัพธ์: Compile Error / Test Failed -> นี่คือ RED ที่บอกว่าเรายังขาดอะไร
Step 2: GREEN (เขียน Code แค่พอให้ผ่าน)
ไปสร้าง method นั้นขึ้นมา แล้วเขียน Logic ที่ "ง่ายที่สุด" เพื่อให้ Test ผ่าน
public double calculateDiscount(double total) {
if (total >= 1000) return total * 0.9;
return total;
}
ผลลัพธ์: Test Passed -> นี่คือ GREEN ความรู้สึกตอนเห็นไฟเขียวมันฟินมาก!
Step 3: REFACTOR (ทำให้สวยขึ้น)
มั่นใจแล้วว่า Logic ถูกต้อง ก็ไปแก้ Code ให้สวยขึ้น ลดบรรทัด หรือเปลี่ยนชื่อตัวแปร ได้ตามสบาย ตราบใดที่ Run Test แล้วยัง เขียว อยู่
ทำไม TDD ถึงทำให้เรื่องยากกลายเป็นง่าย?
- ไม่ต้องเดา: Test case คือ Requirement ที่ชัดเจนที่สุด ไม่ต้องนั่งเทียนเขียนเผื่อ
- หยุด Over-engineering: เขียน Code เท่าที่จำเป็นเพื่อให้ Test ผ่านเท่านั้น ระบบจะไม่ซับซ้อนเกินความจำเป็น
- กล้าแก้ Code: การ Refactor กลายเป็นเรื่องสนุก เพราะถ้าแก้ผิด Test จะฟ้องทันที
บทสรุป: ทำไมต้องทำ? (Pros vs Cons)
ข้อดี
- Safety Net: กล้าแก้ Code เก่าโดยไม่ต้องกลัวพัง เพราะ Test จะฟ้องทันทีถ้ามีอะไรผิดพลาด
- Live Documentation: Test Case คือคู่มือการใช้งาน Code ที่ดีที่สุดและไม่มีวันล้าสมัย
- Faster Debugging: ไม่ต้องรันทั้ง App เพื่อหา Bug แค่เขียน Test Case จำลอง Bug นั้น แล้วแก้ จบในวิเดียว
ผลเสีย
- Regression Bugs: แก้ Bug นึง งอกใหม่อีกสอง Bug วนลูปนรกไปเรื่อยๆ
- Fear of Change: ทีมจะไม่กล้าแตะต้อง Legacy Code เพราะ "กลัวพัง" ทำให้ระบบล้าหลัง
- Slow Feedback: กว่าจะรู้ว่า Logic ผิด ก็ตอน Deploy ขึ้น Server ไปแล้ว หรือลูกค้าโทรมาด่า
คำแนะนำสุดท้าย
การเขียน Test ไม่ใช่ "งานเพิ่ม" แต่มันคือ "ส่วนหนึ่งของการเขียน Code" เหมือนหมอที่ต้องล้างมือก่อนผ่าตัด เริ่มวันละนิดด้วย Mockito แบ่ง Slice ให้ถูก และใช้ TDD นำทาง แล้วคุณจะพบว่าการเขียน Code โดยมี "ไฟเขียว" คอยคุ้มกัน มันคือความสบายใจที่สุดของ Developer ครับ
No responses yet. Be the first to respond.