JUnit Testing Basics (Arrange–Act–Assert)

บทนำ: ทำไมต้อง Unit Testing?

เมื่อเขียนโปรแกรม เราสามารถ run มันได้ แต่ “run ได้” ไม่ได้หมายความว่า “ทำงานถูก”:

java// ❌ ปัญหา: ไม่รู้ว่า code ถูกหรือไม่
public class Calculator {
    public int add(int a, int b) {
        return a + b;  // ← ถูกไหม?
    }
}

// เรา "test" ด้วยตาว่าดูเหมือนถูก แต่:
// - ถ้า add() ผิด เราไม่รู้
// - เมื่อแก้ code ที่อื่น add() อาจเสีย
// - ไม่มี proof ว่า code ถูก

Unit Testing = การเขียน code ที่ test code อื่น ว่าทำงานถูกต้อง

JUnit = Framework ยอดนิยมใน Java สำหรับ unit testing


Unit Test คืออะไร?

Unit Test = Test ที่ตรวจสอบ “unit” (ส่วนเล็ก) ของ code:

textUnit = หน่วยเล็กที่สุด ที่สามารถ test ได้
└─ Method (ที่สุด)
└─ Class
└─ Function

ตัวอย่างข้อความ Unit Test

textTest: When adding 2 + 3, result should be 5
Test: When adding -5 + 3, result should be -2
Test: When dividing by 0, should throw exception
Test: When creating user with null email, should throw exception

Arrange–Act–Assert Pattern: โครงสร้างของ Unit Test

โครงสร้างทั่วไป

text┌─────────────────────────────────────┐
│ Arrange (จัดเตรียม)                │
│ - สร้าง objects ที่ต้อง test       │
│ - ตั้งค่า initial state             │
├─────────────────────────────────────┤
│ Act (กระทำ)                        │
│ - เรียก method ที่ต้อง test        │
│ - ดำเนินการ operation ที่ต้อง test │
├─────────────────────────────────────┤
│ Assert (ยืนยัน)                    │
│ - ตรวจสอบผลลัพธ์                  │
│ - ยืนยันว่า ได้ผลที่คาดหวัง        │
└─────────────────────────────────────┘

ตัวอย่างที่ 1: JUnit Test พื้นฐาน

Code ที่จะ Test

javapublic class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return a / b;
    }
}

Test Code (Arrange–Act–Assert)

javaimport org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class CalculatorTest {
    
    // ==== TEST 1: Add ====
    @Test
    public void testAddPositiveNumbers() {
        // ARRANGE: Prepare objects and data
        Calculator calculator = new Calculator();
        int a = 5;
        int b = 3;
        
        // ACT: Execute the operation
        int result = calculator.add(a, b);
        
        // ASSERT: Verify the result
        assertEquals(8, result, "5 + 3 should equal 8");
    }
    
    // ==== TEST 2: Add with negative numbers ====
    @Test
    public void testAddNegativeNumbers() {
        // ARRANGE
        Calculator calculator = new Calculator();
        int a = -5;
        int b = -3;
        
        // ACT
        int result = calculator.add(a, b);
        
        // ASSERT
        assertEquals(-8, result, "-5 + -3 should equal -8");
    }
    
    // ==== TEST 3: Subtract ====
    @Test
    public void testSubtract() {
        // ARRANGE
        Calculator calculator = new Calculator();
        
        // ACT
        int result = calculator.subtract(10, 3);
        
        // ASSERT
        assertEquals(7, result);
    }
    
    // ==== TEST 4: Multiply ====
    @Test
    public void testMultiply() {
        // ARRANGE
        Calculator calculator = new Calculator();
        
        // ACT
        int result = calculator.multiply(4, 5);
        
        // ASSERT
        assertEquals(20, result);
    }
    
    // ==== TEST 5: Divide ====
    @Test
    public void testDivide() {
        // ARRANGE
        Calculator calculator = new Calculator();
        
        // ACT
        int result = calculator.divide(20, 4);
        
        // ASSERT
        assertEquals(5, result);
    }
    
    // ==== TEST 6: Divide by zero (Exception) ====
    @Test
    public void testDivideByZeroThrowsException() {
        // ARRANGE
        Calculator calculator = new Calculator();
        
        // ACT & ASSERT: Expect exception
        assertThrows(
            IllegalArgumentException.class,
            () -> calculator.divide(10, 0),
            "Should throw IllegalArgumentException when dividing by 0"
        );
    }
}

Output จาก JUnit (ถ้า run test):

text✓ testAddPositiveNumbers - PASSED
✓ testAddNegativeNumbers - PASSED
✓ testSubtract - PASSED
✓ testMultiply - PASSED
✓ testDivide - PASSED
✓ testDivideByZeroThrowsException - PASSED

Summary: 6 tests, 6 passed, 0 failed

ตัวอย่างที่ 2: More Complex Scenario – User Registration

Code ที่จะ Test

javapublic class User {
    private String email;
    private String password;
    private int age;
    
    public User(String email, String password, int age) 
        throws IllegalArgumentException {
        
        if (email == null || email.isEmpty()) {
            throw new IllegalArgumentException("Email cannot be empty");
        }
        
        if (!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email format");
        }
        
        if (password == null || password.length() < 6) {
            throw new IllegalArgumentException(
                "Password must be at least 6 characters"
            );
        }
        
        if (age < 18 || age > 120) {
            throw new IllegalArgumentException(
                "Age must be between 18 and 120"
            );
        }
        
        this.email = email;
        this.password = password;
        this.age = age;
    }
    
    public String getEmail() {
        return email;
    }
    
    public int getAge() {
        return age;
    }
    
    public boolean isAdult() {
        return age >= 18;
    }
}

Test Code (Multiple Scenarios)

javaimport org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("User Registration Tests")
public class UserTest {
    
    // ==== HAPPY PATH: Valid user ====
    @Test
    @DisplayName("Create user with valid data")
    public void testCreateValidUser() {
        // ARRANGE
        String email = "[email protected]";
        String password = "securepass123";
        int age = 25;
        
        // ACT
        User user = new User(email, password, age);
        
        // ASSERT
        assertEquals("[email protected]", user.getEmail());
        assertEquals(25, user.getAge());
        assertTrue(user.isAdult());
    }
    
    // ==== ERROR CASE: Empty email ====
    @Test
    @DisplayName("Email cannot be empty")
    public void testEmptyEmailThrowsException() {
        // ARRANGE
        String email = "";
        String password = "securepass123";
        int age = 25;
        
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> new User(email, password, age),
            "Should throw exception for empty email"
        );
    }
    
    // ==== ERROR CASE: Invalid email format ====
    @Test
    @DisplayName("Email must contain @")
    public void testInvalidEmailFormatThrowsException() {
        // ARRANGE
        String email = "johninvalid.com";  // Missing @
        String password = "securepass123";
        int age = 25;
        
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> new User(email, password, age)
        );
    }
    
    // ==== ERROR CASE: Password too short ====
    @Test
    @DisplayName("Password must be at least 6 characters")
    public void testPasswordTooShortThrowsException() {
        // ARRANGE
        String email = "[email protected]";
        String password = "short";  // Only 5 chars
        int age = 25;
        
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> new User(email, password, age)
        );
    }
    
    // ==== ERROR CASE: Age too young ====
    @Test
    @DisplayName("User must be at least 18 years old")
    public void testAgeTooYoungThrowsException() {
        // ARRANGE
        String email = "[email protected]";
        String password = "securepass123";
        int age = 17;  // Too young
        
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> new User(email, password, age),
            "Should throw exception for users under 18"
        );
    }
    
    // ==== ERROR CASE: Age too old ====
    @Test
    @DisplayName("Age cannot exceed 120")
    public void testAgeTooOldThrowsException() {
        // ARRANGE
        String email = "[email protected]";
        String password = "securepass123";
        int age = 150;  // Too old
        
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> new User(email, password, age)
        );
    }
    
    // ==== EDGE CASE: Minimum valid age ====
    @Test
    @DisplayName("User at exactly 18 years old should be accepted")
    public void testMinimumValidAge() {
        // ARRANGE
        String email = "[email protected]";
        String password = "securepass123";
        int age = 18;
        
        // ACT
        User user = new User(email, password, age);
        
        // ASSERT
        assertTrue(user.isAdult());
    }
}

Output:

text✓ Create user with valid data - PASSED
✓ Email cannot be empty - PASSED
✓ Email must contain @ - PASSED
✓ Password must be at least 6 characters - PASSED
✓ User must be at least 18 years old - PASSED
✓ Age cannot exceed 120 - PASSED
✓ User at exactly 18 years old should be accepted - PASSED

Summary: 7 tests, 7 passed, 0 failed

ตัวอย่างที่ 3: @BeforeEach & @AfterEach (Setup & Teardown)

ปัญหา: Repeating Setup

java// ❌ ไม่ดี: Setup ซ้ำกันทุก test
@Test
public void testCase1() {
    Calculator calculator = new Calculator();  // ← ซ้ำ
    // ...
}

@Test
public void testCase2() {
    Calculator calculator = new Calculator();  // ← ซ้ำ
    // ...
}

@Test
public void testCase3() {
    Calculator calculator = new Calculator();  // ← ซ้ำ
    // ...
}

วิธีแก้: @BeforeEach

javaimport org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

public class CalculatorTest {
    
    private Calculator calculator;
    
    // ✓ GOOD: Setup ก่อน test แต่ละตัว
    @BeforeEach
    public void setUp() {
        System.out.println("Setting up test...");
        calculator = new Calculator();
    }
    
    @AfterEach
    public void tearDown() {
        System.out.println("Cleaning up after test...");
        calculator = null;
    }
    
    @Test
    public void testAdd() {
        // calculator already created in setUp()
        int result = calculator.add(5, 3);
        assertEquals(8, result);
    }
    
    @Test
    public void testSubtract() {
        // calculator already created in setUp()
        int result = calculator.subtract(10, 3);
        assertEquals(7, result);
    }
    
    @Test
    public void testMultiply() {
        // calculator already created in setUp()
        int result = calculator.multiply(4, 5);
        assertEquals(20, result);
    }
}

// Output:
// Setting up test...
// [testAdd runs]
// Cleaning up after test...
// Setting up test...
// [testSubtract runs]
// Cleaning up after test...
// Setting up test...
// [testMultiply runs]
// Cleaning up after test...

ตัวอย่างที่ 4: Testing with Dependencies (Mocking)

Scenario: Order Service with Dependency

java// ==== Dependencies ====
public interface PaymentService {
    boolean processPayment(double amount);
}

public interface EmailService {
    void sendConfirmation(String email);
}

// ==== Code to Test ====
public class OrderService {
    private PaymentService paymentService;
    private EmailService emailService;
    
    public OrderService(PaymentService paymentService, EmailService emailService) {
        this.paymentService = paymentService;
        this.emailService = emailService;
    }
    
    public boolean placeOrder(String email, double amount) {
        // Try to process payment
        if (!paymentService.processPayment(amount)) {
            return false;  // Payment failed
        }
        
        // Send confirmation email
        emailService.sendConfirmation(email);
        return true;
    }
}

// ==== Test with Mock Objects ====
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

public class OrderServiceTest {
    
    @Test
    public void testPlaceOrderSuccessful() {
        // ARRANGE: Create mock objects
        PaymentService mockPaymentService = mock(PaymentService.class);
        EmailService mockEmailService = mock(EmailService.class);
        
        // Setup mock behavior
        when(mockPaymentService.processPayment(100.0))
            .thenReturn(true);
        
        OrderService orderService = new OrderService(
            mockPaymentService, 
            mockEmailService
        );
        
        // ACT
        boolean result = orderService.placeOrder("[email protected]", 100.0);
        
        // ASSERT
        assertTrue(result);
        
        // Verify that email was sent
        verify(mockEmailService).sendConfirmation("[email protected]");
        
        // Verify payment was attempted
        verify(mockPaymentService).processPayment(100.0);
    }
    
    @Test
    public void testPlaceOrderPaymentFailed() {
        // ARRANGE
        PaymentService mockPaymentService = mock(PaymentService.class);
        EmailService mockEmailService = mock(EmailService.class);
        
        // Setup mock to fail
        when(mockPaymentService.processPayment(50.0))
            .thenReturn(false);
        
        OrderService orderService = new OrderService(
            mockPaymentService,
            mockEmailService
        );
        
        // ACT
        boolean result = orderService.placeOrder("[email protected]", 50.0);
        
        // ASSERT
        assertFalse(result);
        
        // Email should NOT be sent
        verify(mockEmailService, never()).sendConfirmation(anyString());
    }
}

ตัวอย่างที่ 5: Real-World Test Suite

Test Coverage: UserRepository

javapublic class User {
    private int id;
    private String name;
    private String email;
    
    public User(int id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
    
    public int getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}

public class UserRepository {
    private List<User> users = new ArrayList<>();
    
    public void save(User user) {
        if (user == null) {
            throw new IllegalArgumentException("User cannot be null");
        }
        users.add(user);
    }
    
    public User findById(int id) {
        for (User user : users) {
            if (user.getId() == id) {
                return user;
            }
        }
        return null;
    }
    
    public List<User> findAll() {
        return new ArrayList<>(users);
    }
    
    public int count() {
        return users.size();
    }
    
    public void delete(int id) {
        users.removeIf(user -> user.getId() == id);
    }
}

// ==== Comprehensive Test Suite ====
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;

@DisplayName("UserRepository Tests")
public class UserRepositoryTest {
    
    private UserRepository repository;
    
    @BeforeEach
    public void setUp() {
        repository = new UserRepository();
    }
    
    @Test
    @DisplayName("Save user successfully")
    public void testSaveUser() {
        // ARRANGE
        User user = new User(1, "John", "[email protected]");
        
        // ACT
        repository.save(user);
        
        // ASSERT
        assertEquals(1, repository.count());
    }
    
    @Test
    @DisplayName("Cannot save null user")
    public void testSaveNullUserThrowsException() {
        // ACT & ASSERT
        assertThrows(
            IllegalArgumentException.class,
            () -> repository.save(null)
        );
    }
    
    @Test
    @DisplayName("Find user by ID")
    public void testFindById() {
        // ARRANGE
        User user = new User(1, "John", "[email protected]");
        repository.save(user);
        
        // ACT
        User found = repository.findById(1);
        
        // ASSERT
        assertNotNull(found);
        assertEquals("John", found.getName());
        assertEquals("[email protected]", found.getEmail());
    }
    
    @Test
    @DisplayName("Return null when user not found")
    public void testFindByIdNotFound() {
        // ACT
        User found = repository.findById(999);
        
        // ASSERT
        assertNull(found);
    }
    
    @Test
    @DisplayName("Find all users")
    public void testFindAll() {
        // ARRANGE
        repository.save(new User(1, "John", "[email protected]"));
        repository.save(new User(2, "Jane", "[email protected]"));
        repository.save(new User(3, "Bob", "[email protected]"));
        
        // ACT
        List<User> users = repository.findAll();
        
        // ASSERT
        assertEquals(3, users.size());
    }
    
    @Test
    @DisplayName("Delete user by ID")
    public void testDelete() {
        // ARRANGE
        repository.save(new User(1, "John", "[email protected]"));
        repository.save(new User(2, "Jane", "[email protected]"));
        
        // ACT
        repository.delete(1);
        
        // ASSERT
        assertEquals(1, repository.count());
        assertNull(repository.findById(1));
        assertNotNull(repository.findById(2));
    }
    
    @Test
    @DisplayName("Empty repository count is 0")
    public void testEmptyRepositoryCount() {
        // ACT & ASSERT
        assertEquals(0, repository.count());
    }
}

Common JUnit Assertions

java// Equality
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);

// Null checks
assertNull(object);
assertNotNull(object);

// Boolean
assertTrue(condition);
assertFalse(condition);

// Exception
assertThrows(ExceptionClass.class, () -> { /* code */ });

// Identical objects
assertSame(object1, object2);
assertNotSame(object1, object2);

// Collections
assertTrue(list.contains(element));
assertEquals(3, list.size());

// String
assertTrue(string.startsWith("prefix"));
assertTrue(string.contains("substring"));

Best Practices: Unit Testing Checklist

text✓ DO:
  ☑ Test one thing per test method
  ☑ Use descriptive test names
  ☑ Use Arrange-Act-Assert pattern
  ☑ Test both happy path and error cases
  ☑ Use @BeforeEach for common setup
  ☑ Test edge cases (0, null, empty, max value)
  ☑ Keep tests fast
  ☑ Keep tests independent (no dependencies between tests)
  ☑ Test public methods only
  ☑ Use assertions meaningful with messages

✗ DON'T:
  ☐ Test multiple things in one test
  ☐ Have unclear test names
  ☐ Use System.out.println for assertions
  ☐ Only test happy path
  ☐ Copy-paste setup code
  ☐ Test private methods (test public behavior)
  ☐ Sleep/wait in tests (makes them slow)
  ☐ Make tests depend on execution order
  ☐ Skip edge cases

สรุป

JUnit Testing (Arrange–Act–Assert) ไม่ใช่แค่ “run code เพื่อดู output” แต่เป็นการ automated verification ว่าโปรแกรมทำงานถูกต้อง:

ประโยชน์ของ Unit Testing:

  • Confidence – รู้ว่า code ทำงานถูก
  • Regression Prevention – ตรวจหา bug ที่เกิดจากการแก้ code
  • Documentation – test แสดงว่า code ควร ทำงานยังไง
  • Refactoring Safety – แก้ code โดยไม่กลัว “ทำลาย”
  • Early Bug Detection – พบปัญหาตั้งแต่ development ไม่ใช่ production

Arrange–Act–Assert pattern:

  • Arrange → Setup test data
  • Act → Execute the code
  • Assert → Verify the result

เมื่อใช้ Unit Testing อย่างถูกต้อง:

  • Bug ลด – พบและแก้ตั้งแต่แรก
  • Quality เพิ่ม – code ที่มี test มีคุณภาพ
  • Maintenance ง่าย – แก้ code โดยมั่นใจ
  • Development เร็ว – ไม่ต้อง manual test ทุกที่

Unit Testing คือ “safety net” ของโปรแกรมเมอร์ – มันคอยจับ bug ให้ก่อนมันลงไปถูก user