Chương 14 · Kiểm thử & dự án

Kiểm thử, build và dự án thực chiến

Mười ba chương trước dạy ngôn ngữ. Chương cuối này dạy nghề: viết test để code của bạn còn đúng sau khi người khác sửa, dùng công cụ build để dự án chạy được trên máy đồng nghiệp và trên máy chủ CI, và đóng gói thành một sản phẩm giao được. Nửa sau là một ứng dụng quản lý hoàn chỉnh — nơi mọi thứ bạn đã học ghép lại thành một thứ chạy được thật.

Độ dài: ~1600 dòng Bài tập: 5 dự án Quiz: 8 Prerequisites: Chương 1-13
🎯 Mục tiêu chương
  • 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 testPhạm viTốc độTỉ lệ nên có
UnitMột class/method, mọi phụ thuộc đều giả lậpMili giây~70%
IntegrationNhiều thành phần thật (DAO + database)Giây~20%
End-to-endToàn hệ thống qua giao diện thậtPhú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

pom.xml
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>
src/test/java/com/vidu/PhanSoTest.java
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()   { ... }
    }
}
AssertionKiểm tra
assertEquals(mongDoi, thucTe)Bằng nhau — thứ tự tham số quan trọng khi đọc báo lỗi
assertNotEqualsKhác nhau
assertTrue / assertFalseĐiều kiện boolean
assertNull / assertNotNullNull hay không
assertSame / assertNotSameCùng object (==)
assertArrayEqualsMảng giống nhau từng phần tử
assertIterableEqualsCollection 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
VongDoi.java
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

Parameterized.java
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

Mockito.java
<!-- 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
    }
}
💡 Khi nào dùng mock, khi nào không

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

pom.xml — JaCoCo
<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
⚠️ 100% độ phủ không có nghĩa là không có bug
PhuNhungVoDung.java
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

pom.xml đầy đủ cho ứng dụng
<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 MavenLàm gì
mvn cleanXoá target/
mvn compileBiên dịch src/main/java
mvn testBiê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 -DskipTestsBuild nhanh, bỏ test
mvn dependency:treeXem cây phụ thuộc — công cụ gỡ xung đột phiên bản
mvn versions:display-dependency-updatesKiểm tra bản mới
mvn test -Dtest=PhanSoTestChạy một lớp test
scopeCó khi biên dịchCó khi testĐóng gói vào JARVí 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
Terminal — build và chạy
$ 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

Cấu trúc dự á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ớ
model/Sach.java — Chương 5
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);
    }
}
service/MuonTraService.java — nơi mọi thứ ghép lại
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;
    }
}
test — ba tầng kiểm thử
// ---------- ① 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ụcKiểm tra bằngChương
1Cấu trúc Maven chuẩn, pom.xml đầy đủmvn clean package chạy được1, 14
2Fat JAR chạy bằng java -jarThử trên máy khác14
3Không có SQL nối chuỗigrep -rn "\" + " --include=*.java13
4Mọi AutoCloseable trong try-with-resourcesSpotBugs / rà soát code6, 11, 13
5Không nuốt ngoại lệ, không printStackTrace()grep -rn "printStackTrace"6
6Tiền dùng BigDecimal/long, ngày dùng java.timeRà soát model2, 13
7Mọi tệp văn bản chỉ định UTF-8grep -rn "new FileReader\|new FileWriter"11
8Không có mật khẩu trong mã nguồngitleaks detect13
9Dùng logging, không System.out để gỡ lỗigrep -rn "System.out" src/main/6, 14
10Test cho logic nghiệp vụ, độ phủ > 70%mvn test + JaCoCo14
11Không cảnh báo khi biên dịchmvn compile -Dmaven.compiler.showWarnings=true9
12README 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ột enum có hành vi cho trạng thái phiếu mượn.
  • DAO cho ba bảng, dùng HikariCP với SQLite.
  • MuonTraService vớ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 / FIXME kèm tệp và số dòng.
  • Rà soát bảo mật: tìm nối chuỗi SQL, printStackTrace, catch rỗng, new FileReader khô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 record bằ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 LinkedHashMap chế độ 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à:

  1. Chạy JaCoCo, ghi lại độ phủ ban đầu.
  2. Viết test cho 5 method quan trọng nhất. Ghi lại: method nào khó test và vì sao.
  3. 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ụ.
  4. Đưa độ phủ lên trên 70%.
  5. 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ì?
  6. 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:

  1. Thêm GitHub Actions chạy mvn clean verify trên mỗi push.
  2. Viết Dockerfile đóng gói ứng dụng (dùng ảnh nền JRE tối giản, build nhiều tầng).
  3. 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

Q1

Kim tự tháp test là gì? Vì sao không nên có nhiều test E2E?

Xem đáp án
✓ Đá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".

Q2

@BeforeEach và @BeforeAll khác nhau thế nào? Vì sao @BeforeAll phải static?

Xem đáp án
✓ Đá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.

Q3

Vì sao 100% độ phủ test không đảm bảo code không có bug?

Xem đáp án
✓ Đá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.

ViDu.java
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.

Q4

Khi nào nên dùng mock, khi nào không?

Xem đáp án
✓ Đá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.

Q5

scope trong Maven là gì? Nêu bốn giá trị.

Xem đáp án
✓ Đá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:

scopeBiên dịchTestĐóng góiVí 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.

Q6

Fat JAR là gì? Vì sao cần nó?

Xem đáp án
✓ Đá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.

Terminal
$ 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.

Q7

Vì sao dùng database trong bộ nhớ cho integration test thay vì mock DAO?

Xem đáp án
✓ Đá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.

Test.java
@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.

Q8

Vì sao "code khó test" thường là dấu hiệu thiết kế chưa tốt?

Xem đáp án
✓ Đá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ặc DriverManager.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 — static thay đổ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êm Clock, 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) — record cho model, interface cho DAO: cả hai giúp test dễ hơn hẳn.
  • Chương 6 (Ngoại lệ) — assertThrows khẳ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) — @TempDir củ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: pytest gọn hơn JUnit (dùng assert trầ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.