Unit Test với JUnit 5 + Mockito và Tư duy Kiểm thử — Ôn tập Fresher Backend VPBank
Vì sao mảng này quan trọng ở ngân hàng: code backend ngân hàng là code "chạm tiền". Một bug ở hàm chuyển tiền lọt ra production có thể gây sai lệch số dư hàng loạt, chi phí khắc phục (đối soát, hoàn tiền, báo cáo NHNN, uy tín) đắt gấp hàng trăm lần so với việc bắt được nó ở tầng test. Vì vậy ở VPBank và các ngân hàng nói chung, viết test không phải việc phụ mà là yêu cầu chất lượng/compliance bắt buộc — code không có test thường không được merge. Interviewer hỏi về test không phải để kiểm tra bạn thuộc API của JUnit, mà để xem bạn có tư duy phòng thủ với tiền của khách hàng hay không.
1. Danh sách chủ đề con theo độ ưu tiên
ƯU TIÊN CAO (bắt buộc nắm chắc, gần như chắc chắn bị hỏi)
- Unit test là gì, vì sao ngân hàng bắt buộc: test tự động cho đơn vị nhỏ nhất (thường là 1 method/class), chạy độc lập không cần DB/network. Hai giá trị cốt lõi: (1) bắt bug sớm ở code chạm tiền, (2) chống regression — sửa code A không làm hỏng code B; ở ngân hàng codebase sống hàng chục năm, không có test thì không ai dám sửa gì.
- JUnit 5 cơ bản:
@Test, assertEquals, assertTrue, assertThrows, @BeforeEach/@AfterEach, @DisplayName. Cấu trúc một test tốt: Arrange – Act – Assert (hay Given – When – Then): chuẩn bị dữ liệu → gọi method → kiểm tra kết quả.
- Mockito cơ bản: mock là đối tượng giả thay thế dependency thật (repository, API bên ngoài). Khi nào cần mock: dependency chậm, khó dựng, không kiểm soát được (DB, core banking API, service khác). API tối thiểu:
mock()/@Mock, when(...).thenReturn(...) (stub hành vi), verify(...) (kiểm tra tương tác), assertThrows.
- Test pyramid (mức khái niệm): đáy là unit test (nhiều, nhanh, rẻ) → giữa là integration test (ít hơn, test các thành phần ghép nối, có DB/context thật) → đỉnh là E2E test (ít nhất, chậm, đắt, test toàn luồng như người dùng). Fresher chỉ cần vẽ/mô tả được hình tháp và giải thích vì sao unit test nhiều nhất.
- Đặc tính unit test tốt (FIRST rút gọn): nhanh (mili-giây, chạy hàng nghìn test trong vài giây), độc lập (test A không phụ thuộc test B, chạy thứ tự nào cũng pass), lặp lại được (chạy máy nào, lúc nào cũng cùng kết quả — không phụ thuộc
new Date(), random, DB thật), tự verify (pass/fail rõ ràng bằng assert, không cần người nhìn log đoán).
ƯU TIÊN TRUNG BÌNH (nắm khái niệm, trả lời được 3–4 câu)
- Test service Spring có dependency: mock repository bằng
@Mock + @InjectMocks. Liên hệ với mảng Spring đã ôn: đây chính là lý do constructor injection được khuyên dùng — vì dependency đi qua constructor nên trong test chỉ cần new TransferService(mockRepo) là xong, không cần khởi động Spring context, test chạy trong mili-giây. Field injection (@Autowired trên field) khiến việc nhét mock vào khó hơn nhiều.
- Phân biệt
@SpringBootTest / @WebMvcTest / @MockBean (mức khái niệm): @SpringBootTest load toàn bộ context (chậm, dùng cho integration test); @WebMvcTest chỉ load tầng web/controller (test controller + mock service bằng @MockBean); @MockBean = mock được đưa vào Spring context thay bean thật (khác @Mock của Mockito thuần — không cần context).
- Test coverage: % dòng code/nhánh được test chạy qua (đo bằng JaCoCo). Giới hạn: coverage cao chỉ nói code được chạy, không nói code được kiểm tra đúng — test không có assert vẫn tăng coverage.
- TDD (mức khái niệm): viết test trước → test fail (Red) → viết code cho pass (Green) → refactor. Chỉ cần giải thích được vòng lặp Red-Green-Refactor và lợi ích (thiết kế dễ test, không viết thừa code); không cần đã thực hành thành thạo.
ƯU TIÊN THẤP (chỉ cần biết tên, nghe hỏi không bị "đứng hình")
- Testcontainers: thư viện chạy DB thật (Postgres, Kafka...) trong Docker container cho integration test.
- Integration test với DB thật (thay vì H2 in-memory) — biết là tồn tại và vì sao đáng tin hơn.
- Mutation testing (ví dụ PIT): tự sửa lén code (đổi
> thành >=) xem test có bắt được không — cách đo chất lượng test thực sự, tốt hơn coverage.
2. Câu hỏi phỏng vấn khả năng cao + hướng trả lời
Câu 1. Unit test là gì? Khác integration test thế nào? (gần như chắc chắn hỏi)
- Unit test: kiểm thử đơn vị nhỏ nhất (1 method/class) cô lập hoàn toàn — dependency được mock, không DB, không network, chạy mili-giây.
- Integration test: kiểm thử nhiều thành phần ghép nối hoạt động cùng nhau (service + repository + DB thật/H2, hoặc gọi qua HTTP), chậm hơn, dựng môi trường phức tạp hơn.
- Nối vào test pyramid: unit nhiều nhất vì rẻ và nhanh, integration ít hơn, E2E ít nhất.
- Ý ăn điểm: cho ví dụ ngân hàng — "unit test kiểm tra logic tính phí chuyển tiền đúng công thức; integration test kiểm tra khi gọi API chuyển tiền thì số dư trong DB thực sự thay đổi và transaction rollback đúng khi lỗi." Nói thêm: unit test bắt bug logic, integration test bắt bug ghép nối (sai query, sai mapping, sai config transaction).
- Lỗi cần tránh: nói "unit test là test bằng tay từng chức năng" (nhầm với manual test); nói unit test cần DB.
Câu 2. Trong đồ án, em test code như thế nào? (câu hỏi thực chiến — trả lời trung thực)
Nếu đã viết test (mẫu): "Đồ án của em có khoảng X unit test cho tầng service, dùng JUnit 5 + Mockito, mock repository để test logic nghiệp vụ như [ví dụ cụ thể: tính tiền, kiểm tra số dư]. Em ưu tiên test phần logic tiền bạc/nghiệp vụ chính vì đó là nơi sai thì hậu quả lớn nhất. Em chạy test bằng mvn test trước mỗi lần commit."
Nếu chưa viết test (mẫu — trung thực nhưng không mất điểm): "Thời điểm làm đồ án em chủ yếu test thủ công qua Postman theo từng luồng nghiệp vụ, và em thừa nhận đó là thiếu sót. Sau khi tìm hiểu quy trình làm việc thực tế, em đã tự học JUnit 5 và Mockito, quay lại viết bổ sung một số unit test cho phần [chuyển tiền/đặt hàng] của đồ án — ví dụ case số dư không đủ phải ném exception. Em hiểu ở môi trường ngân hàng, code không có test là code chưa hoàn thành."
- Ý ăn điểm: trung thực + thể hiện đã tự sửa thiếu sót bằng hành động cụ thể (đã viết bổ sung test — xem mục 4). Interviewer bank đánh giá thái độ này rất cao ở fresher, cao hơn cả việc "biết sẵn".
- Lỗi cần tránh: nói dối "em có viết test đầy đủ" rồi bị hỏi sâu (when/thenReturn viết thế nào?) và lộ; hoặc trả lời cụt lủn "em chưa viết test ạ" rồi dừng — mất điểm oan.
Câu 3. Mock là gì, tại sao cần? Test TransferService mà không cần DB thật thì làm sao?
- Mock = đối tượng giả đóng thế dependency thật, do mình lập trình sẵn hành vi (
when...thenReturn) và kiểm tra được nó bị gọi thế nào (verify).
- Cần mock khi dependency: chậm (DB), không kiểm soát được (core banking API, cổng NAPAS), khó dựng, hoặc có side effect thật (gửi tiền thật, gửi SMS thật!).
- Cách làm: mock
AccountRepository, inject vào TransferService qua constructor, stub findByAccountNumber trả về account giả với số dư định sẵn → test logic trừ/cộng tiền hoàn toàn không cần DB.
// Giả định: TransferService(AccountRepository repo) — constructor injection
@ExtendWith(MockitoExtension.class)
class TransferServiceTest {
@Mock
private AccountRepository accountRepository;
@InjectMocks
private TransferService transferService;
private Account nguon;
private Account dich;
@BeforeEach
void setUp() {
nguon = new Account("ACC001", new BigDecimal("1000000"));
dich = new Account("ACC002", new BigDecimal("500000"));
}
@Test
void transfer_duSoDu_truNguonCongDich() {
// Given
when(accountRepository.findByAccountNumber("ACC001")).thenReturn(Optional.of(nguon));
when(accountRepository.findByAccountNumber("ACC002")).thenReturn(Optional.of(dich));
// When
transferService.transfer("ACC001", "ACC002", new BigDecimal("300000"));
// Then
assertEquals(new BigDecimal("700000"), nguon.getBalance());
assertEquals(new BigDecimal("800000"), dich.getBalance());
verify(accountRepository).save(nguon);
verify(accountRepository).save(dich);
}
@Test
void transfer_khongDuSoDu_nemException_khongLuuGi() {
when(accountRepository.findByAccountNumber("ACC001")).thenReturn(Optional.of(nguon));
when(accountRepository.findByAccountNumber("ACC002")).thenReturn(Optional.of(dich));
assertThrows(InsufficientBalanceException.class,
() -> transferService.transfer("ACC001", "ACC002", new BigDecimal("2000000")));
verify(accountRepository, never()).save(any()); // tiền không được đụng vào
}
}
- Ý ăn điểm: (1) dùng BigDecimal cho tiền (nhất quán với mảng Java Core — không bao giờ dùng double); (2) case fail có
verify(never()).save(any()) — "khi chuyển tiền thất bại, phải chắc chắn không có gì được ghi xuống DB" — đúng tư duy atomic của giao dịch ngân hàng, nối được sang kiến thức transaction đã ôn ở mảng Database.
- Lỗi cần tránh: nhầm mock với DB test/H2; chỉ test happy case; quên rằng
@InjectMocks hoạt động trơn tru nhờ constructor injection.
Câu 4. Thiết kế test case cho hàm chuyển tiền transfer(from, to, amount) gồm những case nào? (câu luyện tư duy — interviewer bank cực thích)
Trả lời theo nhóm, đừng liệt kê lộn xộn:
- Happy path: đủ số dư → nguồn bị trừ, đích được cộng, tổng tiền hai tài khoản không đổi.
- Validate amount: amount âm, amount = 0, amount null, amount lẻ quá số thập phân cho phép, amount vượt hạn mức giao dịch/ngày.
- Số dư & tài khoản: không đủ số dư (kể cả case biên: đúng bằng số dư — pass hay fail tùy rule phí); tài khoản nguồn/đích không tồn tại; tài khoản bị khóa/đóng.
- Nghiệp vụ đặc thù: chuyển cho chính mình (from = to — phải chặn); chuyển giữa hai loại tiền tệ khác nhau (nếu có).
- Với mỗi case fail: assert đúng loại exception và số dư không đổi, không có bản ghi giao dịch rác.
- Ý ăn điểm: nói thành lời mindset — "Khi nghĩ test case, em nghĩ như kẻ phá hoại: người dùng/hacker có thể truyền gì vào để lấy tiền không phải của họ? Chuyển số âm chính là một cách rút tiền ngược nếu code không chặn." Fresher nói được câu này thường ghi điểm mạnh với interviewer ngân hàng, vì đó chính là tư duy họ cần.
- Lỗi cần tránh: chỉ nêu 2–3 case rồi dừng; quên case số âm và chuyển cho chính mình (hai case "bẫy" kinh điển); quên khẳng định trạng thái sau khi fail.
Câu 5. Coverage 100% có nghĩa là code không có bug không?
- Không. Coverage chỉ đo code được chạy qua, không đo được assert đúng. Test gọi hàm mà không assert gì vẫn cho coverage cao.
- Bug có thể nằm ở case chưa nghĩ tới (thiếu test case cho amount âm — dòng code chạy hết nhưng logic vẫn sai), ở requirement hiểu sai, hoặc ở tương tác giữa các thành phần (unit test không bắt được).
- Coverage là điều kiện cần, không phải điều kiện đủ; hữu ích để phát hiện vùng code hoàn toàn không có test.
- Ý ăn điểm: nhắc mutation testing đúng một câu — "muốn đo chất lượng test thật sự có kỹ thuật mutation testing: cố tình sửa lén code xem test có fail không" — thể hiện hiểu vấn đề ở tầng bản chất. Thêm góc ngân hàng: "ngân hàng thường yêu cầu ngưỡng coverage (vd 70–80%) như một chốt chất lượng, nhưng review test case nghiệp vụ vẫn là bắt buộc."
- Lỗi cần tránh: trả lời "có" (fail ngay); hoặc chê coverage vô dụng hoàn toàn (cực đoan ngược).
Câu 6. Vì sao nên viết code dễ test (testable)? Code thế nào là dễ test?
- Code dễ test = dependency được inject từ ngoài (DI, ưu tiên constructor injection), method có input/output rõ ràng, ít side effect ẩn, không
new dependency hay gọi static/singleton cứng bên trong.
- Nối với mảng Spring: loose coupling qua interface + constructor injection → test chỉ cần
new Service(mock) — không cần Spring context, test nhanh và đơn giản.
- Chiều ngược lại cũng đúng: code khó test thường là code thiết kế tồi — class ôm quá nhiều việc, phụ thuộc chằng chịt. Viết test là "máy đo mùi" của thiết kế.
- Ý ăn điểm: ví dụ cụ thể — "nếu TransferService tự
new JdbcAccountRepository() bên trong thì không thể thay bằng mock, muốn test phải dựng DB thật; đổi sang nhận AccountRepository qua constructor là test được ngay." Cho thấy bạn hiểu vì sao các best practice Spring tồn tại chứ không học vẹt.
- Lỗi cần tránh: trả lời chung chung "để code sạch hơn" mà không nối được với DI/mock.
Câu 7. @BeforeEach dùng làm gì? Vì sao mỗi test phải độc lập với nhau?
@BeforeEach chạy trước mỗi @Test để dựng lại dữ liệu/đối tượng sạch (JUnit 5 còn tạo instance test class mới cho mỗi test) → test không dùng chung state.
- Test độc lập thì: chạy thứ tự nào cũng pass, chạy song song được, một test fail không kéo theo test khác fail oan → dễ tìm nguyên nhân.
- Cấu trúc bên trong mỗi test: Given – When – Then, mỗi test kiểm một hành vi, tên test mô tả hành vi (
transfer_khongDuSoDu_nemException).
- Ý ăn điểm: nêu ví dụ phản diện — "nếu test 1 trừ tiền vào account dùng chung mà test 2 assert số dư ban đầu, đổi thứ tự chạy là fail — đây là flaky test, trong CI của ngân hàng flaky test làm cả team mất niềm tin vào pipeline."
- Lỗi cần tránh: nhầm
@BeforeEach với @BeforeAll (chạy một lần cho cả class, phải static).
Câu 8. Phân biệt unit test thuần Mockito với @WebMvcTest và @SpringBootTest? Khi nào dùng cái nào?
- Mockito thuần (
@ExtendWith(MockitoExtension.class)): không Spring context, nhanh nhất → mặc định cho tầng service/logic nghiệp vụ.
@WebMvcTest: load mỗi tầng web → test controller (routing, validation, HTTP status, JSON) với service được thay bằng @MockBean.
@SpringBootTest: load cả context → integration test toàn luồng; chậm, dùng tiết kiệm.
- Ý ăn điểm: nguyên tắc chọn — "dùng thứ nhẹ nhất đủ để test điều mình muốn"; phân biệt được
@Mock (Mockito, không context) với @MockBean (thay bean trong Spring context).
- Lỗi cần tránh: dùng
@SpringBootTest cho mọi test "cho chắc" (dấu hiệu không hiểu chi phí); nói @Mock và @MockBean là một.
Câu 9. TDD là gì? Em nghĩ gì về việc viết test trước?
- TDD = Test-Driven Development: viết test trước khi viết code, theo vòng Red → Green → Refactor (test fail → code tối thiểu cho pass → dọn code, test vẫn xanh).
- Lợi ích: buộc nghĩ rõ requirement và edge case trước khi code; code sinh ra tự nhiên dễ test; có lưới an toàn khi refactor.
- Trả lời trung thực ở mức fresher: "em hiểu vòng lặp và đã thử ở mức bài nhỏ; em chưa dám nói thành thạo, nhưng em áp dụng được tinh thần của nó: nghĩ test case trước khi code, đặc biệt với logic tiền."
- Ý ăn điểm: sự trung thực + nắm đúng vòng Red-Green-Refactor; nói được rằng ngay cả khi không làm TDD thuần, viết test ngay sau khi code trong cùng commit vẫn là chuẩn phổ biến ở doanh nghiệp.
- Lỗi cần tránh: chém "em luôn làm TDD" rồi không mô tả nổi Red-Green-Refactor.
Câu 10. verify() trong Mockito dùng làm gì? Khác gì when...thenReturn?
when...thenReturn = dạy mock trả lời gì (stub input → output); verify = kiểm tra sau đó mock có bị gọi đúng như kỳ vọng không (đúng method, đúng tham số, đúng số lần).
- Dùng
verify khi điều cần test là hành vi/tương tác chứ không phải giá trị trả về: "khi chuyển tiền thành công phải gọi save đúng 2 lần", "khi fail không được gọi save" (verify(repo, never()).save(any())), "phải ghi audit log một lần" (times(1)).
- Ý ăn điểm: ví dụ ngân hàng — "gửi thông báo OTP/ghi audit trail là nghĩa vụ compliance, không có giá trị trả về để assert, nên phải
verify là nó đã được gọi."
- Lỗi cần tránh: verify mọi tương tác một cách máy móc (test giòn, sửa nội bộ là vỡ test) — chỉ verify tương tác có ý nghĩa nghiệp vụ.
3. Góc ngân hàng — nhấn xuyên suốt khi trả lời bất kỳ câu nào
- Test là compliance, không phải việc phụ: quy trình phát triển ở ngân hàng (SDLC có kiểm soát, audit nội bộ) yêu cầu bằng chứng kiểm thử; PR không có test cho logic mới thường bị reject. Nói được điều này chứng tỏ bạn hiểu môi trường mình ứng tuyển.
- Kinh tế học của bug: bug bắt ở unit test tốn vài phút của dev; cùng bug đó ra production ở luồng chuyển tiền tốn đối soát, hoàn tiền, sự cố truyền thông — đắt gấp hàng trăm lần. Đây là câu "chốt hạ" dùng được cho hầu hết câu hỏi why.
- BigDecimal mọi nơi: mọi ví dụ code về tiền — kể cả trong test — luôn dùng
BigDecimal và so sánh cẩn thận (lưu ý equals của BigDecimal phân biệt scale: 1000 ≠ 1000.00 — dùng compareTo khi cần). Nhất quán với mảng Java Core.
- Mindset "kẻ phá hoại": khi được hỏi thiết kế test case, luôn chủ động nghĩ input ác ý (số âm, null, chuyển cho chính mình, double-submit). Interviewer bank coi đây là tín hiệu fresher "hợp văn hóa rủi ro" — đáng giá hơn thuộc lòng API.
4. Ghi chú chiến thuật trước buổi phỏng vấn
- Nếu đồ án chưa có test — viết bổ sung NGAY, trước buổi phỏng vấn. Chọn class quan trọng nhất (service xử lý tiền/đơn hàng/nghiệp vụ chính), viết 5–10 unit test với JUnit 5 + Mockito: 2–3 happy case, còn lại là edge case (null, âm, không tồn tại, không đủ điều kiện). Commit lên repo. Chỉ mất 1 buổi nhưng biến câu trả lời "em chưa viết test" thành "em đã nhận ra thiếu sót và bổ sung — đây là các case em cover" — khác biệt rất lớn về ấn tượng.
- Chủ động lồng chuyện test khi kể về project, đừng đợi bị hỏi. Mẫu câu: "Phần khó nhất là logic X; để chắc nó đúng, em viết unit test cover các case A, B, C — nhờ đó khi refactor sang Y em phát hiện ngay một bug ở case biên." Kể được một bug cụ thể mà test đã bắt là ăn điểm gấp đôi.
- Chuẩn bị chạy lại được test trước mặt interviewer (nếu phỏng vấn có laptop/live coding):
mvn test phải xanh; nhớ tên vài test method của chính mình.
- Thuộc lòng bộ khung 4 dòng để trả lời nhanh mọi câu hỏi viết test:
@Mock repository → @InjectMocks service → when(...).thenReturn(...) → assert + verify. Cộng thêm assertThrows cho case lỗi là đủ xử lý 90% câu hỏi thực hành mức fresher.
- Câu chốt an toàn khi bị hỏi vượt tầm (Testcontainers, mutation testing...): "Em mới biết khái niệm ở mức đọc qua — [nói đúng 1 câu định nghĩa] — em chưa dùng thực tế nhưng rất muốn được học khi vào team." Trung thực + ham học luôn tốt hơn chém gió ở vòng phỏng vấn ngân hàng.