บทนำ: ทำไมต้อง 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
