Command Palette

Search for a command to run...

[Advanced Java] Ghép toàn bộ khóa Java: report, error mapping và test suite ba level

Bài trước đã dựng xong một service bán hàng: product, customer, order và những rule làm cho một order có ý nghĩa. Nó dừng lại khi các endpoint chạy được. Bài này là vòng làm việc tiếp theo — vòng quyết định code có sống sót khi có người thứ hai đụng vào hay không.

Vòng đó gồm ba phần, và không phần nào là trang trí. Một test suite dựng ở ba level, để một rule hỏng thì fail ở chỗ rẻ nhất có thể nhìn thấy nó. Phần report viết hai lần, một lần chạy stream trên entity trong JVM và một lần để database aggregate, rồi so sánh sòng phẳng. Và một lượt đi ngược lại toàn bộ code đã hoàn thành để gọi tên từng kiến thức của khóa nằm ở đâu — kể cả những thứ không nằm ở đâu cả.

Sáu chủ đề — OOP, Collections, Stream, Exception, Database, Tests — hội tụ vào một project

Mọi thứ bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64) với Spring Boot 4.1.1, Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, Jackson 3.1.5, H2 2.4.240, JUnit Jupiter 6.0.3 và Mockito 5.23.0. Mọi con số thời gian đã bị xóa khỏi các transcript một cách có chủ ý, và lý do được nói rõ ở chỗ nó quan trọng.

Toàn bộ hệ thống trong một block

Bạn không cần mở lại bài trước để đọc bài này. Cả domain chỉ gồm bốn entity và năm rule:

Java
// Product   id, sku (unique), name, priceCents, stock
// Customer  id, name, email (unique)
// Order     id, customer, createdAt, status, items (cascade)
// OrderItem id, order, product, quantity, unitPriceCents (copied at order time)
 
// POST /api/orders          from a customer id plus a {sku, quantity} list
// GET  /api/orders/{id}
// POST /api/orders/{id}/pay
// POST /api/orders/{id}/cancel
// plus CRUD for products and customers
 
// 1. an unknown sku is 404
// 2. insufficient stock is 409
// 3. paying decrements every line's stock in one transaction
// 4. paying is rejected unless the order is NEW (409)
// 5. cancelling a PAID order puts the stock back

Đó là toàn bộ đặc tả. Bài dựng hệ thống đã giao phiên bản đơn giản nhất của nó: entity có getter và setter, phép tính stock viết thẳng trong OrderService, và hai class exception — NotFoundExceptionConflictException — phủ mọi loại failure. Ba trong số các quyết định đó thay đổi ở vòng này, và mỗi thay đổi đều được lập luận bên dưới chứ không chỉ được tuyên bố: invariant chuyển vào trong entity, các bước chuyển status chuyển vào enum, và hai class exception trở thành một hierarchy nhỏ dưới một root abstract để phần map sang HTTP chỉ phải viết một lần.

Phần còn lại của bài viết là chuyện xảy ra với đặc tả đó khi bạn xem chất lượng code là việc nghiêm túc.

Kiến thức của khóa thực sự nằm ở đâu trong code

Một bài tổng kết chỉ liệt kê từ vựng — "chúng ta đã dùng encapsulation, polymorphism và generics" — thì vô giá trị, vì project nào cũng nói được câu đó. Phiên bản có ích là phiên bản gọi tên file và method.

Sáu package của project, mỗi package chú thích bằng kiến thức mà tên type và method thật của nó là ví dụ

Encapsulation, invariant và một enum mang behaviour

Product không có setter cho cái field quan trọng nhất. Stock chỉ đổi qua hai method, và invariant "stock không bao giờ âm" nằm bên trong class chứ không nằm ở service nào tình cờ gọi nó:

Java
public boolean canFulfil(int quantity) {
    return quantity > 0 && stock >= quantity;
}
 
public void decreaseStock(int quantity) {
    if (!canFulfil(quantity)) {
        throw new IllegalStateException("cannot take " + quantity + " from stock " + stock);
    }
    this.stock -= quantity;
}

Đó là encapsulation làm việc thật: OrderService.pay có thể sai thứ tự kiểm tra, stock vẫn không thể âm. Unit test chứng minh điều này không cần framework nào cả.

Order.getItems() trả về Collections.unmodifiableList(items). Bên ngoài chỉ đọc được các line item; chỉ addItem mới được thêm, và chỉ addItem biết rằng unit price phải được copy từ product ngay tại thời điểm đó. Chính cú copy đó là lý do một lần reprice về sau không thể âm thầm viết lại một order đã đặt.

OrderStatus là chỗ state machine sống, và nó là enum có constant body chứ không phải một danh sách tên trơ:

Java
public enum OrderStatus {
    NEW {
        @Override public boolean canPay()    { return true; }
        @Override public boolean canCancel() { return true; }
    },
    PAID {
        @Override public boolean canPay()    { return false; }
        @Override public boolean canCancel() { return true; }
    },
    CANCELLED {
        @Override public boolean canPay()    { return false; }
        @Override public boolean canCancel() { return false; }
    };
 
    public abstract boolean canPay();
 
    public abstract boolean canCancel();
 
    public boolean releasesStockOnCancel() {
        return this == PAID;
    }
}

Phần thưởng là OrderService không có một câu switch nào trên status. Nó hỏi order.getStatus().canPay() rồi throw nếu câu trả lời là không. Thêm state REFUNDED nghĩa là thêm một constant với hai method, và compiler sẽ chỉ thẳng vào mọi abstract method bạn quên — đúng thứ mà switch không làm được cho bạn.

Các business failure là một hierarchy nhỏ dưới một root abstract:

Java
public abstract class SalesException extends RuntimeException { }
 
public class NotFoundException extends SalesException { }              // 404
public class InsufficientStockException extends SalesException { }     // 409
public class IllegalOrderStateException extends SalesException { }     // 409
public class DuplicateKeyException extends SalesException { }          // 409

Mỗi subclass mang theo đúng dữ liệu mà bên gọi cần — InsufficientStockException giữ sku, số lượng yêu cầu và số lượng còn lại, chứ không chỉ một chuỗi đã format — vì message là dành cho người đọc, còn các field là dành cho JSON body.

Generics, Optional và những collection được chọn có chủ ý

ProductRepository extends JpaRepository<Product, Long> là generics làm việc trong đúng một dòng: findById trả Optional<Product>, save trả Product, không có cast ở đâu cả. Derived query findBySku cũng trả Optional<Product>, và service biến "không có" thành một domain failure ngay tại boundary thay vì đẩy một null vào sâu bên trong:

Java
Product product = products.findBySku(entry.getKey())
        .orElseThrow(() -> new NotFoundException("product", entry.getKey()));

Hai lựa chọn collection trong codebase này là có chủ ý chứ không phải theo phản xạ. OrderService.mergeLines dựng một LinkedHashMap<String, Integer> để hai line cùng sku gộp thành một response giữ đúng thứ tự client gửi lên:

Java
public static Map<String, Integer> mergeLines(List<CreateOrderRequest.Line> lines) {
    Map<String, Integer> merged = new LinkedHashMap<>();
    for (CreateOrderRequest.Line line : lines) {
        if (line.quantity() <= 0) {
            throw new IllegalArgumentException("quantity must be > 0 for sku " + line.sku());
        }
        merged.merge(line.sku(), line.quantity(), Integer::sum);
    }
    return merged;
}

HashMap cũng chạy được và sẽ xáo trộn thứ tự line; TreeMap sẽ sắp theo sku, thứ không ai yêu cầu. Còn ReportService đếm order vào một EnumMap vì tập key là cố định, nhỏ và đã có sẵn thứ tự:

Java
return orders.findAll().stream()
        .collect(Collectors.groupingBy(
                Order::getStatus,
                () -> new EnumMap<>(OrderStatus.class),
                Collectors.counting()));

Các comparator trong report là static field chứ không phải lambda dựng lại mỗi lần gọi, và chúng được chain để trường hợp bằng điểm được phá theo quy tắc rõ ràng thay vì theo thứ tự map tình cờ duyệt:

Java
private static final Comparator<CustomerRevenue> BY_REVENUE_DESC =
        Comparator.comparingLong(CustomerRevenue::revenueCents).reversed()
                .thenComparing(CustomerRevenue::customerName);

Không có thenComparing, hai customer cùng doanh thu sẽ đổi chỗ giữa các lần chạy và test nào assert trên list đó sẽ flaky.

Những gì khóa học dạy mà project này cố tình không dùng

Đây mới là nửa sòng phẳng của một bài tổng kết. Một service CRUD nhỏ không cần mọi thứ một khóa học bao phủ, và giả vờ ngược lại sẽ tạo ra loại code mà đi review không ai tìm nổi business logic ở đâu.

Khóa học có dạyDùng ở đâyVì sao
Thread, ExecutorService, CompletableFutureKhôngMỗi request là một transaction ngắn. Servlet container đã lo phần concurrency; thêm một thread pool nữa bên trong một request chỉ thêm failure mode chứ không thêm throughput.
File I/O và NIOKhôngKhông có gì đọc ghi xuống đĩa. Database là nơi lưu duy nhất.
Serialization, tự làm việc với ObjectOutputStreamKhôngJSON vào, JSON ra, Jackson lo hết.
Singleton, Factory, Builder, ObserverGần như khôngSpring bean vốn đã là singleton do container quản lý, viết lại pattern bằng tay là làm trùng. Có bốn entity và không có nhánh khởi tạo nào, nên Builder chỉ là thủ tục.
Inheritance giữa các entityKhôngProduct, CustomerOrder không chia sẻ gì. Inheritance duy nhất trong project là exception hierarchy, nơi nó đổi lấy đúng một @ExceptionHandler.
Comparable trên entityKhôngReport cần nhiều cách sắp xếp, nên Comparator mới đúng, còn natural ordering sẽ là một lựa chọn tùy tiện.

Quy luật rất nhất quán: những ý tưởng sống sót vào codebase này là những ý tưởng xóa được một chỗ lặp hoặc bảo vệ được một invariant. Những ý tưởng không sống sót là những thứ chỉ thêm một layer cho có.

Một advice, một chỗ duy nhất biến exception thành status code

Exception hierarchy tồn tại để phần map sang HTTP được viết đúng một lần, trong một @RestControllerAdvice, thay vì rải rác thành các lời gọi ResponseEntity.status(...) khắp bốn controller:

Java
@RestControllerAdvice
public class ApiExceptionHandler {
 
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ApiError> notFound(NotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ApiError("NOT_FOUND", e.getMessage(),
                        Map.of("resource", e.getResource(), "key", e.getKey())));
    }
 
    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ApiError> stock(InsufficientStockException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ApiError("INSUFFICIENT_STOCK", e.getMessage(),
                        Map.of("sku", e.getSku(), "requested", e.getRequested(),
                               "available", e.getAvailable())));
    }
 
    /** Anything new under SalesException still gets a JSON body, never a stack trace. */
    @ExceptionHandler(SalesException.class)
    public ResponseEntity<ApiError> fallback(SalesException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiError.of("BAD_REQUEST", e.getMessage()));
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> invalid(MethodArgumentNotValidException e) {
        Map<String, Object> fields = new LinkedHashMap<>();
        e.getBindingResult().getFieldErrors()
                .forEach(f -> fields.put(f.getField(), f.getDefaultMessage()));
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ApiError("VALIDATION_FAILED", "request body is not valid", fields));
    }
}

Handler đặt trên SalesException abstract mới là phần đáng chép lại. Spring chọn @ExceptionHandler khớp cụ thể nhất với type được throw, nên một subclass thêm vào tháng sau sẽ rơi vào fallback và trả về JSON body có code, thay vì trả 500 kèm stack trace trong response. Bốn response thật từ ứng dụng đang chạy:

JSON
{"code":"NOT_FOUND","message":"product not found: SKU-NOPE",
 "details":{"resource":"product","key":"SKU-NOPE"}}
 
{"code":"INSUFFICIENT_STOCK","message":"insufficient stock for SKU-MONITOR: requested 99, available 17",
 "details":{"sku":"SKU-MONITOR","requested":99,"available":17}}
 
{"code":"ILLEGAL_STATE","message":"cannot pay an order in status PAID",
 "details":{"status":"PAID","attempted":"pay"}}
 
{"code":"VALIDATION_FAILED","message":"request body is not valid",
 "details":{"lines":"must not be empty"}}

Chúng lần lượt trả về 404, 409, 409 và 400. Field code là thứ client switch trên đó; message dành cho log; details là thứ một form cần để tô sáng đúng ô nhập.

Report hai lần: group trong JVM hay group dưới database

Service cần ba report chỉ đọc: doanh thu theo customer, top product theo số lượng bán, và số order theo status. Mỗi report đều có thể viết bằng Stream API trên entity đã load, hoặc viết thành một JPQL aggregate để database trả lời. Cả hai đều đúng. Chúng không phải cùng một quyết định.

Hai panel: stream trên entity phát 36 statement cho 135 row, đối lại một JPQL aggregate trả về 5 dòng projection

Doanh thu theo customer, viết hai lần

Bản stream đọc đúng như cách bạn giải thích report bằng miệng:

Java
@Transactional(readOnly = true)
public List<CustomerRevenue> revenueByCustomerInMemory() {
    return groupRevenue(orders.findAll());
}
 
private List<CustomerRevenue> groupRevenue(List<Order> all) {
    Map<Long, List<Order>> byCustomer = all.stream()
            .filter(o -> o.getStatus() == OrderStatus.PAID)
            .collect(Collectors.groupingBy(o -> o.getCustomer().getId()));
 
    return byCustomer.entrySet().stream()
            .map(e -> new CustomerRevenue(
                    e.getKey(),
                    e.getValue().get(0).getCustomer().getName(),
                    e.getValue().stream().mapToLong(Order::totalCents).sum(),
                    e.getValue().size()))
            .sorted(BY_REVENUE_DESC)
            .toList();
}

Để ý khóa group: o.getCustomer().getId(), không phải object Customer. Entity ở đây thừa hưởng equals theo identity, nên group theo object chỉ đúng chừng nào mọi order trong kết quả đến từ cùng một persistence context. Group theo id thì luôn đúng, và không chậm hơn.

Bản query đẩy đúng phép tính đó xuống database và trả về một record chứa bốn giá trị scalar:

Java
public record CustomerRevenue(Long customerId, String customerName,
                              long revenueCents, long orderCount) { }
 
@Query("""
        select new com.example.sales.report.CustomerRevenue(
                 c.id, c.name, sum(i.quantity * i.unitPriceCents), count(distinct o.id))
        from Order o
        join o.customer c
        join o.items i
        where o.status = com.example.sales.domain.OrderStatus.PAID
        group by c.id, c.name
        order by sum(i.quantity * i.unitPriceCents) desc, c.name asc
        """)
List<CustomerRevenue> revenueByCustomer();

count(distinct o.id) không phải cho vui. Join o.items nhân mỗi dòng order lên theo số line của nó, nên count(o) trơn sẽ báo ba order ở chỗ thực ra chỉ có một order ba line. sum thì không bị ảnh hưởng, vì mỗi line vẫn chỉ được đếm đúng một lần.

Hai bản trả về cùng một list. Một integration test assert thẳng điều đó thay vì tin vào việc đọc code:

Java
assertEquals(lazy, viaSql, "both versions must produce identical rows");

Chạy trên ứng dụng thật, hai endpoint khớp nhau từng byte:

JSON
GET /api/reports/revenue
[{"customerId":2,"customerName":"Binh Le","revenueCents":62300,"orderCount":1},
 {"customerId":1,"customerName":"Ann Tran","revenueCents":50200,"orderCount":2}]
 
GET /api/reports/revenue?mode=stream
[{"customerId":2,"customerName":"Binh Le","revenueCents":62300,"orderCount":1},
 {"customerId":1,"customerName":"Ann Tran","revenueCents":50200,"orderCount":2}]

SQL mà mỗi bản thực sự phát ra

Bật logging.level.org.hibernate.SQL=DEBUG sẽ cho thấy hai bản tốn gì. Bản stream phát một query lấy order, rồi mỗi collection lazy nó chạm vào là thêm một query, rồi mỗi customer proxy nó dereference lại thêm một query nữa:

SQL
select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.status from orders o1_0
 
select i1_0.order_id,i1_0.id,i1_0.product_id,i1_0.quantity,i1_0.unit_price_cents
  from order_items i1_0 where i1_0.order_id=?
 
select c1_0.id,c1_0.email,c1_0.name from customers c1_0 where c1_0.id=?

Bản aggregate là một statement duy nhất, và đúng là statement bạn sẽ tự viết bằng tay:

SQL
select c1_0.id,c1_0.name,sum((i1_0.quantity*i1_0.unit_price_cents)),count(distinct o1_0.id)
  from orders o1_0
  join customers c1_0 on c1_0.id=o1_0.customer_id
  join order_items i1_0 on o1_0.id=i1_0.order_id
  where o1_0.status='PAID'
  group by c1_0.id,c1_0.name
  order by sum((i1_0.quantity*i1_0.unit_price_cents)) desc,c1_0.name

Hai report còn lại ra theo đúng kiểu đó:

SQL
select p1_0.sku,p1_0.name,sum(oi1_0.quantity)
  from order_items oi1_0
  join products p1_0 on p1_0.id=oi1_0.product_id
  join orders o1_0 on o1_0.id=oi1_0.order_id
  where o1_0.status='PAID'
  group by p1_0.sku,p1_0.name
  order by sum(oi1_0.quantity) desc,p1_0.sku
 
select o1_0.status,count(o1_0.id) from orders o1_0 group by o1_0.status order by o1_0.status

Đếm số row chuyển đi, không đếm mili giây

Đây mới là phép so sánh đáng làm, và cũng là chỗ đa số bài viết làm hỏng vì với tay lấy đồng hồ bấm giây. Một con số thời gian đo cái máy đã chạy nó: page cache, JIT, và mọi thứ khác đang bận lúc đó. Một con số row đo thiết kế, và nó giống hệt nhau trên laptop của bạn lẫn trên máy production.

Hibernate đếm hộ bạn. Bật spring.jpa.properties.hibernate.generate_statistics=true, clear counter, chạy report, rồi đọc lại:

Java
private <T> T count(String label, Supplier<T> report, int resultRows) {
    stats().clear();
    T result = report.get();
    System.out.printf("ROWS %-34s statements=%-4d entityRows=%-5d collectionFetches=%-5d resultRows=%d%n",
            label, stats().getPrepareStatementCount(), stats().getEntityLoadCount(),
            stats().getCollectionFetchCount(), resultRows);
    return result;
}

Trên bộ dữ liệu seed gồm 40 order, 120 order item, 5 customer và 8 product — 30 order PAID, 5 CANCELLED, 5 NEW — đây là những gì suite in ra:

Text
ROWS revenue / stream + lazy items      statements=36   entityRows=135   collectionFetches=30    resultRows=5
ROWS revenue / stream + fetch join      statements=1    entityRows=165   collectionFetches=0     resultRows=5
ROWS revenue / JPQL aggregate           statements=1    entityRows=0     collectionFetches=0     resultRows=5
ROWS top products / stream              statements=39   entityRows=138   collectionFetches=30    resultRows=8
ROWS top products / JPQL aggregate      statements=1    entityRows=0     collectionFetches=0     resultRows=8
ROWS by status / stream                 statements=1    entityRows=40    collectionFetches=0     resultRows=3
ROWS by status / JPQL aggregate         statements=1    entityRows=0     collectionFetches=0     resultRows=3

Viết lại thành bảng:

ReportCách làmStatementRow entity vào JVMRow kết quả
Doanh thu theo customerstream, item lazy361355
Doanh thu theo customerstream, join fetch11655
Doanh thu theo customerJPQL aggregate105
Top productstream391388
Top productJPQL aggregate108
Order theo statusstream1403
Order theo statusJPQL aggregate103

Bảng đó cho ra bốn kết luận, và chỉ một trong số đó là kết luận hiển nhiên.

Bản stream ngây thơ dính N+1, và các con số gọi thẳng tên nó: 1 query lấy order, 30 query cho collection item của các order đã PAID, 5 query cho customer proxy. Ba mươi sáu statement để tạo ra năm dòng.

Thêm left join fetch o.items sửa được số statement chứ không sửa được khối lượng. Một statement — nhưng 165 row entity được dựng ra, nhiều hơn cả bản lazy, vì giờ mọi order và mọi item đều về, kể cả những cái sẽ bị filter bỏ. Nếu phản xạ của bạn là "thêm fetch join là xong", thì dòng giữa của bảng đó chính là phần đính chính.

Bản aggregate chuyển đi đúng thứ report là: năm dòng, không entity nào. Không có gì được dựng lên mà bên gọi sẽ không đọc.

Còn hai dòng cuối là giới hạn sòng phẳng của lập luận này. Đếm order theo status là một statement với cả hai cách, và khác biệt là 40 row so với 3 row. Trên bộ dữ liệu này thì chẳng là gì. Trên một bảng hai triệu order thì đó là khác biệt giữa một report và một sự cố — mà code thì không tự đổi hình dạng khi bảng lớn lên, nên quyết định phải được đưa ra lúc bảng còn nhỏ.

Rule rút ra không phải là "luôn dùng aggregate". Nó là: report chỉ đọc mà rút gọn nhiều row thành ít row thì thuộc về database; thứ gì cần chính behaviour của entity thì thuộc về JVM. Order.totalCents() là một method trên entity, và bản stream được quyền gọi nó. Bản aggregate phải diễn đạt lại phép tính đó bằng JPQL, và nếu rule tính giá sau này mọc thêm phần discount, hai biểu thức ấy có thể lệch nhau. Đó mới là chi phí thật của bản aggregate, và nó là chi phí bảo trì chứ không phải chi phí hiệu năng.

Test ở ba level

Năm mươi tư test trên một service, chia ba nhóm khác nhau ở đúng một điểm: mỗi nhóm được phép load những gì.

Ba level test có kích thước theo số test, mỗi level chú thích bằng số bean và thứ nó nhìn thấy được

Level 1: unit test thuần, không có Spring

Hai mươi sáu trong năm mươi tư test không khởi động context nào, không mở connection nào và không mock gì cả. Chúng tạo object rồi gọi method:

Java
class ProductStockTest {
 
    private Product product(int stock) {
        return new Product("SKU-1", "Widget", 1000, stock);
    }
 
    @ParameterizedTest
    @CsvSource({ "10, 1, true", "10, 10, true", "10, 11, false",
                 "0, 1, false", "10, 0, false", "10, -1, false" })
    void canFulfil_isTrueOnlyForAPositiveQuantityThatFits(int stock, int wanted, boolean expected) {
        assertEquals(expected, product(stock).canFulfil(wanted));
    }
 
    @Test
    @DisplayName("stock can never go negative, even if a caller ignores canFulfil")
    void decreaseStock_rejectsOverdraft() {
        Product p = product(3);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> p.decreaseStock(4));
        assertEquals("cannot take 4 from stock 3", e.getMessage());
        assertEquals(3, p.getStock());
    }
}

Những trường hợp biên trong @CsvSource đó chính là loại hay lọt bug lên production: vừa đủ stock, thừa đúng một đơn vị, yêu cầu 0, và số lượng âm. Viết chúng tốn mỗi cái một dòng vì không phải khởi động gì trước.

Rule tính giá cũng được đối xử như vậy, kể cả cái mà nhiều người làm sai:

Java
@Test
@DisplayName("the unit price is copied at order time, so a later reprice cannot rewrite history")
void unitPriceIsFrozenAtOrderTime() {
    Product p = new Product("A", "Widget", 1000, 10);
    Order order = new Order(new Customer("Ann", "ann@example.com"), T0);
    order.addItem(p, 2);
 
    p.reprice(9999);
 
    assertEquals(2000, order.totalCents());
    assertEquals(1000, order.getItems().get(0).getUnitPriceCents());
}
 
@Test
void totalCents_survivesAQuantityThatWouldOverflowAnInt() {
    Order order = new Order(new Customer("Ann", "ann@example.com"), T0);
    order.addItem(new Product("A", "Bulk", 1_000_000, Integer.MAX_VALUE), 3000);
    assertEquals(3_000_000_000L, order.totalCents());
}

Cái thứ hai là lý do lineTotalCents() phải cast sang long trước khi nhân. 3000 * 1_000_000 tràn số int 32-bit có dấu; test sẽ fail rõ ràng nếu có người "dọn dẹp" cú cast đó đi.

Một static method trên Spring bean thì vẫn là một method bình thường, nên OrderService.mergeLines cũng được test ở đây, không container gì hết:

Java
@Test
void duplicateSkusAreSummed() {
    Map<String, Integer> merged = OrderService.mergeLines(
            List.of(new Line("A", 2), new Line("B", 1), new Line("A", 3)));
    assertEquals(Map.of("A", 5, "B", 1), merged);
}
 
@Test
void insertionOrderIsPreserved() {
    Map<String, Integer> merged = OrderService.mergeLines(
            List.of(new Line("Z", 1), new Line("A", 1), new Line("M", 1)));
    assertEquals(List.of("Z", "A", "M"), List.copyOf(merged.keySet()));
}

Level 2: slice @WebMvcTest với @MockitoBean

Mười ba test kiểm tra riêng tầng HTTP: status code, JSON shape, validation, và phần map exception sang status. @WebMvcTest khởi động controller, các message converter và @RestControllerAdvice, ngoài ra không gì khác. Ở Spring Boot 4, annotation này nằm trong org.springframework.boot.webmvc.test.autoconfigure, và @MockBean đã bị bỏ — thay thế nó là @MockitoBean trong org.springframework.test.context.bean.override.mockito:

Java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired MockMvc mvc;
 
    @MockitoBean OrderService orderService;
 
    private static final String BODY = """
            {"customerId": 7, "lines": [{"sku": "SKU-WIDGET", "quantity": 3}]}
            """;
 
    @Test
    void postOrder_returns201AndTheOrderShape() throws Exception {
        given(orderService.create(any())).willReturn(TestOrders.sample());
 
        mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content(BODY))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.status").value("NEW"))
                .andExpect(jsonPath("$.totalCents").value(3750))
                .andExpect(jsonPath("$.items.length()").value(1))
                .andExpect(jsonPath("$.items[0].unitPriceCents").value(1250))
                .andExpect(jsonPath("$.items[0].lineTotalCents").value(3750));
    }
 
    @Test
    void insufficientStock_becomes409WithTheNumbers() throws Exception {
        given(orderService.create(any())).willThrow(new InsufficientStockException("SKU-WIDGET", 3, 1));
 
        mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content(BODY))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"))
                .andExpect(jsonPath("$.details.requested").value(3))
                .andExpect(jsonPath("$.details.available").value(1));
    }
 
    @Test
    void emptyLines_becomes400FromBeanValidation() throws Exception {
        mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON)
                        .content("{\"customerId\": 7, \"lines\": []}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
                .andExpect(jsonPath("$.details.lines").exists());
    }
}

Slice cần một Order đầy đủ để serialize, mà id của entity lại do database sinh ra — thứ mà slice này không có. ReflectionTestUtils điền vào chỗ đó:

Java
static Order sample() {
    Customer customer = new Customer("Ann Tran", "ann@example.com");
    ReflectionTestUtils.setField(customer, "id", 7L);
    Product widget = new Product("SKU-WIDGET", "Widget", 1250, 100);
    ReflectionTestUtils.setField(widget, "id", 1L);
 
    Order order = new Order(customer, T0);
    ReflectionTestUtils.setField(order, "id", 42L);
    order.addItem(widget, 3);
    return order;
}

Nhớ kỹ cái helper này. Nó là thứ làm slice nhanh, và cũng là thứ làm slice bị mù — object nó dựng ra đầy đủ hơn bất cứ thứ gì service thật trả về.

Slice nhẹ hơn bao nhiêu? Suite in ra thay vì để bạn đoán cảm tính:

Text
FOOTPRINT WebMvcTest    beans=128 dataSource=0 emf=0
FOOTPRINT SpringBootTest beans=237 dataSource=1 emf=1

Một trăm hai mươi tám bean definition so với hai trăm ba mươi bảy, và trong slice không hề có DataSource lẫn EntityManagerFactory. Đó là lập luận cho slice, phát biểu bằng thứ được load chứ không bằng số giây — số giây sẽ là thuộc tính của cái máy.

Level 3: @SpringBootTest chạy trên H2

Mười lăm test khởi động toàn bộ ứng dụng trên một database H2 in-memory, và test class không có @Transactional, nên mọi commit và mọi rollback đều là thật. Vòng đời đầy đủ:

Java
@Test
@DisplayName("create then pay then cancel: stock moves down at pay and back up at cancel")
void roundTrip() {
    CreateOrderRequest request =
            SalesFixture.order(customerId, new Line("SKU-WIDGET", 3), new Line("SKU-GIZMO", 2));
 
    Order created = orderService.create(request);
    assertEquals(OrderStatus.NEW, created.getStatus());
    assertEquals(3 * 1250 + 2 * 499, created.totalCents());
    assertEquals(10, stockOf("SKU-WIDGET"), "creating an order must not reserve stock");
 
    Order paid = orderService.pay(created.getId());
    assertEquals(OrderStatus.PAID, paid.getStatus());
    assertEquals(7, stockOf("SKU-WIDGET"));
    assertEquals(8, stockOf("SKU-GIZMO"));
 
    Order cancelled = orderService.cancel(created.getId());
    assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
    assertEquals(10, stockOf("SKU-WIDGET"), "cancelling a PAID order restores stock");
    assertEquals(10, stockOf("SKU-GIZMO"));
}

Và đây là test mà chỉ level này viết được, vì nó cần một transaction thật sự rollback:

Java
@Test
@DisplayName("a payment that fails on the second line rolls the first line's decrement back")
void payIsAllOrNothing() {
    Order big = orderService.create(SalesFixture.order(customerId, new Line("SKU-GIZMO", 9)));
    Order mixed = orderService.create(
            SalesFixture.order(customerId, new Line("SKU-WIDGET", 4), new Line("SKU-GIZMO", 5)));
 
    orderService.pay(big.getId());
    assertEquals(1, stockOf("SKU-GIZMO"));
 
    assertThrows(InsufficientStockException.class, () -> orderService.pay(mixed.getId()));
 
    assertEquals(10, stockOf("SKU-WIDGET"), "the widget decrement must have been rolled back");
    assertEquals(1, stockOf("SKU-GIZMO"));
    assertEquals(OrderStatus.NEW, orders.findById(mixed.getId()).orElseThrow().getStatus());
}

pay duyệt các line theo thứ tự. Nó trừ bốn widget thành công, rồi thấy chỉ còn một gizmo trong khi cần năm, và throw. InsufficientStockException kế thừa RuntimeException, nên Spring rollback transaction và stock của widget trở lại 10 — không phải 6. Hãy assert trên stock của line đầu tiên, đừng assert trên line bị fail: line bị fail vốn chưa bao giờ bị trừ, nên assert vào đó chẳng chứng minh được gì.

Ba test nữa chạy đúng vòng đời đó qua HTTP bằng @SpringBootTest cộng @AutoConfigureMockMvc, nên request đi qua controller thật, service thật, repository thật và H2:

Java
mvc.perform(post("/api/orders/" + id + "/pay"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("PAID"));
assertEquals(7, products.findBySku("SKU-WIDGET").orElseThrow().getStock());
 
mvc.perform(post("/api/orders/" + id + "/pay"))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("ILLEGAL_STATE"));

Con bug mà chỉ level trên cùng bắt được

@WebMvcTest cho pay pass. Mọi unit test pass. @SpringBootTest chạy qua HTTP thì fail ngay chính endpoint đó:

Text
jakarta.servlet.ServletException: Request processing failed:
  org.hibernate.LazyInitializationException: Could not initialize proxy
  [com.example.sales.domain.Customer#1] - no session
Caused by: org.hibernate.LazyInitializationException: Could not initialize proxy
  [com.example.sales.domain.Customer#1] - no session
    at com.example.sales.domain.Customer$HibernateProxy.getName(Unknown Source)
    at com.example.sales.web.dto.OrderResponse.of(OrderResponse.java:19)
    at com.example.sales.web.OrderController.pay(OrderController.java:39)

Ở thời điểm đó trong quá trình làm, findByIdWithItems mới chỉ fetch items và để customer là một lazy proxy. OrderService.pay load order qua nó, rồi controller gọi OrderResponse.of(order) sau khi transaction đã đóng, với spring.jpa.open-in-view=false. Gọi getName() trên một proxy không còn session thì throw. Chỗ sửa là một mệnh đề trong một query:

Java
// before
@Query("select o from Order o left join fetch o.items where o.id = :id")
 
// after
@Query("select o from Order o join fetch o.customer left join fetch o.items where o.id = :id")

Nói cách khác: mỗi association mà response thực sự đọc tới thì cần một mệnh đề fetch. Một response còn đọc item.getProduct() thì cần thêm left join fetch i.product — đó chính là lý do câu query hoàn chỉnh trong bài dựng dự án mang đủ cả ba mệnh đề, vì nó được viết sau khi lỗi này đã phải trả giá một lần.

⚠️ Slice không thể bắt được lỗi này, và viết thêm bao nhiêu slice test nữa cũng không. @MockitoBean OrderService trả về đúng thứ test dựng ra — một OrderCustomer là object thật, không bao giờ là proxy. Mock hợp tác hơn thực tế, và chính điều đó vừa làm nó nhanh vừa làm nó mù.

Đó là phiên bản sòng phẳng của test pyramid. Hình dạng đó là mặc định tốt: unit test rẻ để viết, chạy không cần container, và bắt được những bug số học và biên vốn chiếm đa số defect. Nhưng pyramid là heuristic chứ không phải luật. Defect của project này là một bug wiring — một query, một fetch strategy và một boundary serialization tương tác với nhau — mà bug wiring thì vô hình với mọi level đã mock phần wiring đi. Một integration test bắt được bug wiring đáng giá hơn mười unit test không bắt được.

Hệ quả thực tế là một rule về chỗ nào đáng đầu tư, chứ không phải viết bao nhiêu cái: thêm một unit test cho mỗi rule có biên, và thêm một integration test cho mỗi chỗ hai layer gặp nhau — một transaction boundary, một association lazy đi qua serialization, một status code sinh ra từ exception thật chứ không phải từ một stub.

Chạy toàn bộ suite

./mvnw test. Pattern include mặc định của Surefire bắt *Test, đó là lý do các class integration được đặt tên ...IntegrationTest chứ không phải ...IT — class *IT là phần của Failsafe, và sẽ bị mvn test bỏ qua trong im lặng. Cú bỏ qua im lặng đó đáng để kiểm tra ở bất kỳ project nào bạn tiếp quản: chạy suite, rồi đếm số class.

Text
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.example.sales.web.OrderControllerTest
FOOTPRINT WebMvcTest beans=128 dataSource=0 emf=0
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.web.OrderControllerTest
Running com.example.sales.web.ProductControllerTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.web.ProductControllerTest
Running com.example.sales.integration.OrderLifecycleIntegrationTest
FOOTPRINT SpringBootTest beans=237 dataSource=1 emf=1
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.OrderLifecycleIntegrationTest
Running com.example.sales.integration.OrderApiIntegrationTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.OrderApiIntegrationTest
Running com.example.sales.integration.ReportComparisonIntegrationTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.ReportComparisonIntegrationTest
Running com.example.sales.service.MergeLinesTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.service.MergeLinesTest
Running com.example.sales.domain.ProductStockTest
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.ProductStockTest
Running com.example.sales.domain.OrderStatusTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.OrderStatusTest
Running com.example.sales.domain.OrderPricingTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.OrderPricingTest
 
Results:
 
Tests run: 54, Failures: 0, Errors: 0, Skipped: 0

Cột Time elapsed của Surefire và các dòng Started ... in N seconds của Spring Boot đã được xóa khỏi transcript đó một cách có chủ ý. Chúng là thuộc tính của cái máy đã chạy chúng, và in lại trong một bài viết chỉ mời gọi người đọc so sánh với máy của mình — một phép so sánh không có ý nghĩa gì.

Sẽ đổi gì nếu hệ thống lớn lên

Toàn bộ phần này là phán đoán thiết kế, không phải thứ đã đo được. Đây là những chỗ tôi sẽ xem trước tiên, theo thứ tự vấn đề thường xuất hiện — và câu trả lời trung thực cho phần lớn trong số đó là "chưa cần".

Pagination cho mọi endpoint list. GET /api/products đang trả về products.findAll(). Với một demo thì ổn, với một catalogue thì sai, và cách sửa không miễn phí: khi endpoint trả về một page thì client phải xử lý page, nên bổ sung về sau là một cú phá vỡ API. Spring Data đưa sẵn cho bạn PageablePage<Product> gần như không tốn gì, nên đây là mục duy nhất trong danh sách tôi sẽ làm ngay chứ không đợi "khi nào lớn lên".

Một outbox, hoặc một cơ chế reservation rõ ràng, cho stock. Hiện tại stock thay đổi bên trong đúng transaction thanh toán order. Điều đó đúng, và đó là lý do payIsAllOrNothing pass. Nó hết đủ ngay khi stock phải được phản ánh ra ngoài database này — một hệ thống kho, một search index, một service khác. Hình dạng quen thuộc là một bảng outbox ghi trong cùng transaction rồi được rút ra sau đó, để thay đổi stock và thông điệp về nó không thể mâu thuẫn nhau. Hai customer cùng thanh toán đơn vị cuối cùng ở đúng một thời điểm là bài toán khác, giải bằng optimistic locking với @Version trên Product, và tôi sẽ thêm cái đó trước outbox.

Một database thật. H2 in-memory là một fixture cho test, không phải một môi trường chạy thật. ddl-auto=create-drop ổn với một suite tự seed và sai ở mọi chỗ khác; schema production cần Flyway hoặc Liquibase và ddl-auto=validate. Chuyển sang database thật cũng đổi những behaviour mà bạn có thể đang dựa vào mà không biết: mặc định của H2 về identity generation, so sánh chuỗi và phân biệt hoa thường không giống Postgres.

Cache, để cuối cùng và làm một cách miễn cưỡng. Catalogue product được đọc liên tục và ghi rất thưa, đúng bài của cache. Nó cũng là cách nhanh nhất để trả về số stock cũ cho một customer sắp bị báo là order thất bại. Nếu buộc phải làm, hãy cache phần metadata của catalogue — name, price — và tuyệt đối không cache con số stock.

Để ý những thứ không có trong danh sách đó: viết lại các stream, tune JVM, hay thay thứ gì đó bằng thứ nhanh hơn. Phần so sánh report ở trên là chỗ duy nhất hiệu năng được nhắc tới, và câu trả lời ở đó là một quyết định thiết kế về nơi thực hiện phép tính, không phải một nút vặn tuning.

FAQ

Spring Boot 4 nên dùng @MockBean hay @MockitoBean?

@MockitoBean. @MockBean đã bị bỏ ở Spring Boot 4, và bản thay thế nằm ngay trong Spring Framework tại org.springframework.test.context.bean.override.mockito.MockitoBean. Với trường hợp thông thường thì hai cái hoạt động như nhau: một mock thay cho bean cùng type trong test context.

Vì sao class @SpringBootTest của tôi không chạy khi gọi mvn test?

Gần như chắc chắn là do tên class. Include mặc định của Surefire là *Test, Test*, *Tests*TestCase; một class tên OrderLifecycleIT không khớp cái nào và bị bỏ qua mà không có cảnh báo. Hoặc đổi tên cho kết thúc bằng Test, hoặc cấu hình Failsafe rồi chạy mvn verify.

groupingBy trong Java có chậm hơn GROUP BY trong SQL không?

Đó là câu hỏi sai, và đó là lý do bài này đếm row thay vì bấm giờ. Bản stream chuyển 135 row entity qua 36 statement để tạo ra 5 dòng kết quả; bản aggregate chuyển 5 dòng trong 1 statement. Khác biệt nằm ở thứ đi qua connection và thứ JVM phải cấp phát — và nó lớn lên theo bảng dữ liệu của bạn, trên phần cứng nào cũng vậy.

join fetch có sửa được N+1 không?

Nó sửa số statement chứ không sửa khối lượng dữ liệu. Trong phép đo ở trên, left join fetch o.items kéo 36 statement xuống còn 1 và kéo số row entity lên từ 135 thành 165, vì query duy nhất đó trả về mọi order và mọi item, kể cả những cái filter sẽ vứt đi sau. Nó là cách sửa đúng khi bạn cần entity; nó không thay thế được aggregate khi bạn chỉ cần một bản tổng hợp.

Vì sao report của tôi chỉ ném LazyInitializationException trên production?

Nhiều khả năng là spring.jpa.open-in-view, vốn mặc định true và giữ persistence context mở suốt request. Điều đó giấu bug đi khi chạy máy local và giữ một connection database trong suốt mọi request. Đặt nó thành false — như project này làm — khiến lỗi lộ ra ngay lập tức, trong một test, đúng chỗ bạn muốn.

Mỗi level nên có bao nhiêu test?

Không có tỉ lệ đúng, và bất kỳ con số nào được nêu ra như một tỉ lệ đúng đều là bịa. Thay vào đó là một rule dùng được: một unit test cho mỗi rule có biên, và một integration test cho mỗi chỗ hai layer gặp nhau. Project này dừng ở 26 / 13 / 15 vì nó có nhiều rule số học, một bề mặt HTTP và ba transaction boundary thật.

Có cần @Transactional trên test class không?

Không, nếu là test về transaction. @Transactional trên test sẽ rollback mọi thứ ở cuối, tiện cho việc dọn dẹp và chí mạng cho bất kỳ test nào assert rằng một commit hay một rollback đã thực sự xảy ra — transaction của chính test nuốt mất behaviour bạn đang muốn quan sát. Hãy dọn dẹp trong @BeforeEach thay vì vậy.

JPQL aggregate nên trả về record hay interface projection?

Record, trừ khi bạn cần query được derive thay vì viết tay. Constructor expression, select new com.example.sales.report.CustomerRevenue(...), cho bạn một type được compiler kiểm tra với các component có tên, và hoạt động với một record thuần. Interface projection tiện cho derived query nhưng đẩy chỗ lệch sang runtime.

Kết luận

Ba phần của bài viết này là cùng một ý tưởng nhìn từ ba phía. Test suite đáng có vì nó được dựng ở nơi bug thực sự sống chứ không phải ở nơi dễ viết test — và defect thật duy nhất ở đây là một bug wiring mà chỉ level có database mới nhìn thấy. Phép so sánh report đáng có vì nó đo bằng row và statement, vốn là thuộc tính của thiết kế, chứ không đo bằng mili giây, vốn là thuộc tính của việc laptop bạn đang bận làm gì. Và phần tổng kết đáng có vì nó gọi tên file và method, và vì nó nói rõ project cố tình không dùng gì cũng cụ thể như nói nó dùng gì.

Đó là phần code. Bài cuối cùng của khóa sẽ khép lại bằng những thứ nằm bên trên nó: best practice, performance, và cách trình bày tất cả những điều này trong một buổi phỏng vấn.

Bài viết liên quan

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

JDBC trong Java trên OpenJDK 21 với một database SQLite thật: kiến trúc driver phía sau DriverManager, try-with-resources cho Connection, Statement và ResultSet, một màn SQL injection chạy thật mà PreparedStatement chặn được, ResultSet như một cursor, transaction với commit, rollback và Savepoint, batch update, getGeneratedKeys và cách đọc một SQLException.

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Thread trong Java: Thread, Runnable và virtual thread

Thread trong Java trên OpenJDK 21: thread là gì, stack riêng và heap dùng chung, tạo thread bằng Thread, Runnable và lambda, start khác run ra sao, join, daemon thread, tên và priority, output không xác định giữa các lần chạy, virtual thread với Thread.ofVirtual, và interrupt.

[Advanced Java] Lambda Expression trong Java: cú pháp, target typing và method reference

Lambda expression trong Java trên OpenJDK 21: đầy đủ các dạng cú pháp kể cả var parameter, target typing chứng minh bằng cách gán một đoạn text cho ba interface, luật effectively final khi capture kèm error thật của javac, this bên trong lambda, bốn loại method reference, và vì sao bound reference đánh giá receiver ngay lập tức.