โหมดมืด
บทที่ 15 — Testing + Logging (Pure Java)
📓 โซนอ้างอิง—เปิดตอนต้องใช้ (บท 11-19) บทนี้อยู่ในโซนที่ 2 แต่ เป็นทักษะที่ควรหัดเร็ว เมื่อเริ่มเขียนโปรเจกต์จริง — การเขียน test (ทดสอบโค้ดอัตโนมัติ) และ logging (บันทึก log การทำงาน) เป็นพื้นฐานของงานมืออาชีพ ถ้ายังหัดพื้นฐานภาษาอยู่ (บท 0-10) ข้ามไปก่อนได้ แล้วกลับมาตอนเริ่มทำโปรเจกต์
บทสุดท้ายของเนื้อหาหลัก Java — เครื่องมือที่ทุก project จริงต้องมี (บท 16-19 เป็นหัวข้อ advanced/อ้างอิง อ่านทีหลังได้):
- Testing (การทดสอบโค้ดอัตโนมัติ) — JUnit 5 + AssertJ + Mockito (พื้นฐาน)
- Logging (ล็อกกิง = การบันทึกข้อความบอกว่าโปรแกรมทำอะไรไปบ้าง) — SLF4J + Logback
ศัพท์ที่จะเจอบ่อยในบทนี้: unit test (ทดสอบโค้ดทีละชิ้นเล็ก ๆ), mock (ของปลอมที่สร้างมาแทนของจริงตอนทดสอบ), assertion (การยืนยันว่าผลลัพธ์ตรงที่คาด), JUnit (เฟรมเวิร์กเขียน test ของ Java), Mockito (library สร้าง mock), SLF4J (Simple Logging Facade for Java = หน้ากากกลางสำหรับเรียก logging), Logback (ตัว logging ที่ทำงานจริงเบื้องหลัง), CI (Continuous Integration = ระบบรัน build/test อัตโนมัติเมื่อมีโค้ดใหม่)
⚠️ บทนี้เป็น pure Java — ใน Spring Boot บทที่ 04 มี Spring Test รายละเอียดมากกว่า
บทนี้ช่วยให้เข้าใจรากฐานก่อน
Part 1: Why Test?
1. เหตุผลที่ต้องเขียน test
| ไม่มี test | มี test |
|---|---|
| แก้ code 1 ที่ → ไม่รู้พัง 5 ที่อื่น | เปลี่ยน 1 ที่ → run test รู้ทันที |
| โค้ดเก่า 1 ปี กลัวจะแตะ | refactor ได้สบาย — มี safety net |
| Manual test ทุก feature ก่อน deploy | CI (Continuous Integration) run test อัตโนมัติ |
| Bug หลัง deploy = ตื่นตกใจแก้กลางดึก | bug ส่วนใหญ่ถูกจับก่อน deploy — เจอ bug ใหม่ก็เขียน test กันพังซ้ำ |
ไม่ใช่ "เขียน test เพื่อให้ test ผ่าน" — เขียนเพื่อ document พฤติกรรมที่ถูก
2. ประเภท test
| ประเภท | scope (ขอบเขต) | เร็ว/ช้า | สัดส่วน |
|---|---|---|---|
| Unit | 1 class / method | เร็วมาก (ms) | 70% |
| Integration | หลาย class ทำงานด้วยกัน | ปานกลาง (s) | 20% |
| End-to-end (E2E) | ทั้ง app + external (DB, API) | ช้า (10s+) | 10% |
= Testing Pyramid — unit เยอะ, e2e น้อย
Part 2: JUnit 5
3. Setup
เริ่มเขียนเทสต์ใน Java ด้วย JUnit 5 (หรือชื่อเต็ม JUnit Jupiter — ชื่อ subproject ของ JUnit 5 สำหรับเขียน test) — เพิ่ม dependency ใน pom.xml (scope test) แล้ววางไฟล์เทสต์ใน src/test/java/ โดยใช้ชื่อเดียวกับคลาสจริงตามด้วย "Test":
xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>⚠️ Maven Surefire Plugin: ต้องใช้เวอร์ชัน ≥ 2.22.0 ถึงจะ run JUnit 5 ได้ (เวอร์ชันเก่า detect test ไม่เจอ) — เพิ่มใน
pom.xml:xml<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> </plugin>
โครงสร้าง:
text
src/
├── main/java/com/example/
│ └── Calculator.java
└── test/java/com/example/
└── CalculatorTest.java ← convention: ชื่อเดียวกัน + "Test"4. Test แรก
มาเขียนเทสต์แรกกัน — ใส่ @Test บน method, เรียกโค้ดที่ต้องการทดสอบ, แล้วตรวจผลด้วย assertEquals (ค่าตรงไหม) หรือ assertThrows (โยน exception ตามคาดไหม):
java
// Calculator.java
public class Calculator {
public int add(int a, int b) { return a + b; }
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("Divide by zero");
return a / b;
}
}java
// CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
Calculator calc = new Calculator();
@Test
void shouldAddTwoNumbers() {
// Arrange
int a = 2, b = 3;
// Act
int result = calc.add(a, b);
// Assert
assertEquals(5, result);
}
@Test
void shouldThrowWhenDivideByZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
}
}Run:
bash
mvn test5. Lifecycle Annotations
JUnit มี annotation ควบคุมว่าโค้ดเตรียม/เก็บกวาดทำงานเมื่อไหร่ — @BeforeEach/@AfterEach (ทุกเทสต์), @BeforeAll/@AfterAll (ครั้งเดียวต่อ class) ใช้เตรียมข้อมูลร่วมหรือปิดทรัพยากร:
java
// Database/UserService ด้านล่างเป็น class สมมติ — ดูที่ลำดับ annotation เป็นหลัก
class UserServiceTest {
static Database db;
UserService service;
@BeforeAll // run 1 ครั้งก่อนเริ่มทั้ง class
static void setupAll() {
db = new Database();
db.start();
}
@AfterAll // run 1 ครั้งหลังจบทั้ง class
static void teardownAll() {
db.stop();
}
@BeforeEach // run ก่อนทุก @Test
void setup() {
service = new UserService(db);
db.cleanTable("users");
}
@AfterEach // run หลังทุก @Test
void teardown() {
service.close();
}
@Test
void testCase1() { ... }
@Test
void testCase2() { ... }
}Order: @BeforeAll → (@BeforeEach → @Test → @AfterEach) ทำซ้ำตามจำนวน @Test ทั้งหมด → @AfterAll
6. AAA Pattern — Arrange-Act-Assert
มาตรฐานของ test ที่อ่านง่าย:
java
@Test
void shouldRegisterUser() {
// Arrange
User input = new User("anna@example.com", "Anna");
// Act
User saved = service.register(input);
// Assert
assertNotNull(saved.id());
assertEquals("anna@example.com", saved.email());
}ทุก test มี 3 ส่วนนี้เสมอ — ถ้าไม่ใช่ = test ไม่ดี
7. Assertion ของ JUnit
JUnit มี assertion ให้ครบทุกแบบสำหรับตรวจผล — เทียบค่า, เช็ค null/boolean, ตรวจ exception, ตรวจเวลา และ assertAll ที่รวมหลายการตรวจไว้ด้วยกัน (รายงานทุกอันที่ผิด ไม่หยุดที่อันแรก):
java
assertEquals(expected, actual);
assertEquals(expected, actual, "message ถ้า fail");
assertNotEquals(...);
assertTrue(condition);
assertFalse(condition);
assertNull(x);
assertNotNull(x);
assertSame(a, b); // a == b (เปรียบเทียบ reference)
assertNotSame(a, b); // a != b (reference ต่างกัน)
assertArrayEquals(arr1, arr2);
assertThrows(Exception.class, () -> code());
assertTimeout(Duration.ofSeconds(1), () -> code());
// หลาย assertion พร้อมกัน — fail ทั้งหมดที่ผิด ไม่ได้หยุดที่อันแรก
assertAll(
() -> assertEquals(1, user.id()),
() -> assertEquals("Anna", user.name()),
() -> assertTrue(user.active())
);8. @DisplayName — test name ที่อ่านง่าย
java
@Test
@DisplayName("ควร register user ใหม่ได้ถ้า email ไม่ซ้ำ")
void shouldRegisterNewUser() { ... }IDE / report จะแสดงข้อความนี้ — อ่านง่ายกว่า method name
9. Parameterized Test — test หลายชุดด้วย method เดียว
แทนที่จะเขียนเทสต์แยกทุกค่า input "Parameterized test" รันเทสต์เดียวกันด้วยข้อมูลหลายชุด — ป้อนค่าผ่าน @ValueSource, @CsvSource ฯลฯ ลดโค้ดซ้ำและเพิ่มเคสได้ง่าย:
java
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
void shouldRejectEmptyName(String name) {
assertThrows(ValidationException.class, () -> service.setName(name));
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, -5, 5",
"0, 0, 0"
})
void shouldAdd(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
@ParameterizedTest
@NullAndEmptySource // ป้อนค่า null และ empty string ("") ให้ทดสอบทั้งสองแบบในตัวเดียว
void shouldRejectNullOrEmpty(String input) {
assertFalse(validator.isValid(input));
}10. Nested Test — group test ที่เกี่ยวกัน
@Nested ช่วยจัดกลุ่มเทสต์ที่เกี่ยวกัน (เช่นเทสต์ของ method เดียวกัน) ไว้ใน class ย่อย — ทำให้ report อ่านเป็นหมวดหมู่และแชร์ setup เฉพาะกลุ่มได้:
java
class UserServiceTest {
@Nested
@DisplayName("register()")
class Register {
@Test void shouldSucceedWithValidInput() { ... }
@Test void shouldFailWithDuplicateEmail() { ... }
}
@Nested
@DisplayName("delete()")
class Delete {
@Test void shouldDeleteExistingUser() { ... }
@Test void shouldNotFailIfNotExists() { ... }
}
}10.5 @RepeatedTest — รัน test ซ้ำหลายรอบ
@RepeatedTest(n) รันเทสต์เดียวกันซ้ำ n รอบ — มีประโยชน์เวลาทดสอบความสุ่ม (เช่น password generator) หรือจับ flaky test ที่บางครั้งผ่านบางครั้งไม่ผ่าน:
java
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
// generator เป็น object สมมติที่สร้าง password — ดูที่ @RepeatedTest และ RepetitionInfo เป็นหลัก
// ConcurrentHashMap.newKeySet() แทน HashSet — ปลอดภัยถ้า JUnit รัน test แบบ parallel
static final Set<String> generated = ConcurrentHashMap.newKeySet();
@RepeatedTest(100)
@DisplayName("password generator ผลิตค่าที่ unique และตรงรูปแบบ")
void shouldGenerateUniquePassword(RepetitionInfo info) {
String pw = generator.next();
// ตรวจรูปแบบ: ตัวอักษร/ตัวเลข 12 ตัว
assertTrue(pw.matches("[A-Za-z0-9]{12}"),
"รอบที่ " + info.getCurrentRepetition() + ": '" + pw + "' ไม่ตรงรูปแบบ");
// ตรวจความ unique: เพิ่มลง Set แล้ว assert ว่าไม่ซ้ำ
assertTrue(generated.add(pw),
"รอบที่ " + info.getCurrentRepetition() + ": password ซ้ำกัน!");
}ใช้ทำ: flaky test detection, randomness test, stress test ระดับ unit
10.6 @TestFactory — dynamic test (สร้าง test ขณะ runtime)
java
import org.junit.jupiter.api.DynamicTest;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.*;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.stream.Stream;
// test-data.csv วางไว้ที่ src/test/resources/ แล้วโหลดผ่าน classpath (ตามด้านล่าง)
// หรือวางที่ root ของโปรเจกต์แล้วใช้ Path.of("test-data.csv") ตรง ๆ (ตรงกับ working dir ตอนรัน mvn test)
// ตัวอย่างเนื้อหา: "1,1,2" / "2,3,5" / "10,-5,5"
// calc คือ field Calculator เดิมจาก section 4 (Calculator calc = new Calculator();) — ในคลาสจริงต้องมี field นี้ด้วยถึงจะ compile ผ่าน
@TestFactory
Stream<DynamicTest> calculatorTestsFromCsv() throws IOException, URISyntaxException {
// โหลดจาก src/test/resources/ ผ่าน classpath — ไม่ขึ้นกับ working directory
Path csv = Path.of(getClass().getClassLoader().getResource("test-data.csv").toURI());
return Files.lines(csv)
.map(line -> line.split(","))
.map(parts -> dynamicTest(
"calc: " + parts[0] + " + " + parts[1],
() -> assertEquals(
Integer.parseInt(parts[2]),
calc.add(
Integer.parseInt(parts[0]),
Integer.parseInt(parts[1])))));
}ต่างจาก @ParameterizedTest: dynamic test สร้างจาก runtime data (file, DB, API) — ไม่จำกัดแค่ literal ใน @CsvSource
10.7 @Tag — แยก test เป็นกลุ่ม + filter
java
@Test @Tag("slow")
void heavyIntegrationTest() { ... }
@Test @Tag("fast") @Tag("unit")
void quickUnitTest() { ... }รันเฉพาะกลุ่ม (Maven):
bash
mvn test -Dgroups="fast" # รัน @Tag("fast")
mvn test -DexcludedGroups="slow" # ข้าม @Tag("slow")ใช้ทำ: แยก unit / integration / e2e — CI รัน fast บน PR, รันทั้งหมดบน main
10.8 Custom Extension — extension model ของ JUnit 5
JUnit 5 ใช้ "extension" เป็นกลไกหลักสำหรับขยาย behavior ของ test — เช่น เพิ่ม timing, inject dependency, หรือเพิ่ม lifecycle hook เองได้:
Namespace + Store คือที่เก็บข้อมูลชั่วคราวของ extension เอง (เหมือน Map เล็ก ๆ) ใช้ส่งค่าระหว่าง beforeEach → afterEach:
java
public class TimingExtension implements BeforeEachCallback, AfterEachCallback {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create("timing");
@Override
public void beforeEach(ExtensionContext ctx) {
ctx.getStore(NS).put("start", System.nanoTime());
}
@Override
public void afterEach(ExtensionContext ctx) {
// ใช้ typed overload get(key, Class) แทน (long) cast — ป้องกัน NullPointerException ถ้า key หาย
long start = ctx.getStore(NS).get("start", Long.class);
long dur = (System.nanoTime() - start) / 1_000_000;
System.out.printf("%s took %d ms%n", ctx.getDisplayName(), dur);
}
}
@ExtendWith(TimingExtension.class)
class MyTest {
@Test void slow() { /* ... */ }
}Hook อื่นที่ extension รับได้: BeforeAllCallback, AfterAllCallback, BeforeTestExecutionCallback, ParameterResolver, TestInstancePostProcessor, TestExecutionExceptionHandler
ตัวอย่าง extension จริง: @SpringBootTest, @DataJpaTest, MockitoExtension, TestcontainersExtension — ใช้ extension model ทั้งหมด
Part 3: AssertJ — assertion ที่อ่านเหมือนภาษาอังกฤษ
11. ทำไมใช้ AssertJ
assertion ของ JUnit ใช้ได้ แต่ AssertJ เขียนแบบ "fluent" — รูปแบบการเขียนโค้ดที่ต่อ method เป็นทอด ๆ เหมือนอ่านเรื่องเล่าตามลำดับ เช่น:
java
assertThat(list).isNotEmpty().hasSize(3).contains("Anna");autocomplete ช่วยเยอะ และข้อความ error ชัดกว่า — นิยมใช้ในโปรเจกต์จริง:
JUnit:
java
assertEquals("Anna", user.name());
assertTrue(user.active());
assertEquals(25, user.age());AssertJ:
java
assertThat(user.name()).isEqualTo("Anna");
assertThat(user.active()).isTrue();
assertThat(user.age()).isEqualTo(25);
// หรือ chain
assertThat(user)
.extracting(User::name, User::age, User::active)
.containsExactly("Anna", 25, true);fluent + autocomplete + error message ดีกว่า
xml
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.0</version>
<scope>test</scope>
</dependency>💡 สองกรณี:
- Plain Maven project (ไม่ใช้ Spring Boot): เพิ่ม dependency
assertj-coreตามด้านบนได้เลย- Spring Boot project (เรียนในบทหลังแยกต่างหาก):
spring-boot-starter-testรวม AssertJ มาให้อัตโนมัติ — ไม่ต้องเพิ่ม dependency เอง และ AssertJ ไม่ได้ต้องการ Spring Boot เพื่อใช้งาน ทั้งสองกรณีใช้ API เดียวกัน
12. AssertJ Patterns
AssertJ มี assertion เฉพาะทางสำหรับแต่ละชนิดข้อมูล — String, Number, Collection, Exception รวมแพตเทิร์นที่ใช้บ่อยไว้ด้านล่าง สังเกตว่าต่อหลายเงื่อนไขในบรรทัดเดียวได้:
String
java
assertThat("Hello World")
.startsWith("Hello")
.endsWith("World")
.contains("lo Wo")
.hasSize(11)
.isNotBlank();Number
java
assertThat(score)
.isPositive()
.isLessThan(100)
.isBetween(50, 90);Collection
java
assertThat(users)
.hasSize(3)
.extracting(User::name)
.containsExactly("Anna", "Ben", "Carol");
assertThat(users)
.filteredOn(u -> u.age() > 18)
.hasSize(2);
assertThat(users).anyMatch(u -> u.email().endsWith("@admin.com"));Exception
java
assertThatThrownBy(() -> service.divide(10, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessageContaining("Divide by zero");Part 4: Mockito — Mock dependency
13. ปัญหา — class มี dependency
java
class UserService {
private final UserRepository repo;
private final EmailSender sender;
public UserService(UserRepository repo, EmailSender sender) {
this.repo = repo;
this.sender = sender;
}
public User register(String email, String name) {
if (repo.findByEmail(email).isPresent()) {
throw new EmailExistsException();
}
User u = repo.save(new User(null, email, name));
sender.sendWelcome(email);
return u;
}
}ทดสอบ UserService แต่:
UserRepositoryคุยกับ DB จริง → ช้า + bug ไม่แน่นอนEmailSenderส่ง email จริง → spam
→ ต้อง mock dependency เหล่านี้
14. Setup Mockito
Mockito เป็น library สำหรับสร้าง "mock" (ของปลอม) ของ dependency เพื่อแยกทดสอบเฉพาะคลาสที่สนใจ — เพิ่ม 2 dependency: mockito-core และ mockito-junit-jupiter (เชื่อมกับ JUnit 5):
xml
<!-- เช็ค compatibility กับ JDK ที่ใช้: https://github.com/mockito/mockito/wiki/Compatibility-Matrix
(ลิงก์ไปหน้าภาษาอังกฤษ — ตรวจ version ที่รองรับ JDK ที่ใช้)
Mockito 5.x ขึ้นไปต้องการ Java 11+ (ถ้าใช้ Java 8 ให้ใช้ Mockito 4.x แทน)
Mockito 5.x ใช้ byte-buddy ใหม่ที่รองรับ JDK ใหม่ ๆ ได้ดี -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>💡 Spring Boot project:
spring-boot-starter-testรวม Mockito มาให้อัตโนมัติ — ไม่ต้องเพิ่ม dependency เอง dependency ด้านบนสำหรับ standalone Java project ที่ไม่ใช้ Spring Boot
15. Mock + InjectMocks
หัวใจของ Mockito: @Mock สร้างของปลอมของ dependency, @InjectMocks ฉีด mock เหล่านั้นเข้าคลาสที่ทดสอบ จากนั้น when(...).thenReturn(...) กำหนดพฤติกรรม และ verify(...) ตรวจว่า method ถูกเรียกตามคาด:
java
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository repo;
@Mock EmailSender sender;
// @InjectMocks ลำดับการ inject: 1) constructor injection (constructor ที่ใหญ่สุดที่ตรงกับ mock)
// 2) setter injection (setter ที่ type ตรงกับ mock) 3) field injection (inject ตรงเข้า field)
@InjectMocks UserService service;
@Test
void shouldRegisterNewUser() {
// Arrange — กำหนดพฤติกรรม mock
when(repo.findByEmail("anna@x.com")).thenReturn(Optional.empty());
when(repo.save(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
return new User(1L, u.email(), u.name());
});
// Act
User result = service.register("anna@x.com", "Anna");
// Assert
assertThat(result.id()).isEqualTo(1L);
verify(sender).sendWelcome("anna@x.com"); // ตรวจว่าถูกเรียก
}
@Test
void shouldThrowIfEmailExists() {
when(repo.findByEmail("anna@x.com"))
.thenReturn(Optional.of(new User(1L, "anna@x.com", "Anna")));
assertThatThrownBy(() -> service.register("anna@x.com", "Anna"))
.isInstanceOf(EmailExistsException.class);
verify(sender, never()).sendWelcome(anyString()); // email ต้องไม่ถูกส่ง
verify(repo, never()).save(any());
}
}16. Mockito API ใช้บ่อย
รวม API ของ Mockito ที่ใช้บ่อยไว้เป็นที่เดียว — stubbing (กำหนดให้ mock คืนค่า/โยน exception), argument matcher (any(), eq()), และ verify (ตรวจการเรียก) เปิดอ้างอิงได้เลย:
Stubbing — กำหนด behavior
java
when(mock.method(args)).thenReturn(value);
when(mock.method(args)).thenThrow(new Exception());
when(mock.method(args)).thenAnswer(invocation -> ...);
// Void method
doNothing().when(mock).voidMethod();
doThrow(Ex.class).when(mock).voidMethod();Verify — ตรวจว่า mock ถูกเรียก
java
verify(mock).method(arg); // เรียก 1 ครั้ง
verify(mock, times(3)).method(any());
verify(mock, never()).method(any());
verify(mock, atLeast(1)).method(any());
verify(mock, atMost(5)).method(any());
verifyNoInteractions(mock); // ไม่เคยถูกเรียกเลย
verifyNoMoreInteractions(mock); // ไม่มีการเรียกอื่นนอกจากที่ verify ไว้Argument Matchers
java
when(repo.findByEmail(anyString())).thenReturn(Optional.empty());
when(repo.findById(eq(1L))).thenReturn(Optional.of(new User(1L, "anna@x.com", "Anna")));
when(repo.save(any(User.class))).thenReturn(new User(1L, "anna@x.com", "Anna"));
// ⚠️ ถ้าใช้ matcher 1 ตัว → ทุก argument ต้องเป็น matcher
when(svc.process(eq(1), eq("x"))).thenReturn(result); // ✅ ต้องใช้ eq() ทุก argument เมื่อใช้ matcher อย่างน้อย 1 ตัว
// when(svc.process(1, eq("x"))).thenReturn(result); // ❌ ผสม literal กับ matcher → โยน InvalidUseOfMatchersExceptionArgument Captor
📝
@Captor(และ@Mock,@InjectMocks) จะถูก initialize อัตโนมัติก็ต่อเมื่อมี@ExtendWith(MockitoExtension.class)บน test class — ตามตัวอย่างที่แสดงใน section 15 ด้านบน ถ้าลืมใส่@ExtendWithfield จะเป็นnullและ test จะ fail
java
// ต้องมี @ExtendWith(MockitoExtension.class) บน class ด้วย — ไม่งั้น @Captor จะเป็น null
@Captor ArgumentCaptor<User> userCaptor;
verify(repo).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertThat(saved.email()).isEqualTo("anna@x.com");17. ⚠️ Mockito Pitfalls
| ❌ | ✅ |
|---|---|
| Mock data class (User, DTO) | ใช้ object จริง |
| Mock everything → test ไม่มี value | mock เฉพาะ I/O (DB, network) |
verify ทุก call | verify เฉพาะ key behavior |
| ใช้ matcher 1 ตัว, literal อีกตัว | ใช้ matcher ทุกตัว eq(...) |
| Test ที่ stub + verify เยอะมาก | refactor → ลด coupling |
Part 5: Logging — SLF4J + Logback
18. System.out.println() ไม่ใช่ logging
java
System.out.println("User registered: " + user.email());ปัญหา:
- ไม่มี level (debug/info/warn/error)
- ไม่มี timestamp / thread / class
- ปิดไม่ได้ใน production
- เขียนลง stdout เท่านั้น (ไม่ได้ไฟล์)
→ ใช้ logging framework
19. SLF4J + Logback — มาตรฐาน Java
SLF4J เป็น API (เราเรียกสิ่งนี้ในโค้ด) → Logback เป็น implementation จริง (ทำงานเบื้องหลัง)
SLF4J = APILogback = engine จริง (เร็ว, default ของ Spring Boot)
ทางเลือก implementation: Log4j2 (ดีพอกัน), java.util.logging (built-in แต่ไม่ flex)
ศัพท์ย่อในส่วนนี้: CVE = รหัสมาตรฐานระบุช่องโหว่ความปลอดภัย · RCE = ผู้โจมตีรันคำสั่งบน server จากระยะไกล (อันตรายสูงสุด) · JNDI = กลไก Java ดึง resource จาก server ภายนอก · LDAP = โปรโตคอลค้นหาข้อมูลผ่าน network · SCA = เครื่องมือตรวจช่องโหว่ใน dependency อัตโนมัติ
🔴 บทเรียนจาก Log4Shell (CVE-2021-44228, ธ.ค. 2021) — Log4j 2.x < 2.17.0 มีช่องโหว่ RCE (Remote Code Execution = ผู้โจมตีสั่ง execute โค้ดบน server ได้ทางไกล — อันตรายสุด ๆ) ผ่าน JNDI lookup (JNDI = Java Naming and Directory Interface — กลไก Java ที่ใช้ดึง resource จาก server ภายนอกเช่น LDAP ผ่าน network; Log4j2 เวอร์ชันเก่าอ่าน
${jndi:...}ใน log แล้วไปดึง class จาก server ที่โจมตีกำหนดได้) ใน log message (${jndi:ldap://...}) แค่ log ค่าที่ user ส่งมาก็โดน — เป็นบทเรียนสำคัญสำหรับทุก dev เลือก Log4j2:
- ต้องใช้ ≥ 2.17.1 (และตรวจ CVE-2021-45046, 45105, 44832 ตามมา)
- เปิด
-Dlog4j2.formatMsgNoLookups=trueหรือLOG4J_FORMAT_MSG_NO_LOOKUPS=trueตั้งแต่ตอน deploy- audit (ตรวจสอบ) dependency tree:
- macOS/Linux:
mvn dependency:tree | grep log4j- Windows:
mvn dependency:tree | findstr log4jหรือใช้ Git Bash/WSL- transitive dependency (library ของ library ที่เราใช้) จาก legacy lib (library เก่าที่ยังใช้อยู่) อาจดึง log4j เก่ามา
- ใช้ SCA tool (Software Composition Analysis — เครื่องมือตรวจ vulnerability ใน dependency อัตโนมัติ) เช่น Trivy, Grype, Snyk scan image ก่อน push
สำคัญ: CVE-2021-44228 (Log4Shell) เป็นช่องโหว่เฉพาะของ Log4j2 เท่านั้น — Logback ไม่ได้รับผลกระทบเลย เพราะ Logback ไม่มีฟีเจอร์ JNDI lookup ใน log message ผู้ที่ใช้ SLF4J + Logback (ค่า default ของ Spring Boot) ไม่จำเป็นต้องกังวลเรื่อง Log4Shell เลย — คำเตือนด้านบนใช้เฉพาะโปรเจกต์ที่เลือกสลับ implementation มาใช้ Log4j2 แทน Logback เท่านั้น
xml
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>💡 Spring Boot project:
spring-boot-starter(หรือspring-boot-starter-logging) รวม SLF4J + Logback มาให้อัตโนมัติ — ไม่ต้องเพิ่ม dependency เอง dependency ด้านบนสำหรับ standalone Java project
20. ใช้งาน
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UserService {
// private = ใช้ภายใน class เท่านั้น
// static = share ทุก instance ไม่ต้องสร้าง Logger ซ้ำทุกครั้งที่ new object
// final = ป้องกันการ reassign ภายหลัง
private static final Logger log = LoggerFactory.getLogger(UserService.class);
// 💡 ถ้าใช้ Lombok (library generate โค้ดซ้ำซากให้อัตโนมัติ — จะเจอใน Spring Boot):
// ใส่ `@Slf4j` บนคลาส แล้วลบบรรทัดนี้ — Lombok generate ให้
public void register(String email) {
log.info("Registering user: {}", email);
try {
// ...
log.debug("Validated email {}", email);
} catch (Exception e) {
log.error("Failed to register {}", email, e); // ⭐ exception ใส่หลังสุด
throw e;
}
}
}Log Levels
text
TRACE → DEBUG → INFO → WARN → ERROR
น้อย สำคัญ| Level | ใช้เมื่อ |
|---|---|
| TRACE | debug ละเอียดสุด — รายละเอียดเข้าออก method |
| DEBUG | debug ทั่วไป — variable, state |
| INFO | event สำคัญที่ทุก request เห็น — "user registered" |
| WARN | "เกือบจะ error" — fallback, retry |
| ERROR | error จริง — ต้องตามดู |
Production มัก set INFO + (จะ DEBUG เฉพาะ class ที่สงสัย)
21. ใช้ Placeholder {} — ไม่ใช่ +
เวลา log ให้ใช้ placeholder {} แทนการต่อ string ด้วย + — เพราะถ้าต่อด้วย + การประกอบ string เกิดขึ้นเสมอแม้ระดับ log นั้นถูกปิด (เปลือง CPU) ส่วน {} จะประกอบเฉพาะเมื่อจำเป็นต้อง log จริง:
java
// ❌ string concatenation — แม้ debug ปิด ก็ยัง concat
log.debug("user=" + user + " amount=" + amount);
// ✅ placeholder — concat แค่ตอนจะ log จริง
log.debug("user={} amount={}", user, amount);22. Logback Configuration
src/main/resources/logback.xml:
xml
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<!-- %logger{36} = แสดงชื่อ logger (ชื่อ class) ตัดให้ไม่เกิน 36 ตัวอักษร
ถ้าต้องการเห็น package ยาวขึ้น เพิ่มตัวเลข เช่น %logger{50} หรือ %logger{0} = เต็ม -->
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <!-- เก็บ 30 วัน -->
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<!-- ลด noise จาก library — ใส่ชื่อ package ของ library ที่ log เยอะเกินไป
ตัวอย่างนี้แสดงเทคนิค ถ้าไม่ใช้ Spring ให้เปลี่ยนเป็น package ของ library ที่ใช้จริง -->
<logger name="org.springframework" level="WARN"/>
<!-- root level -->
<root level="INFO">
<appender-ref ref="STDOUT"/>
<appender-ref ref="FILE"/>
</root>
</configuration>MDC — Mapped Diagnostic Context (correlation id ใน log)
correlation id (รหัสประจำ request — ใช้ติดตามว่า log หลายบรรทัดมาจาก request เดียวกัน)
ปัญหา: ใน web app ที่รับ 1000 request พร้อมกัน — log ปนกัน หา request ไหนยาก
MDC = thread-local context ที่ log appender ดึงไปใส่ทุกบรรทัด — เหมาะกับ requestId, userId, traceId
java
import org.slf4j.MDC;
public void handleRequest(HttpRequest req) {
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("userId", req.getUserId());
try {
log.info("Processing order");
orderService.process(); // log ใน chain นี้ทั้งหมดมี requestId เดียวกัน
} finally {
MDC.clear(); // สำคัญ! กัน leak ข้าม request (thread pool reuse)
}
}ใน logback.xml — เรียก %X{key}:
xml
<pattern>%d [%X{requestId}] %-5level %logger - %msg%n</pattern>ผลลัพธ์:
text
14:30:25 [a1b2-c3d4] INFO OrderService - Processing order
14:30:25 [a1b2-c3d4] DEBUG PaymentSvc - Charging card
14:30:26 [x9y8-z7w6] INFO OrderService - Processing order ← request อื่น⚠️ MDC + Virtual Threads (ข้ามได้ถ้ายังไม่รู้จัก virtual thread):
- MDC ใช้ ThreadLocal (ตัวแปรที่เก็บค่าแยกตาม thread) ภายใน — ถ้าไม่
MDC.clear()อาจ leak (ข้อมูล MDC ของ request หนึ่งหลุดไปให้ request อื่นเห็น) เพราะ thread pool reuse thread- ถ้าใช้ traditional thread pool ใน Spring Boot MDC ปกติใช้ได้ดี — แค่ต้อง
MDC.clear()ในfinallyเสมอ (ตามตัวอย่างด้านบน)- ถ้าใช้ virtual threads จริง (Java 21+) — Logback 1.5+ ช่วยเรื่อง compatibility/performance กับ virtual thread เท่านั้น ไม่ได้ทำให้ MDC propagate ข้าม thread อัตโนมัติ ยังต้องมีกลไก propagate context เอง เช่น Spring
TaskDecoratorหรือ copy ค่า MDC เข้า task ที่ submit ไปตรง ๆ
Structured Logging (JSON) — สำหรับ production
ใน production ที่ใช้เครื่องมือรวบรวม log จาก server หลาย ๆ ตัว เช่น:
- ELK (Elasticsearch + Logstash + Kibana — stack เก็บ ประมวลผล และแสดงผล log แบบ open-source)
- Loki (เก็บ log แบบ label-based ของ Grafana — เบากว่า ELK ใช้ storage น้อยกว่า)
- Datadog (บริการ cloud monitoring ครบวงจร — มีค่าใช้จ่ายรายเดือน)
— log เป็น JSON จะดึง field ค้นหาได้สะดวกกว่า plain text มาก:
pom.xml — เพิ่ม dependency:
xml
<!-- logstash-logback-encoder -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>logback.xml — เพิ่ม appender:
xml
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>แต่ละบรรทัดของ log เป็น JSON object — ฟิลด์ MDC, level, timestamp, message, stack trace มาเป็น key พร้อม query
22.5 Testcontainers — integration test ที่ใช้ DB จริง
ปัญหา: integration test ที่ใช้ H2/in-memory DB อาจไม่เจอ bug จาก dialect ของ Postgres/MySQL จริง
Testcontainers ลง Docker container ขึ้นมาสั้น ๆ ใน test → ใช้ DB จริง
⚠️ Prerequisite: ต้องติดตั้ง Docker Desktop และเปิดไว้ก่อนรัน test Docker = ตัวรันโปรแกรมใน container (สภาพแวดล้อมจำลองแยกจากเครื่องเรา) ดาวน์โหลด: https://www.docker.com/products/docker-desktop
xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<!-- Testcontainers integration สำหรับ JUnit 5 — ต่างจาก JUnit Jupiter ที่เพิ่มไว้ก่อนหน้า
ชื่อ artifactId "junit-jupiter" นี้เป็นของ Testcontainers (org.testcontainers) ไม่ใช่ JUnit -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>java
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@Test
void shouldSaveOrder() throws Exception {
// ใช้ DriverManager จาก java.sql (standard Java) — ไม่ต้องเพิ่ม dependency Spring
Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
OrderRepository repo = new OrderRepository(conn);
repo.save(new Order("123", 100));
assertThat(repo.findById("123")).isPresent();
}
}💡 ถ้าโปรเจกต์ใช้ Spring Boot (มี
spring-boot-starter-jdbc) สามารถใช้DriverManagerDataSourceจากorg.springframework.jdbc.datasourceแทนได้ — แต่ตัวอย่างด้านบนใช้java.sql.DriverManagerซึ่งเป็น standard Java ไม่ต้องเพิ่ม dependency ใด ๆ
มี container สำเร็จรูปสำหรับ: PostgreSQL, MySQL, Redis, Kafka, RabbitMQ, Elasticsearch, MongoDB, MinIO (S3), LocalStack (AWS), Selenium
💡 ใน Spring Boot 3.1+ ใช้
@ServiceConnection— Spring inject URL/credentials ให้อัตโนมัติ
22.6 ArchUnit — Test architecture rules
บังคับ "กฎสถาปัตยกรรม" ด้วย test เช่น "controller ห้ามเรียก repository ตรง ๆ"
xml
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>⚠️ หมายเหตุ Spring dependency: ตัวอย่างด้านล่างใช้
@Serviceจาก Spring — ดังนั้นต้องมี Spring บน classpath (เช่นspring-context) ด้วย ArchUnit เองไม่ต้องการ Spring แต่ rule ที่เช็ค@Serviceannotation ต้องการ Spring เพราะ annotation นั้นนิยามอยู่ในspring-contextถ้าใช้ pure Java project ที่ไม่มี Spring ให้เปลี่ยนไปตรวจ annotation ของตัวเองแทน หรือตรวจแค่ชื่อ class / package ได้เลยโดยไม่ต้องใช้ annotation ของ Springถ้าต้องการ compile ตัวอย่างด้านล่างตรง ๆ ให้เพิ่ม dependency นี้คู่กับ
archunit-junit5:xml<dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.8</version> <scope>test</scope> </dependency>
java
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import org.springframework.stereotype.Service; // ตัวอย่างนี้ตรวจว่า service class มี @Service (Spring)
// ถ้าใช้ pure Java ปรับ annotation ตามที่ใช้ในโปรเจกต์ตัวเอง
// (ต้องมี Spring dependency ใน classpath ด้วย)
@AnalyzeClasses(packages = "com.example")
class ArchitectureTest {
@ArchTest
static final ArchRule controllers_should_not_depend_on_repositories =
noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAPackage("..repository..");
@ArchTest
static final ArchRule services_should_be_annotated =
classes().that().resideInAPackage("..service..")
.and().haveSimpleNameEndingWith("Service")
.should().beAnnotatedWith(Service.class);
}ใช้เมื่อ team ใหญ่ — กัน drift (การเบี่ยงเบนไปจากโครงสร้างที่ตั้งใจ เช่น มีคนแอบเรียก repository ตรงจาก controller โดยลืมกฎ) ของ architecture โดยอัตโนมัติ ไม่ต้องพึ่ง code review
23. ⚠️ Logging Pitfalls
| ❌ | ✅ |
|---|---|
| Log password / token | ไม่ log password เลย — log แค่ userId หรือ mask: log.info("login user={}", userId) |
| Log inside hot loop ทุก iteration | log แค่ summary |
e.printStackTrace() | log.error("msg", e) |
Concat ด้วย + | placeholder {} |
| Log level ไม่ตรง — ERROR ที่ไม่ใช่ error | INFO/WARN ตามจริง |
| ไม่มี structured log (พิมพ์ free text) | ใช้ JSON encoder (logstash-logback-encoder) สำหรับ production |
24. Checkpoint
🛠️ Checkpoint 15.1 — Test แรก
สร้าง class BankAccount มี deposit, withdraw, balance → เขียน test ครอบคลุม:
- ฝากแล้ว balance เพิ่ม
- ถอนแล้ว balance ลด
- ถอนเกิน balance → throw
- ฝาก/ถอน 0 หรือลบ → throw
🛠️ Checkpoint 15.2 — Mock external
สร้าง OrderService ที่ใช้ PaymentGateway + InventoryService
เขียน test:
- ถ้า payment success + inventory พอ → order created
- ถ้า payment fail → ไม่หัก inventory
- ถ้า inventory ไม่พอ → ไม่ charge payment
🛠️ Checkpoint 15.3 — Logging
ใส่ SLF4J ใน checkpoint ก่อนหน้า → set log level เป็น DEBUG ใน dev, INFO ใน prod
ทำ logback.xml ที่เขียนทั้ง console + file rolling
💡 Hint — แยก log level ตาม environment: ใช้
<root level="${LOG_LEVEL:-INFO}">ในlogback.xmlแล้วตั้ง environment variableLOG_LEVEL=DEBUGตอน dev (เช่น ตอนรัน:LOG_LEVEL=DEBUG mvn exec:java) — production ไม่ต้องตั้งก็ได้ default เป็น INFO
25. สรุปบท
✅ Testing Pyramid — unit เยอะ (70%), integration (20%), e2e (10%)
✅ JUnit 5: @Test, @BeforeEach/AfterEach, @BeforeAll/AfterAll, AAA pattern
✅ @ParameterizedTest + @CsvSource / @ValueSource — test หลายชุดด้วย method เดียว
✅ AssertJ — fluent assertion อ่านง่ายกว่า JUnit assert
✅ Mockito — mock dependency (DB, email), when().thenReturn(), verify()
✅ Mock เฉพาะ I/O — data class ใช้ของจริง
✅ SLF4J + Logback = standard logging — เรียก SLF4J API, swap impl ได้
✅ ใช้ placeholder {} — อย่า concat ด้วย +
✅ Level ตามจริง: INFO = "ลูกค้าทำอะไร", ERROR = "ต้องสอบสวน"
🎉 จบเนื้อหาหลัก Java
หลังจากบทที่ 0-15 คุณมี:
- พื้นฐานภาษา (variable, control flow, OOP, generics, exception)
- Functional (lambda, stream, optional)
- Modern (record, sealed, pattern matching, virtual thread)
- Date/Time + I/O + Files
- Concurrency
- Networking + Regex + Crypto
- Annotation + Module + Build tool + Javadoc
- Testing + Logging
→ พร้อมขึ้น Spring Boot ได้เลย — ไม่ต้องรออ่านบท 16-19 ก่อน
📌 บท 16-19 (JVM Internals, Performance, Security, Advanced Patterns) เป็น advanced/อ้างอิง — ค่อยกลับมาอ่านเมื่อต้องการเพิ่มเติม หรือเมื่อทำงานกับ Spring Boot ไปสักพักแล้ว
← บทที่ 14 | บทที่ 16 → JVM Internals & GC | ขึ้น Spring Boot →