- Hiểu vì sao test tiết kiệm thời gian chứ không phải tốn thêm.
- Viết test với JUnit 5: assertion, vòng đời, nhóm test.
- Dùng parameterized test để chạy một test với nhiều bộ dữ liệu.
- Khẳng định ngoại lệ bằng
assertThrows. - Dùng Mockito để thay thế phụ thuộc bên ngoài.
- Hiểu độ phủ test — và vì sao 100% không phải mục tiêu.
- Nắm vòng đời Maven và cấu trúc
pom.xmlđầy đủ. - Đóng gói fat JAR chạy được bằng
java -jar. - Áp dụng checklist chất lượng 12 mục.
- Xây một ứng dụng hoàn chỉnh ghép cả 13 chương.
1. Vì sao viết test
Test không phải chi phí — nó là khoản đầu tư trả lãi ngay
Lập luận thường gặp: "viết test tốn thêm thời gian". Nhưng hãy so sánh với quy trình không có test: sửa code → khởi động ứng dụng → đăng nhập → bấm qua năm màn hình → kiểm tra kết quả bằng mắt. Mỗi vòng mất 2-3 phút, và bạn lặp nó hàng chục lần một ngày.
Với test tự động, vòng lặp đó còn vài giây — và nó kiểm tra tất cả các trường hợp, không chỉ cái bạn nhớ ra. Điểm hoà vốn thường đến ngay trong ngày đầu tiên.
Lợi ích lớn hơn nữa: bạn dám refactor. Không có test, mọi thay đổi đều đáng sợ, nên code xấu tồn tại mãi. Có test, bạn cải thiện thiết kế mà không lo phá vỡ thứ gì.
| Loại test | Phạm vi | Tốc độ | Tỉ lệ nên có |
|---|---|---|---|
| Unit | Một class/method, mọi phụ thuộc đều giả lập | Mili giây | ~70% |
| Integration | Nhiều thành phần thật (DAO + database) | Giây | ~20% |
| End-to-end | Toàn hệ thống qua giao diện thật | Phút | ~10% |
Tỉ lệ trên gọi là kim tự tháp test: nhiều test nhanh ở đáy, ít test chậm ở đỉnh. Đảo ngược tỉ lệ này (nhiều E2E, ít unit) tạo ra bộ test chạy hàng chục phút và hay hỏng vì lý do vặt — nhóm phát triển sẽ dần bỏ qua nó.
2. JUnit 5
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Lớp PhanSo")
class PhanSoTest {
@Test
@DisplayName("cộng hai phân số cùng mẫu")
void congCungMau() {
// ⭐ Mẫu ARRANGE - ACT - ASSERT (chuẩn bị - thực hiện - khẳng định)
PhanSo a = new PhanSo(1, 4); // arrange
PhanSo b = new PhanSo(1, 4);
PhanSo kq = a.cong(b); // act
assertEquals(new PhanSo(1, 2), kq); // assert
}
@Test
void mauSoBangKhongThiNemLoi() {
ArithmeticException e = assertThrows(
ArithmeticException.class,
() -> new PhanSo(1, 0)
);
assertTrue(e.getMessage().contains("mẫu số"));
}
@Test
void tuRutGonKhiTao() {
PhanSo p = new PhanSo(4, 8);
// ⭐ assertAll: chạy HẾT mọi khẳng định rồi báo cáo cùng lúc
assertAll("phân số đã rút gọn",
() -> assertEquals(1, p.tuSo()),
() -> assertEquals(2, p.mauSo()),
() -> assertEquals("1/2", p.toString())
);
}
@Nested
@DisplayName("khi chia")
class KhiChia {
@Test void chiaBinhThuong() { ... }
@Test void chiaChoKhong() { ... }
}
}
| Assertion | Kiểm tra |
|---|---|
assertEquals(mongDoi, thucTe) | Bằng nhau — thứ tự tham số quan trọng khi đọc báo lỗi |
assertNotEquals | Khác nhau |
assertTrue / assertFalse | Điều kiện boolean |
assertNull / assertNotNull | Null hay không |
assertSame / assertNotSame | Cùng object (==) |
assertArrayEquals | Mảng giống nhau từng phần tử |
assertIterableEquals | Collection giống nhau |
assertThrows(X.class, …) | Ném đúng loại ngoại lệ ⭐ |
assertDoesNotThrow(…) | Không ném gì |
assertAll(…) | Chạy hết mọi khẳng định rồi báo cáo chung ⭐ |
assertTimeout(dur, …) | Chạy xong trong thời hạn |
fail("lý do") | Ép thất bại — dùng cho nhánh không nên tới |
class VongDoiTest {
@BeforeAll // MỘT LẦN trước mọi test — phải static
static void khoiTaoChung() { System.out.println("1. Một lần"); }
@BeforeEach // TRƯỚC MỖI test — nơi tạo dữ liệu sạch
void chuanBi() { System.out.println("2. Mỗi test"); }
@Test void testA() { }
@Test void testB() { }
@AfterEach
void donDep() { System.out.println("3. Sau mỗi test"); }
@AfterAll
static void donDepChung() { System.out.println("4. Một lần cuối"); }
}
// ⚠️ JUnit tạo một INSTANCE MỚI cho MỖI test method — để chúng độc lập.
// Đó là lý do @BeforeAll phải static.
// --- Các annotation hữu ích khác ---
@Disabled("chờ sửa bug #123") // tạm bỏ qua, có ghi lý do
@Tag("cham") // gắn nhãn để lọc khi chạy
@Timeout(5) // thất bại nếu quá 5 giây
@RepeatedTest(10) // chạy 10 lần (tìm bug ngẫu nhiên)
@TempDir Path thuMucTam; // ⭐ thư mục tạm tự dọn sau test
@EnabledOnOs(OS.LINUX) // chỉ chạy trên hệ điều hành cụ thể
3. Parameterized test
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
// ❌ Lặp lại — sáu test gần như giống hệt nhau
@Test void test1() { assertTrue(SoNguyenTo.la(2)); }
@Test void test2() { assertTrue(SoNguyenTo.la(3)); }
// ...
// ✅ Một test, nhiều bộ dữ liệu
@ParameterizedTest(name = "{0} là số nguyên tố")
@ValueSource(ints = {2, 3, 5, 7, 11, 13, 97})
void laSoNguyenTo(int n) {
assertTrue(SoNguyenTo.la(n));
}
// Nhiều tham số qua CSV
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"5, 5, 10",
"-1, 1, 0",
"0, 0, 0"
})
void congDung(int a, int b, int mongDoi) {
assertEquals(mongDoi, MayTinh.cong(a, b));
}
// Từ tệp CSV — hợp với bộ dữ liệu lớn
@ParameterizedTest
@CsvFileSource(resources = "/du_lieu_test.csv", numLinesToSkip = 1)
void tuTep(String dauVao, String mongDoi) { ... }
// Từ enum
@ParameterizedTest
@EnumSource(TrangThai.class)
void moiTrangThaiCoMoTa(TrangThai tt) {
assertNotNull(tt.getMoTa());
}
// ⭐ Từ method — linh hoạt nhất, dùng được object phức tạp
@ParameterizedTest
@MethodSource("duLieuSinhVien")
void xepLoaiDung(SinhVien sv, String mongDoi) {
assertEquals(mongDoi, sv.xepLoai());
}
static Stream<Arguments> duLieuSinhVien() {
return Stream.of(
Arguments.of(new SinhVien("A", 9.5), "Giỏi"),
Arguments.of(new SinhVien("B", 7.0), "Khá"),
Arguments.of(new SinhVien("C", 4.0), "Yếu")
);
}
4. Mockito — thay thế phụ thuộc
<!-- pom.xml -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.15.2</version>
<scope>test</scope>
</dependency>
import org.mockito.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SinhVienServiceTest {
@Mock SinhVienDao dao; // object giả, mọi method trả mặc định
@Mock EmailService email;
@InjectMocks SinhVienService service; // tự tiêm hai mock trên vào
@Test
void timThayThiTraVeSinhVien() {
// ① Dạy mock trả về gì
SinhVien sv = new SinhVien(1L, "SV001", "An");
when(dao.timTheoId(1L)).thenReturn(Optional.of(sv));
// ② Gọi code thật
SinhVien kq = service.layBatBuoc(1L);
// ③ Khẳng định kết quả
assertEquals("An", kq.hoTen());
// ④ Khẳng định TƯƠNG TÁC
verify(dao).timTheoId(1L); // đã gọi đúng một lần
verify(dao, never()).xoa(anyLong()); // chưa từng gọi xoá
verifyNoInteractions(email); // không đụng tới email
}
@Test
void khongThayThiNemLoi() {
when(dao.timTheoId(anyLong())).thenReturn(Optional.empty());
assertThrows(KhongTimThayException.class,
() -> service.layBatBuoc(999L));
}
@Test
void bắtDuocThamSoTruyenVao() {
ArgumentCaptor<SinhVien> bat = ArgumentCaptor.forClass(SinhVien.class);
service.dangKy("An", "an@example.com");
verify(dao).them(bat.capture());
assertEquals("An", bat.getValue().hoTen()); // kiểm tra object đã tạo
}
@Test
void mockNemNgoaiLe() {
when(dao.timTheoId(anyLong()))
.thenThrow(new DataAccessException("Mất kết nối", null));
assertThrows(DataAccessException.class, () -> service.layBatBuoc(1L));
// ⭐ Test được kịch bản mà database thật rất khó tái hiện
}
}
Dùng mock cho: database, API bên ngoài, dịch vụ gửi email/SMS, đồng hồ hệ thống, sinh số ngẫu nhiên, hệ tệp — những thứ chậm, không ổn định, hoặc có tác dụng phụ thật.
Không mock: object giá trị (record, String, List), class bạn đang test, và class thuộc thư viện chuẩn. Mock quá nhiều khiến test chỉ kiểm tra chính các mock chứ không kiểm tra logic thật.
Dấu hiệu thiết kế xấu: nếu phải mock năm thứ để test một method, có lẽ method đó làm quá nhiều việc. Test khó viết thường là phản hồi về thiết kế, không phải vấn đề của test.
5. Độ phủ test
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution>
<id>report</id><phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
// $ mvn test
// → target/site/jacoco/index.html
int chia(int a, int b) { return a / b; }
@Test
void testChia() {
assertEquals(2, chia(4, 2)); // ✅ độ phủ dòng: 100%
}
// Nhưng chia(1, 0) vẫn ném ArithmeticException — chưa hề được test!
Độ phủ chỉ đo dòng nào đã chạy, không đo trường hợp nào đã được kiểm tra. Nó hữu ích để tìm vùng chưa test, không phải để làm mục tiêu.
Đặt độ phủ làm KPI dẫn tới hành vi tệ: người ta viết test gọi method mà không khẳng định gì, chỉ để con số đẹp. Ngưỡng thực dụng: 70-80%, và tập trung vào logic nghiệp vụ chứ không phải getter/setter.
Câu hỏi tốt hơn "độ phủ bao nhiêu": "Nếu tôi cố tình làm hỏng một dòng logic, có test nào đỏ không?" Đó là ý tưởng của mutation testing (thư viện PIT).
6. Maven — vòng đời và cấu hình
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.vidu</groupId>
<artifactId>quan-ly-thu-vien</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>com.vidu.thuvien.Main</main.class>
</properties>
<dependencies>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.47.1.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope> <!-- chỉ dùng khi test -->
</dependency>
</dependencies>
<build><plugins>
<!-- ⭐ Fat JAR: gộp mọi dependency vào một tệp chạy được -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions><execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration><transformers>
<transformer implementation="...ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
</transformers></configuration>
</execution></executions>
</plugin>
</plugins></build>
</project>
| Lệnh Maven | Làm gì |
|---|---|
mvn clean | Xoá target/ |
mvn compile | Biên dịch src/main/java |
mvn test | Biên dịch + chạy test |
mvn package | + đóng gói JAR |
mvn install | + cài vào kho cục bộ ~/.m2 |
mvn clean package -DskipTests | Build nhanh, bỏ test |
mvn dependency:tree | Xem cây phụ thuộc — công cụ gỡ xung đột phiên bản |
mvn versions:display-dependency-updates | Kiểm tra bản mới |
mvn test -Dtest=PhanSoTest | Chạy một lớp test |
scope | Có khi biên dịch | Có khi test | Đóng gói vào JAR | Ví dụ |
|---|---|---|---|---|
compile (mặc định) | ✅ | ✅ | ✅ | Thư viện dùng chính |
test | ❌ | ✅ | ❌ | JUnit, Mockito |
provided | ✅ | ✅ | ❌ | Servlet API (server cung cấp) |
runtime | ❌ | ✅ | ✅ | Driver JDBC |
$ mvn clean package
[INFO] Tests run: 47, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/quan-ly-thu-vien-1.0.0.jar
# Chạy được trên bất kỳ máy nào có Java 21 — không cần cài gì thêm
# Kiểm tra fat JAR đã gộp dependency chưa
$ jar tf target/quan-ly-thu-vien-1.0.0.jar | grep hikari | head -3
7. Dự án — Hệ thống quản lý thư viện
quan-ly-thu-vien/
├── pom.xml
├── README.md
├── src/main/
│ ├── java/com/vidu/thuvien/
│ │ ├── Main.java // điểm vào CLI
│ │ ├── model/ // Chương 5: record, enum, sealed
│ │ │ ├── Sach.java // record
│ │ │ ├── DocGia.java // record
│ │ │ ├── PhieuMuon.java // record
│ │ │ └── TrangThai.java // enum có hành vi
│ │ ├── dao/ // Chương 13: JDBC
│ │ │ ├── SachDao.java // interface
│ │ │ ├── SachDaoJdbc.java // cài đặt
│ │ │ └── Db.java // HikariCP
│ │ ├── service/ // nghiệp vụ + giao dịch
│ │ │ └── MuonTraService.java
│ │ ├── exception/ // Chương 6: cây lỗi
│ │ │ ├── ThuVienException.java
│ │ │ └── HetSachException.java
│ │ └── cli/ // Chương 3: menu, Scanner
│ │ └── Menu.java
│ └── resources/
│ ├── schema.sql
│ └── logback.xml
└── src/test/java/com/vidu/thuvien/
├── model/SachTest.java // unit test thuần
├── service/MuonTraServiceTest.java // với Mockito
└── dao/SachDaoJdbcTest.java // integration, SQLite trong bộ nhớ
package com.vidu.thuvien.model;
public record Sach(Long id, String isbn, String tieuDe,
String tacGia, int namXb, int soLuong) {
// Compact constructor — kiểm tra hợp lệ (Chương 5)
public Sach {
Objects.requireNonNull(isbn, "ISBN không được null");
Objects.requireNonNull(tieuDe, "Tiêu đề không được null");
if (soLuong < 0)
throw new IllegalArgumentException("Số lượng không âm: " + soLuong);
if (namXb < 1450 || namXb > Year.now().getValue() + 1)
throw new IllegalArgumentException("Năm xuất bản không hợp lệ: " + namXb);
tieuDe = tieuDe.trim();
}
public boolean conHang() { return soLuong > 0; }
// Factory cho sách mới (chưa có id)
public static Sach moi(String isbn, String tieuDe,
String tacGia, int namXb, int soLuong) {
return new Sach(null, isbn, tieuDe, tacGia, namXb, soLuong);
}
}
public class MuonTraService {
private static final Logger log = LoggerFactory.getLogger(MuonTraService.class);
private static final int SO_NGAY_MUON = 14;
private static final long PHAT_MOI_NGAY = 5_000L;
private final DataSource ds;
public MuonTraService(DataSource ds) { this.ds = ds; }
/** Mượn sách — MỘT giao dịch, hoặc thành công hoàn toàn hoặc không gì cả. */
public long muonSach(long sachId, long docGiaId) {
try (Connection c = ds.getConnection()) { // Chương 11: AutoCloseable
c.setAutoCommit(false); // Chương 13: giao dịch
try {
// ① Giảm số lượng — để DATABASE kiểm tra nguyên tử
try (PreparedStatement ps = c.prepareStatement(
"UPDATE sach SET so_luong = so_luong - 1 " +
"WHERE id = ? AND so_luong > 0")) {
ps.setLong(1, sachId);
if (ps.executeUpdate() == 0)
throw new HetSachException(sachId); // Chương 6
}
// ② Tạo phiếu mượn, lấy id tự sinh
long phieuId;
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO muon_tra (sach_id, doc_gia_id, ngay_muon, ngay_hen_tra) " +
"VALUES (?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) {
LocalDate homNay = LocalDate.now();
ps.setLong(1, sachId);
ps.setLong(2, docGiaId);
ps.setObject(3, homNay);
ps.setObject(4, homNay.plusDays(SO_NGAY_MUON));
ps.executeUpdate();
try (ResultSet k = ps.getGeneratedKeys()) {
k.next();
phieuId = k.getLong(1);
}
}
c.commit();
log.info("Độc giả {} mượn sách {}, phiếu {}", docGiaId, sachId, phieuId);
return phieuId;
} catch (SQLException | RuntimeException e) {
c.rollback(); // hoàn tác TẤT CẢ
throw e;
} finally {
c.setAutoCommit(true); // khôi phục cho pool
}
} catch (SQLException e) {
throw new ThuVienException("Không mượn được sách " + sachId, e);
}
}
/** Tính tiền phạt — hàm THUẦN, dễ test nhất trong cả service. */
public static long tinhPhat(LocalDate henTra, LocalDate traThucTe) {
long soNgayTre = ChronoUnit.DAYS.between(henTra, traThucTe);
return soNgayTre > 0 ? soNgayTre * PHAT_MOI_NGAY : 0L;
}
}
// ---------- ① UNIT: logic thuần, không phụ thuộc gì ----------
class TinhPhatTest {
@ParameterizedTest(name = "trễ {2} ngày → phạt {3}đ")
@CsvSource({
"2026-08-01, 2026-08-01, 0, 0", // đúng hạn
"2026-08-01, 2026-07-30, -2, 0", // trả sớm
"2026-08-01, 2026-08-04, 3, 15000" // trễ 3 ngày
})
void tinhPhatDung(LocalDate hen, LocalDate tra, int tre, long phat) {
assertEquals(phat, MuonTraService.tinhPhat(hen, tra));
}
}
// ---------- ② UNIT với MOCK: service không cần database ----------
@ExtendWith(MockitoExtension.class)
class SachServiceTest {
@Mock SachDao dao;
@InjectMocks SachService service;
@Test
void khongTimThayThiNemLoi() {
when(dao.timTheoId(anyLong())).thenReturn(Optional.empty());
assertThrows(KhongTimThayException.class, () -> service.layBatBuoc(1L));
}
}
// ---------- ③ INTEGRATION: database THẬT trong bộ nhớ ----------
class SachDaoJdbcTest {
private DataSource ds;
private SachDao dao;
@BeforeEach
void chuanBi() throws Exception {
var cf = new HikariConfig();
cf.setJdbcUrl("jdbc:sqlite::memory:"); // ⭐ DB trong RAM, mỗi test một cái mới
ds = new HikariDataSource(cf);
try (var c = ds.getConnection(); var st = c.createStatement()) {
st.execute(Files.readString(Path.of("src/main/resources/schema.sql")));
}
dao = new SachDaoJdbc(ds);
}
@Test
void themRoiTimLaiDuoc() {
long id = dao.them(Sach.moi("978-1", "Java", "Gosling", 2024, 5));
assertTrue(dao.timTheoId(id).isPresent());
assertEquals("Java", dao.timTheoId(id).orElseThrow().tieuDe());
}
@Test
void isbnTrungThiNemLoi() {
dao.them(Sach.moi("978-1", "A", "X", 2024, 1));
assertThrows(DataAccessException.class,
() -> dao.them(Sach.moi("978-1", "B", "Y", 2024, 1)));
}
}
8. Checklist chất lượng — 12 mục
| # | Hạng mục | Kiểm tra bằng | Chương |
|---|---|---|---|
| 1 | Cấu trúc Maven chuẩn, pom.xml đầy đủ | mvn clean package chạy được | 1, 14 |
| 2 | Fat JAR chạy bằng java -jar | Thử trên máy khác | 14 |
| 3 | Không có SQL nối chuỗi | grep -rn "\" + " --include=*.java | 13 |
| 4 | Mọi AutoCloseable trong try-with-resources | SpotBugs / rà soát code | 6, 11, 13 |
| 5 | Không nuốt ngoại lệ, không printStackTrace() | grep -rn "printStackTrace" | 6 |
| 6 | Tiền dùng BigDecimal/long, ngày dùng java.time | Rà soát model | 2, 13 |
| 7 | Mọi tệp văn bản chỉ định UTF-8 | grep -rn "new FileReader\|new FileWriter" | 11 |
| 8 | Không có mật khẩu trong mã nguồn | gitleaks detect | 13 |
| 9 | Dùng logging, không System.out để gỡ lỗi | grep -rn "System.out" src/main/ | 6, 14 |
| 10 | Test cho logic nghiệp vụ, độ phủ > 70% | mvn test + JaCoCo | 14 |
| 11 | Không cảnh báo khi biên dịch | mvn compile -Dmaven.compiler.showWarnings=true | 9 |
| 12 | README có: cài đặt, ví dụ dùng, cấu hình | Đưa cho người khác đọc và làm theo | — |
Bài tập — 5 dự án
Dự án A — Hoàn thiện hệ thống thư viện
Xây dựng đầy đủ ứng dụng đã phác thảo ở mục 7:
- Ba model bằng
record, mộtenumcó hành vi cho trạng thái phiếu mượn. - DAO cho ba bảng, dùng HikariCP với SQLite.
MuonTraServicevới giao dịch đầy đủ; tính tiền phạt.- CLI menu vòng lặp, không sập với bất kỳ input nào.
- Báo cáo bằng Stream: top sách mượn nhiều nhất, độc giả quá hạn, tỉ lệ sách đang được mượn.
- Xuất báo cáo ra CSV và JSON.
- Ít nhất 30 test đủ ba tầng; độ phủ > 70%.
- Đóng gói fat JAR, viết README.
Gợi ý
Bắt đầu từ schema.sql và các record — model đúng thì mọi thứ khác dễ
theo. Viết test song song với code, không để dồn cuối.
jdbc:sqlite::memory: cho test integration là mẹo quan trọng nhất: mỗi test có một
database sạch, chạy trong mili giây, không cần cài gì.
Tách tinhPhat thành static thuần — nó là phần dễ test nhất và cũng
hay sai nhất (điều kiện biên: trả đúng hạn, trả sớm).
Dự án B — Công cụ dòng lệnh phân tích mã nguồn
Viết công cụ quét một dự án Java và báo cáo:
- Số tệp, số dòng code, số dòng comment, số dòng trống (tách riêng).
- Class dài nhất, method dài nhất (ước lượng bằng đếm ngoặc).
- Danh sách
TODO/FIXMEkèm tệp và số dòng. - Rà soát bảo mật: tìm nối chuỗi SQL,
printStackTrace,catchrỗng,new FileReaderkhông encoding. - Thống kê import: package nào được dùng nhiều nhất.
- Xuất báo cáo dạng bảng ra terminal và dạng HTML ra tệp.
Dùng Files.walk, Stream, regex; xử lý song song bằng virtual thread và đo chênh lệch.
Gợi ý
Đếm dòng comment cần một máy trạng thái nhỏ: đang trong /* */, đang trong chuỗi,
hay code thường — vì "// không phải comment" nằm trong chuỗi.
Đây chính là công cụ bạn dùng để chạy checklist ở mục 8 lên các dự án khác. Hãy chạy nó lên
chính dự án A và sửa những gì nó tìm ra.
Dự án C — API client với retry và cache
Xây client cho một REST API công khai, đầy đủ tính năng sản xuất:
- Dùng
java.net.http.HttpClient(có sẵn từ Java 11). - Ánh xạ JSON sang
recordbằng Jackson. - Retry với backoff luỹ thừa; chỉ retry lỗi tạm thời (5xx, timeout) — không retry 4xx.
- Cache kết quả trong bộ nhớ với TTL (dùng
LinkedHashMapchế độ LRU từ chương 8). - Giới hạn tần suất: tối đa N request mỗi giây (dùng
Semaphore). - Gọi 100 endpoint song song bằng virtual thread.
- Test không cần mạng: mock
HttpClient, hoặc dùng một HTTP server nhỏ chạy trong test.
Gợi ý
Điểm khó nhất là test được. Giải pháp: đưa HttpClient vào constructor
(dependency injection) thay vì tạo bên trong — nhờ vậy test truyền vào một mock. Đây chính là
lý do "khó test" là tín hiệu về thiết kế.
Với cache có TTL, lưu record Entry<T>(T giaTri, Instant taoLuc) thay vì
T trần. Test hết hạn bằng cách tiêm một Clock giả — không dùng
Thread.sleep, vì nó làm test chậm và không ổn định.
Dự án D — Viết test cho code có sẵn
Lấy một dự án cũ của bạn chưa có test (hoặc dự án A trước khi viết test) và:
- Chạy JaCoCo, ghi lại độ phủ ban đầu.
- Viết test cho 5 method quan trọng nhất. Ghi lại: method nào khó test và vì sao.
- Refactor những chỗ khó test — thường là tách phụ thuộc ra constructor, hoặc tách hàm thuần khỏi phần có tác dụng phụ.
- Đưa độ phủ lên trên 70%.
- Cố tình làm hỏng một dòng logic (đổi
>thành>=). Có test nào đỏ không? Nếu không, test của bạn còn thiếu gì? - Viết bản tổng kết: refactor nào là do test ép bạn làm, và thiết kế có tốt lên không?
Gợi ý
Bước 5 là bài học quan trọng nhất — nó là mutation testing thủ công. Độ phủ cao mà
không phát hiện được lỗi nghĩa là test đang gọi code mà không khẳng định đủ. Thử thư viện
PIT để tự động hoá việc này.
Ba refactor phổ biến nhất mà test ép bạn làm: (a) đưa phụ thuộc vào constructor thay vì
new bên trong; (b) tách logic thuần khỏi I/O; (c) chia method dài thành nhiều method
nhỏ có tên rõ nghĩa. Cả ba đều làm thiết kế tốt lên — đó là lý do người ta nói "test là công cụ
thiết kế".
Dự án E — Đưa dự án qua đủ 12 mục checklist
Chọn dự án A hoặc C và đưa nó qua toàn bộ checklist ở mục 8. Với mỗi mục, ghi lại:
- Trạng thái ban đầu (đạt / không đạt).
- Nếu không đạt: bạn đã sửa gì, mất bao lâu.
- Mục nào phát hiện ra bug thật? Bug gì?
Bổ sung ba việc nữa:
- Thêm GitHub Actions chạy
mvn clean verifytrên mỗi push. - Viết
Dockerfileđóng gói ứng dụng (dùng ảnh nền JRE tối giản, build nhiều tầng). - Viết README hoàn chỉnh: mô tả, yêu cầu, cài đặt, cách chạy, cấu hình, cách chạy test.
Kết quả cuối: một repo mà người lạ git clone về và chạy được trong 5 phút.
Gợi ý
Dockerfile nhiều tầng: tầng đầu dùng ảnh JDK đầy đủ để mvn package, tầng sau chỉ
copy tệp JAR sang ảnh JRE tối giản. Ảnh cuối nhỏ hơn khoảng 5 lần.
Thứ tự hiệu quả nhất: (1) cấu trúc Maven trước — mọi thứ khác dựa vào nó; (2) sửa cảnh báo biên
dịch, nhanh và hay lộ bug; (3) test; (4) đóng gói và CI cuối cùng.
README tốt trả lời đúng ba câu: Nó làm gì? Chạy thế nào? Cấu hình ở đâu?
Quiz
Kim tự tháp test là gì? Vì sao không nên có nhiều test E2E?
Xem đáp án
Kim tự tháp test mô tả tỉ lệ nên có giữa ba loại test:
- Unit (~70%) — một class/method, mọi phụ thuộc giả lập. Chạy trong mili giây.
- Integration (~20%) — nhiều thành phần thật (DAO + database). Chạy trong giây.
- End-to-end (~10%) — toàn hệ thống qua giao diện thật. Chạy trong phút.
Vì sao ít E2E:
- Chậm — bộ test chạy 30 phút thì không ai chạy trước khi commit.
- Không ổn định — hỏng vì mạng chập chờn, dữ liệu test cũ, thời gian chờ. Test hay đỏ oan khiến cả nhóm mất niềm tin và bắt đầu bỏ qua kết quả.
- Khó chẩn đoán — E2E đỏ chỉ nói "có gì đó hỏng"; unit test đỏ chỉ đúng dòng nào.
Đảo ngược kim tự tháp (nhiều E2E, ít unit) là phản mẫu phổ biến, gọi là "ice cream cone".
@BeforeEach và @BeforeAll khác nhau thế nào? Vì sao @BeforeAll phải static?
Xem đáp án
@BeforeEach chạy trước mỗi test method — nơi tạo dữ liệu sạch
để các test không ảnh hưởng nhau.
@BeforeAll chạy một lần duy nhất trước tất cả — dùng cho việc
thiết lập tốn kém dùng chung (khởi động container database, nạp tệp cấu hình lớn).
Vì sao static: JUnit tạo một instance mới của lớp test cho mỗi
test method — đó là cách nó đảm bảo các test độc lập hoàn toàn, không chia sẻ trạng thái. Vì
vậy tại thời điểm @BeforeAll chạy, chưa có instance nào tồn tại, nên nó phải là
static.
Có thể đổi bằng @TestInstance(Lifecycle.PER_CLASS) — lúc đó JUnit dùng một instance cho
cả lớp và @BeforeAll không cần static. Nhưng khi đó bạn phải tự đảm bảo
các test không làm bẩn trạng thái của nhau.
Vì sao 100% độ phủ test không đảm bảo code không có bug?
Xem đáp án
Vì độ phủ chỉ đo dòng nào đã được chạy qua, không đo trường hợp nào đã được kiểm tra.
int chia(int a, int b) { return a / b; }
@Test void t() { assertEquals(2, chia(4, 2)); }
// Độ phủ dòng: 100% — nhưng chia(1, 0) chưa từng được test!
Tệ hơn: có thể đạt 100% bằng test không khẳng định gì — chỉ gọi method rồi kết thúc. Đó là lý do đặt độ phủ làm KPI dẫn tới hành vi tệ.
Dùng độ phủ đúng cách: coi nó là công cụ tìm vùng chưa được test, không phải mục tiêu. Ngưỡng thực dụng 70-80%, tập trung vào logic nghiệp vụ.
Câu hỏi tốt hơn: "Nếu tôi cố tình làm hỏng một dòng logic, có test nào đỏ không?" — đó là ý tưởng của mutation testing (thư viện PIT), thước đo chất lượng test tốt hơn nhiều.
Khi nào nên dùng mock, khi nào không?
Xem đáp án
Nên mock những phụ thuộc chậm, không ổn định, hoặc có tác dụng phụ thật:
- Database, API bên ngoài, dịch vụ gửi email/SMS.
- Hệ tệp, đồng hồ hệ thống (
Clock), sinh số ngẫu nhiên. - Bất cứ thứ gì khiến test phụ thuộc môi trường.
Không nên mock:
- Object giá trị —
record,String,List. Cứ dùng thật, chúng rẻ và không có tác dụng phụ. - Class bạn đang test — mock nó thì còn test gì?
- Class của thư viện chuẩn.
Dấu hiệu cảnh báo: nếu phải mock năm thứ để test một method, có lẽ method đó làm quá nhiều việc. Test khó viết thường là phản hồi về thiết kế, không phải vấn đề của test.
Với DAO, thường tốt hơn là dùng database thật trong bộ nhớ
(jdbc:sqlite::memory:) thay vì mock — bạn test được cả SQL, thứ mà mock không kiểm tra
được.
scope trong Maven là gì? Nêu bốn giá trị.
Xem đáp án
scope quy định dependency có mặt ở giai đoạn nào và có được đóng gói vào JAR hay không:
| scope | Biên dịch | Test | Đóng gói | Ví dụ |
|---|---|---|---|---|
compile | ✅ | ✅ | ✅ | Thư viện dùng chính |
test | ❌ | ✅ | ❌ | JUnit, Mockito |
provided | ✅ | ✅ | ❌ | Servlet API (server cung cấp) |
runtime | ❌ | ✅ | ✅ | Driver JDBC |
Đặt scope đúng quan trọng ở hai mặt: kích thước (JUnit trong JAR production là vô
ích và làm tệp phình lên) và an toàn (test ngăn code production vô
tình import class chỉ dành cho test).
runtime cho driver JDBC là ví dụ tinh tế: code của bạn chỉ dùng interface
java.sql.*, không cần class cụ thể lúc biên dịch — nhưng cần nó lúc chạy để
DriverManager tìm thấy.
Fat JAR là gì? Vì sao cần nó?
Xem đáp án
Fat JAR (hay uber JAR) là tệp JAR chứa cả code của bạn lẫn mọi dependency đã được giải nén và gộp vào.
Vì sao cần: JAR do maven-jar-plugin tạo chỉ chứa class của
bạn. Chạy nó sẽ gặp NoClassDefFoundError ngay khi chạm tới dòng đầu tiên dùng thư viện
ngoài. Để chạy được, bạn phải tự truyền classpath với tất cả JAR phụ thuộc — bất khả thi khi có vài
chục thư viện.
$ mvn clean package
$ java -jar target/app-1.0.0.jar # chạy được ngay ✅
$ jar tf target/app-1.0.0.jar | grep hikari # kiểm chứng đã gộp
Tạo bằng maven-shade-plugin (phổ biến nhất) hoặc maven-assembly-plugin.
Nhớ khai báo mainClass trong manifest, nếu không JVM không biết chạy từ đâu.
Nhược điểm: tệp lớn (vài chục MB) và có thể xung đột nếu hai thư viện chứa tệp cùng tên trong
META-INF — shade có bộ chuyển đổi để gộp chúng.
Vì sao dùng database trong bộ nhớ cho integration test thay vì mock DAO?
Xem đáp án
Vì mock DAO chỉ kiểm tra rằng bạn gọi DAO đúng cách — nó không kiểm tra chính
SQL. Mà SQL mới là nơi bug hay nằm: sai tên cột, thiếu WHERE, JOIN sai, ràng
buộc khoá ngoại bị vi phạm, kiểu dữ liệu không khớp.
@BeforeEach
void chuanBi() throws Exception {
var cf = new HikariConfig();
cf.setJdbcUrl("jdbc:sqlite::memory:"); // mỗi test một DB SẠCH
ds = new HikariDataSource(cf);
chayLuocDo(ds);
}
Ưu điểm:
- Test được SQL thật — ràng buộc UNIQUE, khoá ngoại, giao dịch, rollback.
- Nhanh — vài mili giây, không cần cài server.
- Sạch tuyệt đối — mỗi test một database mới, không có trạng thái rò rỉ.
Lưu ý: SQLite khác MySQL/PostgreSQL ở một số điểm cú pháp và kiểu dữ liệu. Với truy vấn phức tạp hoặc tính năng riêng của database, dùng Testcontainers — nó chạy database thật trong Docker, chậm hơn nhưng đúng tuyệt đối.
Vì sao "code khó test" thường là dấu hiệu thiết kế chưa tốt?
Xem đáp án
Vì những thứ khiến code khó test cũng chính là những thứ khiến nó khó dùng lại, khó hiểu, và khó thay đổi. Ba nguyên nhân phổ biến nhất:
-
Tự tạo phụ thuộc bên trong —
new HttpClient()hoặcDriverManager.getConnection()ngay trong method. Test không thay thế được, và code cũng bị khoá cứng vào một cài đặt. Sửa: đưa phụ thuộc vào constructor. - Method làm quá nhiều việc — vừa đọc tệp, vừa tính toán, vừa ghi database. Phải mock ba thứ mới test được một logic. Sửa: tách phần thuần (tính toán) khỏi phần có tác dụng phụ (I/O).
-
Phụ thuộc trạng thái tĩnh —
staticthay đổi được,LocalDate.now()gọi trực tiếp. Test không cô lập được và kết quả đổi theo ngày. Sửa: tiêmClock, tránh trạng thái tĩnh.
Đó là lý do nhiều người coi test là công cụ thiết kế chứ không chỉ công cụ kiểm tra. Khi bạn thấy mình phải vật lộn để viết một test, hãy dừng lại và hỏi: "code này có đang làm quá nhiều việc không?" — câu trả lời thường là có.
Tổng kết
Sau chương 14 — chương cuối của Java sub-pillar — bạn đã nắm:
- Vì sao test: vòng lặp phản hồi vài giây thay vì vài phút; dám refactor.
- Kim tự tháp test: 70% unit, 20% integration, 10% E2E.
- JUnit 5: assertion,
assertThrows,assertAll, vòng đời,@Nested,@TempDir. - Parameterized test:
@ValueSource,@CsvSource,@MethodSource. - Mockito:
when/thenReturn,verify,ArgumentCaptor— và khi nào không mock. - Độ phủ là công cụ tìm vùng chưa test, không phải mục tiêu.
- Maven: vòng đời,
scope, fat JAR với shade plugin. - Database trong bộ nhớ cho integration test — nhanh, sạch, test được cả SQL.
- Checklist 12 mục — thước đo khách quan cho "dự án đã xong".
- "Khó test" là phản hồi về thiết kế, không phải vấn đề của test.
Hết sub-pillar Java — đi đâu tiếp?
Bạn đã đi qua 14 chương: từ JVM, cú pháp, OOP, ngoại lệ, Collections, Generics, lập trình hàm, tệp tin, đa luồng, JDBC, tới kiểm thử và dự án hoàn chỉnh. Ba hướng đi tiếp tự nhiên:
- Spring Boot — hệ sinh thái backend Java phổ biến nhất. Mọi thứ bạn học ở chương 5 (interface, annotation), 9 (generics), 13 (DAO, giao dịch) đều là nền tảng trực tiếp của nó.
- Android — hoặc học tiếp Kotlin, ngôn ngữ chạy trên cùng JVM với cú pháp gọn hơn nhiều nhưng dùng chung toàn bộ thư viện.
- Đi sâu vào JVM — điều chỉnh garbage collector, đọc bytecode, profiling hiệu năng với JFR và async-profiler, GraalVM Native Image.
Dù chọn hướng nào, nền tảng ở 14 chương này đều dùng được nguyên vẹn — framework thay đổi, JVM thì không.
Kết nối
- Chương 1 (JDK & Maven) — vòng đời build, cấu trúc dự án; giờ dùng đầy đủ.
- Chương 5 (OOP nâng cao) —
recordcho model, interface cho DAO: cả hai giúp test dễ hơn hẳn. - Chương 6 (Ngoại lệ) —
assertThrowskhẳng định đúng loại và đúng thông điệp. - Chương 10 (Stream) — báo cáo và tổng hợp dữ liệu trong dự án.
- Chương 11 (Tệp tin) —
@TempDircủa JUnit cho test I/O; xuất CSV/JSON. - Chương 12 (Đa luồng) — virtual thread cho xử lý song song trong dự án C.
- Chương 13 (JDBC) — DAO, giao dịch, pool; test bằng SQLite trong bộ nhớ.
- Trụ cột DevOps — CI/CD, Docker, triển khai chính dự án bạn vừa xây.
- Python Chương 13-14 — đối chiếu:
pytestgọn hơn JUnit (dùngasserttrần, fixture linh hoạt hơn), nhưng nguyên tắc kim tự tháp test và bài học "khó test = thiết kế chưa tốt" hoàn toàn giống nhau.