Skip to content

บทที่ 15 — Testing + Logging (Pure Java)

← บทที่ 14 | สารบัญ | บทที่ 16 →

📓 โซนอ้างอิง—เปิดตอนต้องใช้ (บท 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 ก่อน deployCI (Continuous Integration) run test อัตโนมัติ
Bug หลัง deploy = ตื่นตกใจแก้กลางดึกbug ส่วนใหญ่ถูกจับก่อน deploy — เจอ bug ใหม่ก็เขียน test กันพังซ้ำ

ไม่ใช่ "เขียน test เพื่อให้ test ผ่าน" — เขียนเพื่อ document พฤติกรรมที่ถูก


2. ประเภท test

ประเภทscope (ขอบเขต)เร็ว/ช้าสัดส่วน
Unit1 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 test

5. 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 เล็ก ๆ) ใช้ส่งค่าระหว่าง beforeEachafterEach:

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 → โยน InvalidUseOfMatchersException

Argument Captor

📝 @Captor (และ @Mock, @InjectMocks) จะถูก initialize อัตโนมัติก็ต่อเมื่อมี @ExtendWith(MockitoExtension.class) บน test class — ตามตัวอย่างที่แสดงใน section 15 ด้านบน ถ้าลืมใส่ @ExtendWith field จะเป็น 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 ไม่มี valuemock เฉพาะ I/O (DB, network)
verify ทุก callverify เฉพาะ 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 = API
Logback = 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ใช้เมื่อ
TRACEdebug ละเอียดสุด — รายละเอียดเข้าออก method
DEBUGdebug ทั่วไป — variable, state
INFOevent สำคัญที่ทุก request เห็น — "user registered"
WARN"เกือบจะ error" — fallback, retry
ERRORerror จริง — ต้องตามดู

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 ที่เช็ค @Service annotation ต้องการ 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 ทุก iterationlog แค่ summary
e.printStackTrace()log.error("msg", e)
Concat ด้วย +placeholder {}
Log level ไม่ตรง — ERROR ที่ไม่ใช่ errorINFO/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 variable LOG_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 →