Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Unit Testing — JUnit 5 cho Java

Mở đầu — vì sao bài này quan trọng

Trong toàn bộ hành trình automation testing, có một sự thật mà nhiều bạn QA mới vào nghề hay bỏ qua: phần lớn bug rẻ nhất để bắt và sửa lại nằm ở tầng thấp nhất của kim tự tháp — tầng unit test. Một bug bị bắt ở unit test có thể được sửa trong vài phút, ngay trên máy dev, trước khi code kịp rời khỏi laptop. Cũng chính bug đó nếu lọt xuống tầng UI hay production, chi phí sửa có thể đắt gấp hàng chục, hàng trăm lần: reproduce khó, debug qua nhiều lớp, ảnh hưởng khách hàng thật.

Với hệ sinh thái Java — vốn cực kỳ phổ biến ở các ngân hàng, fintech, công ty gia công phần mềm tại Việt Nam như FPT Software, Viettel, VNPAY, MoMo, Techcombank — thì JUnit 5 (tên chính thức là JUnit Jupiter) là framework unit test tiêu chuẩn de-facto. Nếu bạn đang hoặc sắp làm SDET/Automation QA cho một team Java, JUnit 5 gần như chắc chắn nằm trong yêu cầu tuyển dụng.

Bài này tập trung riêng vào JUnit 5 dành cho Java: cách setup, viết test, dùng assertion, lifecycle, parameterized test, và những best practice giúp test của bạn "sạch" và dễ bảo trì. Chúng ta sẽ không lấn sang pytest (đã có bài riêng), không đi sâu vào test double/mock hay BDD/Cucumber (cũng có bài riêng). Ở đây, mục tiêu là bạn viết được một bộ unit test JUnit 5 vững vàng, chuyên nghiệp, và hiểu được tại sao mỗi thứ được thiết kế như vậy.

Khái niệm cốt lõi

Unit test là gì trong ngữ cảnh này

Unit test kiểm tra một "đơn vị" nhỏ nhất của code — thường là một method hoặc một class — một cách cô lập, nhanh (mili-giây), và không phụ thuộc vào database, network hay UI thật. Nếu test của bạn cần kết nối MySQL hay gọi API thật, đó không còn là unit test nữa. Unit test đúng nghĩa phải chạy được ngay cả khi bạn rút mạng.

JUnit 5 gồm ba phần

JUnit 5 không phải một khối đơn lẻ mà là tổ hợp của ba nhóm module:

  • JUnit Platform: nền tảng để chạy test trên JVM, là nơi Maven/Gradle và IDE cắm vào.
  • JUnit Jupiter: mô hình lập trình và API để viết test mới (đây là phần bạn dùng hằng ngày — các annotation @Test, @BeforeEach...).
  • JUnit Vintage: engine tương thích ngược để chạy test cũ viết bằng JUnit 3/4, hữu ích khi migrate dần dự án legacy.
Hiểu kiến trúc này giúp bạn không bối rối khi thấy nhiều artifact khác nhau trong file pom.xml.

Setup với Maven

Cách gọn nhất cho dự án mới là dùng junit-jupiter aggregator:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>

scopetest nghĩa là dependency này chỉ dùng khi chạy test, không đóng gói vào file jar production. Để Maven thực sự chạy test, bạn cần plugin Surefire đủ mới:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
</plugin>

Với Gradle thì tương đương là testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' kèm dòng test { useJUnitPlatform() }.

Anatomy của một test đầu tiên

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {

@Test void congHaiSoDuong() { Calculator calc = new Calculator(); int ketQua = calc.add(2, 3); assertEquals(5, ketQua, "2 cong 3 phai bang 5"); } }

Vài điểm quan trọng ngay từ ví dụ này: class và method test trong JUnit 5 không cần public (khác JUnit 4), tận dụng cấu trúc Arrange–Act–Assert (chuẩn bị dữ liệu, thực thi, kiểm chứng). Tham số cuối của assertion là message hiển thị khi fail — hãy luôn viết nó rõ ràng để đồng đội đọc log là hiểu ngay.

Các annotation lifecycle

JUnit 5 cho phép bạn móc code vào các thời điểm khác nhau trong vòng đời test:

  • @BeforeEach / @AfterEach: chạy trước/sau mỗi method test. Dùng để khởi tạo lại object, reset trạng thái, đảm bảo các test độc lập nhau.
  • @BeforeAll / @AfterAll: chạy một lần trước/sau toàn bộ class. Vì chạy ở mức class nên phải là static. Dùng cho tài nguyên nặng, tốn kém khi tạo.
  • @Disabled: tạm bỏ qua một test, kèm lý do — tốt hơn nhiều so với comment code lại rồi quên.

Assertions cốt lõi

Ngoài assertEquals, bạn sẽ dùng thường xuyên: assertTrue/assertFalse, assertNull/assertNotNull, assertThrows (kiểm chứng code ném đúng exception), và assertAll (gom nhiều assertion để chúng đều được chạy và báo lỗi cùng lúc thay vì dừng ở lỗi đầu tiên). assertThrows đặc biệt quan trọng vì kiểm tra hành vi lỗi chính là nơi bug thật hay ẩn nấp.

Parameterized Test — vũ khí chống lặp code

Thay vì viết mười method gần giống nhau chỉ khác dữ liệu đầu vào, bạn dùng @ParameterizedTest kết hợp @ValueSource, @CsvSource hoặc @MethodSource để chạy cùng logic với nhiều bộ dữ liệu. Đây là cách viết test gọn, dễ mở rộng, và là nền tảng cho tư duy data-driven ở tầng unit.

Tình huống thực tế

Ví dụ 1 — Fintech Hà Nội và cái bug làm tròn tiền

Một công ty fintech tại Hà Nội (khoảng 40 kỹ sư) phát triển ví điện tử, có method tinhPhiGiaoDich(long soTien) tính phí 0,5% nhưng tối thiểu 1.000đ. Ban đầu team chỉ test tay: nạp thử vài giao dịch, thấy đúng thì cho lên production. Ba tuần sau, kế toán phát hiện các giao dịch nhỏ dưới 200.000đ bị tính phí sai do lỗi làm tròn số kiểu double, gây lệch tổng cộng khoảng 3,2 triệu đồng — chưa nhiều về tiền nhưng là vấn đề nghiêm trọng về niềm tin và tuân thủ.

Sau sự cố, SDET của team viết một parameterized test bao trọn các ngưỡng biên:

@ParameterizedTest
@CsvSource({
    "1000, 1000",     // duoi nguong, thu phi toi thieu
    "200000, 1000",   // dung diem giao
    "500000, 2500",   // 0,5%
    "10000000, 50000" // giao dich lon
})
void tinhPhiDung(long soTien, long phiMongDoi) {
    assertEquals(phiMongDoi, phiService.tinhPhiGiaoDich(soTien));
}

Bài học: bug nằm ở điểm biên (boundary). Một parameterized test 4 dòng dữ liệu bắt được lỗi mà hàng chục lần test tay bỏ lọt. Và họ chuyển từ double sang BigDecimal — nhưng chính test mới là thứ chứng minh cái sửa đó đúng.

Ví dụ 2 — Công ty gia công và bài toán test cô lập

Một team gia công cho khách Nhật (theo mô hình phổ biến ở các công ty như FPT Software) có class OrderValidator. Ban đầu mỗi test tự new một object và tự set dữ liệu, dẫn tới 200 dòng lặp lại. Tệ hơn, vài test "vô tình" phụ thuộc vào thứ tự chạy: test B chỉ pass nếu test A chạy trước và để lại state trong một field static.

Reviewer người Nhật trả lại merge request với ghi chú: "Tests must be independent." Team refactor lại dùng @BeforeEach để khởi tạo lại OrderValidator sạch trước mỗi test, xóa mọi state static:

private OrderValidator validator;

@BeforeEach void setUp() { validator = new OrderValidator(); }

Bài học: unit test phải độc lập và chạy được theo bất kỳ thứ tự nào. @BeforeEach không chỉ để tránh lặp code, mà là để đảm bảo mỗi test bắt đầu từ trạng thái sạch. Test phụ thuộc thứ tự là một loại flaky test cực kỳ khó chịu.

Ví dụ 3 — Startup EdTech và assertThrows

Một startup edtech ở TP.HCM có method dangKyKhoaHoc(User user, Course course) phải ném KhoaHocDayException khi lớp đã đủ sĩ số. Dev viết logic nhưng "quên" nhánh khi lớp đầy, và test cũ chỉ kiểm tra trường hợp đăng ký thành công nên mọi thứ vẫn xanh. Đến mùa tuyển sinh cao điểm, hệ thống cho phép đăng ký vượt sĩ số một lớp thực hành có giới hạn 20 học viên — lên tới 27 người, gây quá tải phòng lab.

Sau đó họ bổ sung test cho hành vi lỗi:

@Test
void nemLoiKhiLopDaDay() {
    Course lopDay = taoLopVoiSoLuong(20, 20);
    KhoaHocDayException ex = assertThrows(
        KhoaHocDayException.class,
        () -> service.dangKyKhoaHoc(user, lopDay)
    );
    assertEquals("Lop da du si so", ex.getMessage());
}

Bài học: đừng chỉ test "happy path". Rất nhiều bug production nằm ở nhánh xử lý lỗi mà không ai test. assertThrows là công cụ để kiểm chứng chính xác loại exception và cả thông điệp lỗi.

Hướng dẫn từng bước

Dưới đây là quy trình để bạn đi từ con số 0 tới một bộ unit test JUnit 5 chạy được.

  • Chuẩn bị dự án Maven. Đảm bảo cấu trúc chuẩn: code chính ở src/main/java, code test ở src/test/java. JUnit 5 dựa vào quy ước này.
  • Thêm dependency và Surefire vào pom.xml như phần trên. Chạy mvn dependency:tree để chắc chắn junit-jupiter đã được kéo về.
  • Tạo class test đặt cùng package với class cần test (nhưng trong thư mục test), tên theo quy ước <TênClass>Test. Ví dụ test cho Calculator thì đặt tên CalculatorTest.
  • Viết test theo cấu trúc Arrange–Act–Assert. Mỗi test chỉ nên kiểm chứng một hành vi. Đặt tên method mô tả rõ hành vi, ví dụ traVeZeroKhiDanhSachRong thay vì test1.
  • Thêm assertion có message. Ưu tiên assertion cụ thể (assertEquals) hơn assertion chung chung (assertTrue), vì khi fail nó cho biết cả giá trị mong đợi lẫn giá trị thực.
  • Refactor phần lặp vào @BeforeEach. Khi thấy nhiều test khởi tạo cùng một object, đưa vào setup.
  • Gom các test biến thể thành @ParameterizedTest. Khi thấy nhiều test chỉ khác dữ liệu, chuyển sang @CsvSource hoặc @MethodSource.
  • Chạy test. Dùng mvn test để chạy toàn bộ, hoặc chạy trực tiếp trong IntelliJ IDEA / VS Code. Đọc kỹ output: JUnit 5 báo rõ test nào pass/fail và message của assertion.
  • Đưa vào thói quen chạy trước mỗi commit. Test chỉ có giá trị khi được chạy thường xuyên. Việc tích hợp vào pipeline sẽ được nói ở các bài CI/CD riêng — ở tầng của bạn bây giờ, hãy tập thói quen mvn test trước khi push.

Lỗi thường gặp & mẹo

  • Import nhầm org.junit.Test (JUnit 4) thay vì org.junit.jupiter.api.Test (JUnit 5). Đây là lỗi kinh điển. IDE gợi ý sai import khiến annotation @Test không được engine JUnit 5 nhận diện, test "biến mất" mà không báo lỗi. Luôn kiểm tra dòng import bắt đầu bằng org.junit.jupiter.
  • Để public thừa hoặc thiếu static. JUnit 5 không cần public cho class/method test. Nhưng @BeforeAll/@AfterAll bắt buộc static (trừ khi bạn đổi lifecycle sang PER_CLASS). Quên static sẽ khiến test không chạy.
  • Test phụ thuộc lẫn nhau qua field static. Như ví dụ 2, đây là nguồn gốc của flaky test. Nguyên tắc: mỗi test phải tự chuẩn bị dữ liệu của nó và không để lại "rác" cho test sau.
  • Test cả một thứ quá lớn. Nếu một method test có 5 assertion kiểm 5 hành vi khác nhau, khi fail bạn khó biết cái nào hỏng. Hãy tách nhỏ, hoặc dùng assertAll khi các assertion thật sự liên quan tới cùng một object.
  • Nhầm thứ tự tham số của assertEquals. Đúng là assertEquals(expected, actual) — giá trị mong đợi trước, thực tế sau. Đảo ngược sẽ khiến message lỗi gây hiểu nhầm.
  • Mẹo @DisplayName. Dùng @DisplayName("Tra ve 0 khi danh sach rong") để đặt tên hiển thị đẹp, có dấu, dễ đọc trong report — rất hữu ích khi trình bày với QA lead hay khách hàng.
  • Mẹo assert exception message có dấu tiếng Việt. Nếu message lỗi của bạn có dấu, hãy chắc chắn file source lưu ở UTF-8 và Maven cấu hình project.build.sourceEncoding là UTF-8, tránh so sánh chuỗi bị lệch do encoding.

Bài tập thực hành

  • Setup từ đầu: Tạo một dự án Maven mới, thêm dependency JUnit 5 và Surefire, viết một class StringUtils với method daoNguoc(String s) và viết ít nhất 3 test: chuỗi thường, chuỗi rỗng, và chuỗi null (kỳ vọng ném IllegalArgumentException, dùng assertThrows).
  • Lifecycle: Viết class GioHang (giỏ hàng) với các method themSanPham, tinhTong, xoaHet. Dùng @BeforeEach để tạo giỏ hàng mới trước mỗi test. Chứng minh rằng các test độc lập bằng cách chạy chúng nhiều lần theo thứ tự khác nhau (trong IntelliJ có thể bật chế độ random order).
  • Parameterized: Viết method laSoNguyenTo(int n) và dùng @CsvSource để kiểm chứng ít nhất 6 trường hợp gồm cả biên: 1, 2, 4, 17, 0, và một số âm.
  • Nâng cao: Lấy lại tình huống fintech ở ví dụ 1 — tự cài method tinhPhiGiaoDich bằng BigDecimal, rồi viết parameterized test bao các điểm biên. Thử cố tình cài sai bằng double để thấy test của bạn bắt được lỗi làm tròn.

Tóm tắt

JUnit 5 (Jupiter) là framework unit test tiêu chuẩn cho Java và là kỹ năng gần như bắt buộc nếu bạn làm QA automation trong hệ sinh thái Java tại Việt Nam. Những điều cần nhớ:

  • JUnit 5 gồm Platform, Jupiter và Vintage; bạn viết test hằng ngày bằng Jupiter với junit-jupiter trên Maven/Gradle, kèm Surefire đủ mới để test thực sự chạy.
  • Một test tốt theo cấu trúc Arrange–Act–Assert, chỉ kiểm một hành vi, có tên và message rõ ràng.
  • Các annotation lifecycle (@BeforeEach, @BeforeAll...) giúp test độc lập và không lặp code — độc lập là nguyên tắc tối quan trọng để tránh flaky test.
  • Đừng chỉ test happy path: dùng assertThrows để kiểm chứng hành vi lỗi, nơi bug thật hay ẩn.
  • @ParameterizedTest là vũ khí bắt bug ở điểm biên với ít code nhất.
Ba tình huống thực tế cho thấy cùng một thông điệp: unit test rẻ, nhanh, và bắt bug ngay tại gốc — miễn là bạn test đúng chỗ (biên, nhánh lỗi) và giữ cho chúng độc lập, sạch sẽ. Ở các bài sau, chúng ta sẽ xây trên nền này để đi tới test double, BDD, và tích hợp CI/CD.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi