Java cơ bản #12: dự án cuối — ứng dụng quản lý chi tiêu với Maven và JUnit
Gom toàn bộ kiến thức vào một dự án thật: cấu trúc Maven và pom.xml, chia lớp model–kho dữ liệu–service–giao diện, lưu file CSV, thống kê bằng Stream, cảnh báo ngân sách, viết kiểm thử JUnit 5, đóng gói file jar chạy được — và lộ trình học tiếp sau Java cơ bản.
15 phút đọc
Mục lục
Mười một bài vừa qua mỗi bài một mảnh: biến, vòng lặp, lớp, interface, record, collection, ngoại lệ, file, stream. Bài cuối ghép chúng thành một ứng dụng hoàn chỉnh với cấu trúc như dự án thật ở công ty: công cụ build Maven, code chia lớp rõ ràng, dữ liệu lưu file, và kiểm thử tự động bằng JUnit.
Ứng dụng: quản lý chi tiêu cá nhân chạy trên terminal — ghi khoản chi, xem thống kê theo tháng và danh mục, cảnh báo khi vượt ngân sách.
Maven: công cụ build
Dự án thật cần thư viện bên ngoài (JUnit, thư viện JSON, driver database…), cần biên dịch hàng trăm file, chạy kiểm thử, đóng gói. Maven (và Gradle) tự động hoá tất cả: khai báo thư viện trong một file, Maven tự tải về từ kho Maven Central.
# Windows: winget install Apache.Maven · macOS: brew install maven · Ubuntu: sudo apt install maven
mvn -v # kiểm tra: Apache Maven 3.9.x, Java version: 25IntelliJ IDEA và VS Code có sẵn Maven — tạo dự án mới chọn "Maven" là được.
Cấu trúc dự án chuẩn
chi-tieu/
├── pom.xml cấu hình dự án, thư viện
└── src/
├── main/java/vn/dtcode/chitieu/
│ ├── GiaoDich.java model (record)
│ ├── KhoGiaoDich.java đọc/ghi file CSV
│ ├── ChiTieuService.java nghiệp vụ: thêm, thống kê, ngân sách
│ └── App.java giao diện terminal (menu)
└── test/java/vn/dtcode/chitieu/
└── ChiTieuServiceTest.java kiểm thử JUnitMọi dự án Maven đều theo cấu trúc này — mở dự án Java bất kỳ ở công ty, bạn biết ngay code ở đâu, kiểm thử ở đâu.
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Toạ độ dự án: nhóm (tên miền đảo ngược), tên, phiên bản -->
<groupId>vn.dtcode</groupId>
<artifactId>chi-tieu</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Thư viện kiểm thử, chỉ dùng khi chạy test (scope test) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
<plugin>
<!-- Ghi lớp chứa main vào file jar để chạy được bằng java -jar -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<mainClass>vn.dtcode.chitieu.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>| Lệnh | Làm gì |
|---|---|
mvn compile | Biên dịch code trong src/main |
mvn test | Biên dịch và chạy mọi kiểm thử trong src/test |
mvn package | Chạy test rồi đóng gói thành target/chi-tieu-1.0.0.jar |
mvn clean | Xoá thư mục target/ |
Thiết kế: chia lớp theo trách nhiệm
Đang vẽ sơ đồ…
flowchart LR
App["App<br/>(menu, nhập/xuất terminal)"] --> Service["ChiTieuService<br/>(quy tắc nghiệp vụ)"]
Service --> Kho["KhoGiaoDich<br/>(đọc/ghi CSV)"]
Service --> GD["GiaoDich<br/>(record)"]
Kho --> GDNguyên tắc từ bài 5 và 7: tách tính toán khỏi giao diện. ChiTieuService không biết gì về terminal — vì vậy kiểm thử được tự động, và sau này thay giao diện terminal bằng web (Spring Boot) mà không sửa nghiệp vụ.
Model
package vn.dtcode.chitieu;
import java.time.LocalDate;
/** Một khoản chi. Bất biến; kiểm tra dữ liệu ngay khi tạo nên mọi GiaoDich tồn tại đều hợp lệ. */
public record GiaoDich(LocalDate ngay, String danhMuc, long soTien, String ghiChu) {
public GiaoDich {
if (ngay == null) throw new IllegalArgumentException("Thiếu ngày");
if (danhMuc == null || danhMuc.isBlank()) throw new IllegalArgumentException("Thiếu danh mục");
if (soTien <= 0) throw new IllegalArgumentException("Số tiền phải dương");
danhMuc = danhMuc.strip().toLowerCase();
ghiChu = ghiChu == null ? "" : ghiChu.strip().replace(",", ";"); // dấu phẩy sẽ làm vỡ cột CSV
}
static GiaoDich tuCsv(String dong) {
String[] cot = dong.split(",", -1);
if (cot.length != 4) throw new IllegalArgumentException("Sai định dạng: " + dong);
return new GiaoDich(LocalDate.parse(cot[0]), cot[1], Long.parseLong(cot[2]), cot[3]);
}
String sangCsv() {
return String.join(",", ngay.toString(), danhMuc, String.valueOf(soTien), ghiChu);
}
}Kho dữ liệu (lưu file CSV)
package vn.dtcode.chitieu;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Đọc/ghi danh sách giao dịch ra file CSV. Lớp duy nhất biết dữ liệu nằm ở file. */
public class KhoGiaoDich {
private static final String TIEU_DE = "ngay,danh_muc,so_tien,ghi_chu";
private final Path file;
public KhoGiaoDich(Path file) {
this.file = file;
}
public List<GiaoDich> docTatCa() {
if (!Files.exists(file)) return new ArrayList<>();
try {
List<GiaoDich> ketQua = new ArrayList<>();
List<String> dong = Files.readAllLines(file, StandardCharsets.UTF_8);
for (int i = 1; i < dong.size(); i++) {
if (!dong.get(i).isBlank()) ketQua.add(GiaoDich.tuCsv(dong.get(i)));
}
return ketQua;
} catch (IOException e) {
throw new UncheckedIOException("Không đọc được " + file, e); // giữ nguyên nhân gốc
}
}
public void luuTatCa(List<GiaoDich> ds) {
List<String> dong = new ArrayList<>();
dong.add(TIEU_DE);
for (GiaoDich g : ds) dong.add(g.sangCsv());
try {
Path tam = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tam, dong, StandardCharsets.UTF_8);
// Ghi ra file tạm rồi đổi tên: mất điện giữa chừng cũng không làm hỏng file cũ
Files.move(tam, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException("Không ghi được " + file, e);
}
}
}Service: nghiệp vụ
package vn.dtcode.chitieu;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/** Quy tắc nghiệp vụ. Không đọc bàn phím, không in màn hình → kiểm thử tự động được. */
public class ChiTieuService {
private final KhoGiaoDich kho;
private final List<GiaoDich> giaoDich;
private final Map<String, Long> nganSach = new TreeMap<>();
public ChiTieuService(KhoGiaoDich kho) {
this.kho = kho;
this.giaoDich = new ArrayList<>(kho.docTatCa());
}
public GiaoDich them(LocalDate ngay, String danhMuc, long soTien, String ghiChu) {
GiaoDich g = new GiaoDich(ngay, danhMuc, soTien, ghiChu); // record tự kiểm tra dữ liệu
giaoDich.add(g);
kho.luuTatCa(giaoDich);
return g;
}
public List<GiaoDich> trongThang(YearMonth thang) {
return giaoDich.stream()
.filter(g -> YearMonth.from(g.ngay()).equals(thang))
.sorted(Comparator.comparing(GiaoDich::ngay))
.toList();
}
public long tongThang(YearMonth thang) {
return trongThang(thang).stream().mapToLong(GiaoDich::soTien).sum();
}
/** Tổng chi theo danh mục trong tháng, danh mục xếp A→Z. */
public Map<String, Long> theoDanhMuc(YearMonth thang) {
return trongThang(thang).stream().collect(Collectors.groupingBy(
GiaoDich::danhMuc, TreeMap::new, Collectors.summingLong(GiaoDich::soTien)));
}
public void datNganSach(String danhMuc, long hanMuc) {
if (hanMuc <= 0) throw new IllegalArgumentException("Hạn mức phải dương");
nganSach.put(danhMuc.strip().toLowerCase(), hanMuc);
}
/** Danh mục đã chi vượt ngân sách trong tháng → số tiền vượt. */
public Map<String, Long> vuotNganSach(YearMonth thang) {
Map<String, Long> daChi = theoDanhMuc(thang);
Map<String, Long> vuot = new TreeMap<>();
nganSach.forEach((dm, han) -> {
long chi = daChi.getOrDefault(dm, 0L);
if (chi > han) vuot.put(dm, chi - han);
});
return vuot;
}
}Giao diện terminal
package vn.dtcode.chitieu;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
public class App {
public static void main(String[] args) {
var service = new ChiTieuService(new KhoGiaoDich(Path.of("chi-tieu.csv")));
service.datNganSach("ăn uống", 3_000_000);
service.datNganSach("giải trí", 1_000_000);
while (true) {
IO.println("""
=== QUẢN LÝ CHI TIÊU ===
1. Thêm khoản chi
2. Xem tháng này
3. Thống kê theo danh mục
0. Thoát""");
String chon = IO.readln("Chọn: ");
if (chon == null || chon.strip().equals("0")) break;
try {
switch (chon.strip()) {
case "1" -> themKhoanChi(service);
case "2" -> xemThang(service, YearMonth.now());
case "3" -> thongKe(service, YearMonth.now());
default -> IO.println("Lựa chọn không hợp lệ");
}
} catch (IllegalArgumentException | DateTimeParseException e) {
IO.println("Lỗi: " + e.getMessage()); // nhập sai không làm sập chương trình
}
}
IO.println("Tạm biệt!");
}
private static void themKhoanChi(ChiTieuService service) {
String ngayNhap = IO.readln("Ngày (yyyy-mm-dd, bỏ trống = hôm nay): ").strip();
LocalDate ngay = ngayNhap.isEmpty() ? LocalDate.now() : LocalDate.parse(ngayNhap);
String danhMuc = IO.readln("Danh mục: ");
long soTien = Long.parseLong(IO.readln("Số tiền: ").strip());
String ghiChu = IO.readln("Ghi chú: ");
GiaoDich g = service.them(ngay, danhMuc, soTien, ghiChu);
IO.println("Đã thêm: %s · %s · %,d đ".formatted(g.ngay(), g.danhMuc(), g.soTien()));
}
private static void xemThang(ChiTieuService service, YearMonth thang) {
for (GiaoDich g : service.trongThang(thang)) {
IO.println("%s %-12s %,12d %s".formatted(g.ngay(), g.danhMuc(), g.soTien(), g.ghiChu()));
}
IO.println("Tổng tháng %s: %,d đ".formatted(thang, service.tongThang(thang)));
}
private static void thongKe(ChiTieuService service, YearMonth thang) {
service.theoDanhMuc(thang).forEach((dm, tien) -> IO.println("%-12s %,12d".formatted(dm, tien)));
service.vuotNganSach(thang).forEach((dm, vuot) ->
IO.println("⚠ Vượt ngân sách '%s' %,d đ".formatted(dm, vuot)));
}
}Kiểm thử với JUnit 5
Mỗi lần sửa code, bạn có dám chắc mình không làm hỏng chức năng cũ? Kiểm thử tự động trả lời câu hỏi đó trong vài giây. Mỗi phương thức @Test dựng tình huống, gọi code, rồi khẳng định (assert) kết quả:
package vn.dtcode.chitieu;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChiTieuServiceTest {
private static final YearMonth THANG_10 = YearMonth.of(2026, 10);
@TempDir
Path thuMucTam; // JUnit tạo thư mục tạm riêng cho mỗi test, tự xoá sau đó
private ChiTieuService service;
@BeforeEach
void chuanBi() { // chạy trước MỖI test → các test độc lập với nhau
service = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
service.them(LocalDate.of(2026, 10, 1), "Ăn uống", 50_000, "phở");
service.them(LocalDate.of(2026, 10, 2), "ăn uống ", 2_000_000, "liên hoan");
service.them(LocalDate.of(2026, 10, 5), "đi lại", 30_000, "xe buýt");
service.them(LocalDate.of(2026, 9, 30), "ăn uống", 70_000, "tháng trước");
}
@Test
void tongThangChiTinhGiaoDichTrongThang() {
assertEquals(2_080_000, service.tongThang(THANG_10));
}
@Test
void gomTheoDanhMucSauKhiChuanHoaTen() {
Map<String, Long> ketQua = service.theoDanhMuc(THANG_10);
assertEquals(Map.of("ăn uống", 2_050_000L, "đi lại", 30_000L), ketQua);
}
@Test
void baoVuotNganSach() {
service.datNganSach("ăn uống", 1_000_000);
service.datNganSach("đi lại", 500_000);
assertEquals(Map.of("ăn uống", 1_050_000L), service.vuotNganSach(THANG_10));
}
@Test
void tuChoiSoTienKhongDuong() {
var loi = assertThrows(IllegalArgumentException.class,
() -> service.them(LocalDate.of(2026, 10, 9), "sách", 0, ""));
assertTrue(loi.getMessage().contains("dương"));
}
@Test
void duLieuDuocLuuVaDocLaiTuFile() {
var serviceMoi = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
assertEquals(4, serviceMoi.trongThang(THANG_10).size() + serviceMoi.trongThang(YearMonth.of(2026, 9)).size());
}
@Test
void ghiChuCoDauPhayKhongLamVoFile() {
service.them(LocalDate.of(2026, 10, 10), "mua sắm", 100_000, "áo, quần");
var serviceMoi = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
assertEquals(2_180_000, serviceMoi.tongThang(THANG_10));
}
}Chạy:
mvn test
# [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
# [INFO] BUILD SUCCESSĐặc điểm của kiểm thử tốt, thể hiện ở trên:
- Tên test nói điều được kiểm tra — test hỏng thì đọc tên là biết chức năng nào hỏng.
- Độc lập:
@BeforeEachdựng lại dữ liệu cho từng test;@TempDirkhông đụng file thật. - Kiểm tra cả trường hợp lỗi (
assertThrows) và trường hợp biên (giao dịch tháng trước, tên danh mục khác hoa thường, ghi chú có dấu phẩy). - Test cuối được viết khi phát hiện lỗi: ghi chú có dấu phẩy làm vỡ CSV. Viết test tái hiện lỗi trước, sửa code sau — lỗi đó không bao giờ quay lại.
Đóng gói và chạy
mvn package # chạy test rồi tạo target/chi-tieu-1.0.0.jar
java -jar target/chi-tieu-1.0.0.jar # chạy ứng dụng=== QUẢN LÝ CHI TIÊU ===
1. Thêm khoản chi
2. Xem tháng này
3. Thống kê theo danh mục
0. Thoát
Chọn: 1
Ngày (yyyy-mm-dd, bỏ trống = hôm nay):
Danh mục: Ăn uống
Số tiền: 3500000
Ghi chú: tiệc sinh nhật
Đã thêm: 2026-10-28 · ăn uống · 3,500,000 đ
Chọn: 3
ăn uống 3,500,000
⚠ Vượt ngân sách 'ăn uống' 500,000 đMở rộng dự án (bài tập)
- Thêm chức năng xoá/sửa giao dịch (gợi ý: thêm
idvào record). - Thêm khoản thu, tính số dư cuối tháng (gợi ý:
sealed interfacevới hai recordKhoanThu,KhoanChi— bài 7). - Lưu ngân sách vào file riêng thay vì đặt cứng trong
main. - Xuất báo cáo tháng ra file
bao-cao-2026-10.txt. - Viết thêm test cho mọi chức năng mới — mục tiêu: không sửa code nào mà không có test bảo vệ.
Học gì tiếp theo
Bạn đã có nền tảng Java vững. Lộ trình gợi ý sau loạt bài này:
| Giai đoạn | Nội dung | Tài liệu |
|---|---|---|
| Java nâng cao | Đa luồng, ExecutorService, virtual threads (Java 21+), CompletableFuture | dev.java, Java Concurrency in Practice |
| Viết code chuyên nghiệp | Clean code, design pattern cơ bản, Effective Java | Bloch — Effective Java |
| Database | SQL, JDBC, rồi JPA/Hibernate | Tài liệu PostgreSQL, Baeldung |
| Web backend | Spring Boot: REST API, Spring Data JPA, Spring Security, kiểm thử | spring.io/guides |
| Công cụ | Git, Docker, CI, Gradle | |
| Thuật toán | Cấu trúc dữ liệu, độ phức tạp, luyện LeetCode/Codeforces bằng Java |
Gợi ý dự án tiếp theo: biến ứng dụng chi tiêu thành REST API bằng Spring Boot lưu vào PostgreSQL — ChiTieuService gần như dùng lại nguyên vẹn, đó là phần thưởng cho việc tách nghiệp vụ khỏi giao diện ngay từ đầu.
Tổng kết cả loạt
- Nền tảng (bài 1–5): JDK/JVM, kiểu dữ liệu, điều khiển luồng, mảng, chuỗi, phương thức.
- Hướng đối tượng (bài 6–8): lớp, đóng gói, kế thừa, đa hình, interface, sealed, record, enum,
equals/hashCode. - Thư viện chuẩn (bài 9–11): Collections, Generics, ngoại lệ,
java.nio.file, lambda, Stream, Optional. - Dự án (bài 12): Maven, chia lớp theo trách nhiệm, JUnit, đóng gói jar.
Nguồn tham khảo: Maven — "Maven in 5 Minutes", POM Reference (maven.apache.org); JUnit 5 User Guide (junit.org); dev.java — "Getting Started with Maven"; Bloch — Effective Java; spring.io/guides.
Bài này thế nào?
Bài viết có hữu ích với bạn?
Đánh giá giúp mình biết nên viết thêm về chủ đề nào.