Giai đoạn 3: Collections và Exception
Mục lục (56)
Thời lượng: ~20 giờ, 5 tuần ở nhịp 4h/tuần
Điều kiện: xong Giai đoạn 2 — đặc biệt Bài 5 (polymorphism), Bài 6 (interface), Bài 7 (equals/hashCode)
Bối cảnh đã chốt: TestNG + PageFactory (@FindBy), web app có DOM thật
Kết quả mong đợi: đọc hiểu và tự viết được mọi đoạn xử lý danh sách phần tử; đọc stack trace của Selenium và biết ngay nguyên nhân thuộc loại nào
Giai đoạn này nhẹ hơn Giai đoạn 2
Giai đoạn 2 dạy khái niệm trừu tượng. Giai đoạn 3 chủ yếu là học các class có sẵn của Java — không có khái niệm mới nào khó bằng polymorphism.
Nhưng nó là giai đoạn có giá trị thực dụng cao nhất. Hai lý do:
driver.findElements(...)trả về mộtList. Không nắm List thì mọi việc liên quan tới nhiều phần tử — kiểm tra bảng, đếm kết quả, lặp qua danh sách — đều phải copy mẫu.- Toàn bộ exception của Selenium sẽ hết bí ẩn.
StaleElementReferenceExceptionmà Linh đã gặp ở project Appium sẽ có lời giải thích chính xác ở Bài 7, kèm phần riêng về cách PageFactory làm nó biểu hiện khác đi.
Lịch học gợi ý
| Tuần | Nội dung | Giờ |
|---|---|---|
| 1 | Bài 1 — List · Bài 2 — Generics | 4h |
| 2 | Bài 3 — Map · Bài 4 — Set | 4h |
| 3 | Bài 5 — Exception cơ bản · Bài 6 — throw và custom exception | 4h |
| 4 | Bài 7 — Exception của Selenium | 4h |
| 5 | Bài 8 — Nguyên tắc trong test · Bài 9 — Dự án | 4h |
Bài 1 — List và ArrayList
1.1 Vấn đề của array
Array có kích thước cố định (Giai đoạn 1 Bài 5.2). Nhưng khi lấy các hàng của một bảng, Linh không biết trước có bao nhiêu hàng.
List<String> ketQua = new ArrayList<>();
ketQua.add("PASS");
ketQua.add("FAIL");
ketQua.add("PASS");
System.out.println(ketQua.size()); // 3
Thêm bao nhiêu cũng được, không cần khai báo trước.
Chú ý dòng khai báo — nó là polymorphism từ Giai đoạn 2 Bài 5:
Listlà interfaceArrayListlà class implement nó- Khai báo theo
List(trừu tượng), tạo bằngArrayList(cụ thể)
Đúng nguyên tắc "khai báo theo kiểu trừu tượng nhất mà vẫn đủ dùng". <> ở bên phải gọi là diamond operator — Java tự suy ra kiểu từ bên trái.
1.2 Các method cần thuộc
List<String> ds = new ArrayList<>();
ds.add("PASS"); // thêm vào cuối
ds.add(0, "FAIL"); // chèn vào vị trí 0
ds.get(0); // lấy phần tử — KHÔNG phải ds[0]
ds.set(0, "BLOCKED"); // thay thế
ds.size(); // số lượng — KHÔNG phải length hay length()
ds.isEmpty();
ds.contains("PASS"); // dùng equals() bên trong
ds.indexOf("PASS"); // -1 nếu không có
ds.remove(0); // xoá theo vị trí
ds.remove("PASS"); // xoá theo giá trị (cái đầu tiên tìm thấy)
ds.clear();
Ba cách đếm khác nhau, dễ lẫn:
| Kiểu | Cú pháp |
|---|---|
| Array | arr.length (thuộc tính) |
| String | s.length() (method) |
| List | list.size() (method) |
1.3 Lặp
for (String kq : ketQua) {
System.out.println(kq);
}
for (int i = 0; i < ketQua.size(); i++) {
System.out.println((i + 1) + ". " + ketQua.get(i));
}
1.4 Nối vào Selenium — điểm quan trọng nhất của bài
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
System.out.println("Số hàng: " + rows.size());
for (WebElement row : rows) {
System.out.println(row.getText());
}
Hai điều phải nhớ về findElements (số nhiều):
Nó không bao giờ ném exception. Không tìm thấy gì thì trả về list rỗng, size() == 0. Khác hẳn findElement (số ít) — cái đó ném NoSuchElementException.
Hệ quả trực tiếp, và đây là mẫu dùng liên tục:
public boolean isDisplayed(By locator) {
return !driver.findElements(locator).isEmpty();
}
Đây là cách kiểm tra "phần tử có tồn tại không" mà không cần try/catch. Nhiều người viết:
try {
driver.findElement(locator);
return true;
} catch (NoSuchElementException e) {
return false;
}
Cách này chạy được nhưng dùng exception để điều khiển luồng bình thường — chậm hơn và khó đọc hơn. Bài 8 sẽ nói kỹ tại sao đó là thói quen xấu.
1.5 Ba cái bẫy
Arrays.asList() có kích thước cố định:
List<String> ds = Arrays.asList("A", "B");
ds.add("C"); // UnsupportedOperationException lúc chạy!
Nó chỉ bọc array lại, không tạo list mới. Muốn list thêm được:
List<String> ds = new ArrayList<>(Arrays.asList("A", "B"));
List.of() (Java 9+) là bất biến hoàn toàn:
List<String> ds = List.of("A", "B");
ds.add("C"); // UnsupportedOperationException
ds.set(0, "X"); // UnsupportedOperationException
Tiện cho dữ liệu chỉ đọc, nhưng phải biết là nó không sửa được.
Xoá trong khi lặp → ConcurrentModificationException:
for (String kq : ds) {
if (kq.equals("FAIL")) ds.remove(kq); // CRASH
}
Ba cách đúng:
ds.removeIf(kq -> kq.equals("FAIL")); // gọn nhất (Java 8+)
Iterator<String> it = ds.iterator(); // cách kinh điển
while (it.hasNext()) {
if (it.next().equals("FAIL")) it.remove();
}
for (int i = ds.size() - 1; i >= 0; i--) { // lặp ngược
if (ds.get(i).equals("FAIL")) ds.remove(i);
}
removeIf dùng lambda — nội dung Giai đoạn 4, nhưng cứ dùng trước, cú pháp đủ đơn giản.
Bẫy remove với List<Integer>:
List<Integer> so = new ArrayList<>(List.of(10, 20, 30));
so.remove(1); // xoá VỊ TRÍ 1 → còn [10, 30]
so.remove(Integer.valueOf(20)); // xoá GIÁ TRỊ 20
Hai overload khác nhau: remove(int) theo vị trí, remove(Object) theo giá trị. Với List<Integer> thì rất dễ gọi sai cái mình muốn.
Bài tập 1
1.1 Tạo List<String> chứa 6 kết quả test lẫn PASS/FAIL/BLOCKED. Đếm từng loại, in tỉ lệ pass 2 chữ số thập phân.
1.2 Viết method List<String> locCaseFail(List<String> tenCase, List<String> ketQua) trả về danh sách tên case bị fail. Xử lý trường hợp hai list khác độ dài.
1.3 Đoán kết quả rồi kiểm tra:
List<String> ds = Arrays.asList("A", "B", "C");
System.out.println(ds.size());
ds.set(0, "X");
System.out.println(ds);
ds.add("D");
1.4 Sửa đoạn này cho không crash, ba cách khác nhau:
List<String> ds = new ArrayList<>(List.of("PASS", "FAIL", "PASS", "FAIL"));
for (String kq : ds) {
if (kq.equals("FAIL")) ds.remove(kq);
}
1.5 Viết method boolean isDisplayed(String locator) giả lập theo mẫu findElements(...).isEmpty(). Giả lập findElements bằng một method trả về List<String> rỗng hoặc có phần tử.
1.6 Câu hỏi: tại sao Selenium thiết kế findElement ném exception mà findElements trả list rỗng? Nếu cả hai cùng ném thì bất tiện ở đâu?
Đáp án bài 1
1.1
List<String> ketQua = new ArrayList<>(List.of("PASS", "FAIL", "PASS", "BLOCKED", "PASS", "FAIL"));
int pass = 0, fail = 0, blocked = 0;
for (String kq : ketQua) {
if (kq.equals("PASS")) pass++;
else if (kq.equals("FAIL")) fail++;
else blocked++;
}
System.out.println("PASS: " + pass + ", FAIL: " + fail + ", BLOCKED: " + blocked);
System.out.println(String.format(java.util.Locale.US, "Tỉ lệ pass: %.2f%%", pass * 100.0 / ketQua.size()));
Bài 3 sẽ cho cách gọn hơn hẳn bằng Map, không cần khai báo sẵn từng biến đếm.
1.2
public static List<String> locCaseFail(List<String> tenCase, List<String> ketQua) {
List<String> kq = new ArrayList<>();
if (tenCase == null || ketQua == null || tenCase.size() != ketQua.size()) {
return kq; // trả list RỖNG, không trả null
}
for (int i = 0; i < tenCase.size(); i++) {
if ("FAIL".equals(ketQua.get(i))) kq.add(tenCase.get(i));
}
return kq;
}
Điểm quan trọng: trả về list rỗng, không trả null. Người gọi lặp ngay được, không cần kiểm tra null. Đây chính là lý do Selenium thiết kế findElements như vậy — xem 1.6.
1.3
3
[X, B, C] ← set() ĐƯỢC, vì nó không đổi kích thước
UnsupportedOperationException ← add() thì không được
Arrays.asList cho sửa phần tử nhưng không cho thêm/bớt. Nửa vời, và là lý do nên bọc trong new ArrayList<>(...).
1.4
ds.removeIf(kq -> kq.equals("FAIL"));
Iterator<String> it = ds.iterator();
while (it.hasNext()) {
if (it.next().equals("FAIL")) it.remove();
}
for (int i = ds.size() - 1; i >= 0; i--) {
if (ds.get(i).equals("FAIL")) ds.remove(i);
}
Lặp ngược hoạt động vì xoá phần tử ở vị trí i chỉ làm dịch các phần tử sau nó — mà những phần tử đó thì đã đi qua rồi.
1.5
public static boolean isDisplayed(String locator) {
return !findElements(locator).isEmpty();
}
public static List<String> findElements(String locator) {
if (locator.equals("[name='email']")) return List.of("element-gia-lap");
return List.of();
}
1.6 Vì hai method dùng cho hai mục đích khác nhau.
findElement dùng khi Linh tin chắc phần tử có ở đó — nếu không có thì test sai, và exception kèm stack trace nói rõ locator nào thất bại là điều mình muốn.
findElements dùng khi số lượng là câu trả lời: bảng có bao nhiêu hàng, có bao nhiêu lỗi validate. Số lượng bằng 0 là một câu trả lời hợp lệ, không phải lỗi. Nếu nó cũng ném exception thì mọi lần đếm đều phải bọc try/catch — và không phân biệt được "không có phần tử nào" với "locator viết sai".
Bài 2 — Generics ở mức đọc hiểu
2.1 Cặp ngoặc nhọn nói gì
List<String> ketQua; // list chứa String
List<WebElement> rows; // list chứa WebElement
Map<String, Integer> demTheoLoai; // map: khoá String, giá trị Integer
<...> nói cho compiler biết list này chứa loại gì. Lợi ích:
List<String> ds = new ArrayList<>();
ds.add(123); // LỖI BIÊN DỊCH — bắt được ngay
String s = ds.get(0); // không cần ép kiểu
Không có generics (kiểu cũ, gọi là raw type):
List ds = new ArrayList();
ds.add("PASS");
ds.add(123); // cho phép!
String s = (String) ds.get(1); // ClassCastException lúc CHẠY
Generics chuyển lỗi từ lúc chạy sang lúc biên dịch. Với automation, đó là khác biệt giữa "compiler báo ngay" và "test fail lúc 2 giờ sáng trên CI".
Nguyên tắc: luôn viết kiểu trong <>. Thấy List trơ trọi trong code cũ thì đó là chỗ nên sửa.
2.2 Không dùng được primitive
List<int> ds; // LỖI
List<Integer> ds; // đúng
Generics chỉ nhận kiểu reference. Java có wrapper class cho từng primitive:
| Primitive | Wrapper |
|---|---|
int |
Integer |
double |
Double |
boolean |
Boolean |
char |
Character |
long |
Long |
Java tự chuyển qua lại (gọi là autoboxing):
List<Integer> ds = new ArrayList<>();
ds.add(5); // int 5 tự thành Integer
int x = ds.get(0); // Integer tự thành int
Một bẫy đi kèm — wrapper là reference nên có thể null:
Map<String, Integer> dem = new HashMap<>();
int x = dem.get("khong-co-key"); // NPE! get trả null, không ép được sang int
Và bẫy so sánh:
Integer a = 1000, b = 1000;
a == b // false — hai object khác nhau
a.equals(b) // true
Đúng nguyên tắc từ Giai đoạn 1 Bài 7: .equals() cho reference. (Với số nhỏ từ -128 đến 127 Java tái sử dụng object nên == tình cờ ra true — chính xác cùng kiểu bẫy như string pool. Đừng dựa vào nó.)
2.3 Đọc các khai báo phức tạp
Map<String, List<String>> bugTheoSeverity;
Đọc từ ngoài vào: một Map, khoá là String, giá trị là một List các String. Ví dụ: "Critical" → ["#101", "#104"].
List<Map<String, String>> duLieuTest;
Một List, mỗi phần tử là một Map String→String. Đây là dạng chuẩn khi đọc test data từ Excel: mỗi phần tử là một hàng, mỗi Map là cột→giá trị.
Chưa cần tự viết class generic. Chỉ cần đọc hiểu — đủ cho toàn bộ công việc automation ở mức này.
Bài tập 2
2.1 Viết khai báo cho: danh sách các tên case; map từ case id sang kết quả; map từ severity sang danh sách bug id; danh sách các hàng Excel (mỗi hàng là cột→giá trị).
2.2 Đọc và diễn giải bằng lời:
Map<String, List<WebElement>> a;
List<List<String>> b;
Map<Integer, Map<String, Boolean>> c;
2.3 Đoán dòng nào lỗi biên dịch, dòng nào lỗi lúc chạy:
List<Integer> ds = new ArrayList<>();
ds.add(5);
ds.add("6");
int x = ds.get(0);
Integer y = null;
int z = y;
2.4 Tại sao đoạn này crash, và sửa thế nào?
Map<String, Integer> dem = new HashMap<>();
dem.put("PASS", 3);
int fail = dem.get("FAIL");
Đáp án bài 2
2.1
List<String> tenCase;
Map<String, String> ketQuaTheoId;
Map<String, List<String>> bugTheoSeverity;
List<Map<String, String>> duLieuExcel;
2.2
a— Map từ String sang một danh sách WebElement. Ví dụ: tên cột → các ô của cột đó.b— danh sách của danh sách String. Ví dụ: cả bảng, mỗi phần tử là một hàng.c— Map từ số sang Map. Ví dụ: số thứ tự hàng → (tên cột → đã pass chưa).
2.3
ds.add("6"); // LỖI BIÊN DỊCH — List<Integer> không nhận String
int z = y; // LỖI LÚC CHẠY — NPE, không ép null sang int được
Hai dòng còn lại hợp lệ. Dòng int z = y là bẫy autoboxing đáng nhớ nhất: nhìn như phép gán số bình thường mà lại ném NPE.
2.4 dem.get("FAIL") trả null (không có khoá đó), và gán null vào int là NPE.
Ba cách sửa:
int fail = dem.getOrDefault("FAIL", 0); // gọn nhất
Integer f = dem.get("FAIL");
int fail = (f != null) ? f : 0;
if (dem.containsKey("FAIL")) { ... }
getOrDefault là cách nên dùng mặc định — Bài 3 sẽ dùng nó liên tục.
Bài 3 — Map và HashMap
3.1 Cặp khoá–giá trị
Map<String, Integer> demTheoKetQua = new HashMap<>();
demTheoKetQua.put("PASS", 312);
demTheoKetQua.put("FAIL", 30);
demTheoKetQua.put("BLOCKED", 12);
System.out.println(demTheoKetQua.get("PASS")); // 312
Khoá là duy nhất — put cùng khoá lần nữa thì ghi đè giá trị cũ.
3.2 Method cần thuộc
map.put(k, v);
map.get(k); // null nếu không có khoá
map.getOrDefault(k, macDinh); // an toàn hơn, dùng cái này
map.containsKey(k);
map.containsValue(v);
map.remove(k);
map.size();
map.isEmpty();
map.keySet(); // Set các khoá
map.values(); // Collection các giá trị
map.entrySet(); // Set các cặp
Lặp qua Map:
for (Map.Entry<String, Integer> e : demTheoKetQua.entrySet()) {
System.out.println(e.getKey() + ": " + e.getValue());
}
for (String key : demTheoKetQua.keySet()) {
System.out.println(key + ": " + demTheoKetQua.get(key));
}
Cách đầu hiệu quả hơn (không phải tra lại từng khoá) và là cách nên dùng.
3.3 Mẫu đếm — dùng suốt
List<String> ketQua = List.of("PASS", "FAIL", "PASS", "BLOCKED", "PASS", "FAIL");
Map<String, Integer> dem = new HashMap<>();
for (String kq : ketQua) {
dem.put(kq, dem.getOrDefault(kq, 0) + 1);
}
System.out.println(dem); // {PASS=3, FAIL=2, BLOCKED=1}
Một dòng thay cho ba biến đếm ở bài 1.1 — và nó hoạt động với bất kỳ tập giá trị nào, kể cả những giá trị Linh chưa biết trước. Đây là mẫu quan trọng nhất của bài: đếm bug theo severity, theo trạng thái, theo người xử lý, đều dùng đúng ba dòng này.
3.4 Ba loại Map — thứ tự khác nhau
new HashMap<>(); // KHÔNG đảm bảo thứ tự
new LinkedHashMap<>(); // giữ thứ tự thêm vào
new TreeMap<>(); // sắp xếp theo khoá
HashMap in ra thứ tự tùy ý và có thể khác giữa các lần chạy. Với báo cáo cần thứ tự ổn định thì dùng LinkedHashMap hoặc TreeMap. Đây là chi tiết nhỏ nhưng gây bối rối thật khi báo cáo in ra thứ tự lộn xộn mỗi lần chạy.
3.5 Dùng thật trong automation
Đọc cấu hình:
Map<String, String> config = new HashMap<>();
config.put("browser", "chrome");
config.put("baseUrl", "https://staging.example.com");
config.put("timeout", "10");
String browser = config.getOrDefault("browser", "chrome");
Test data một hàng:
Map<String, String> hang = new HashMap<>();
hang.put("email", "linh@test.com");
hang.put("password", "123456");
hang.put("expected", "success");
Đây chính là dạng @DataProvider của TestNG sẽ trả về ở Giai đoạn 5.
Nhóm bug theo severity:
Map<String, List<String>> theoSeverity = new HashMap<>();
for (Bug b : danhSachBug) {
theoSeverity.computeIfAbsent(b.getSeverity(), k -> new ArrayList<>()).add(b.getId());
}
computeIfAbsent nghĩa là "nếu chưa có khoá này thì tạo list rỗng trước, rồi trả về list đó". Không có nó thì phải viết:
if (!theoSeverity.containsKey(sev)) theoSeverity.put(sev, new ArrayList<>());
theoSeverity.get(sev).add(b.getId());
Hai cách tương đương; cách đầu là dạng viết chuẩn hiện nay.
Bài tập 3
3.1 Cho List<String> gồm 10 kết quả test lẫn nhau. Dùng mẫu đếm ở 3.3, in số lượng từng loại và tỉ lệ phần trăm.
3.2 Tạo Map<String, String> từ case id sang kết quả cho 5 case. Viết method String getKetQua(Map<String,String> m, String id) trả về "NOT_RUN" nếu không có id đó.
3.3 Cho List<Bug> (dùng class Bug từ Giai đoạn 2). Nhóm bug id theo severity vào Map<String, List<String>>. In từng nhóm.
3.4 Đếm số lần xuất hiện của từng từ trong một câu. Gợi ý: split(" ") rồi dùng mẫu đếm.
3.5 Đoán kết quả:
Map<String, Integer> m = new HashMap<>();
m.put("A", 1);
m.put("B", 2);
m.put("A", 3);
System.out.println(m.size());
System.out.println(m.get("A"));
System.out.println(m.get("C"));
System.out.println(m.getOrDefault("C", 0));
3.6 Chạy đoạn này 3 lần, quan sát thứ tự in. Rồi đổi sang LinkedHashMap và TreeMap, so sánh:
Map<String, Integer> m = new HashMap<>();
m.put("Zebra", 1); m.put("Alpha", 2); m.put("Mango", 3);
System.out.println(m);
Đáp án bài 3
3.1
List<String> ketQua = List.of("PASS","FAIL","PASS","BLOCKED","PASS","FAIL","PASS","PASS","BLOCKED","PASS");
Map<String, Integer> dem = new LinkedHashMap<>();
for (String kq : ketQua) {
dem.put(kq, dem.getOrDefault(kq, 0) + 1);
}
for (Map.Entry<String, Integer> e : dem.entrySet()) {
System.out.println(String.format(java.util.Locale.US, "%s: %d (%.2f%%)",
e.getKey(), e.getValue(), e.getValue() * 100.0 / ketQua.size()));
}
Dùng LinkedHashMap để thứ tự in ổn định giữa các lần chạy.
3.2
public static String getKetQua(Map<String, String> m, String id) {
return m.getOrDefault(id, "NOT_RUN");
}
3.3
Map<String, List<String>> theoSeverity = new LinkedHashMap<>();
for (Bug b : danhSachBug) {
theoSeverity.computeIfAbsent(b.getSeverity(), k -> new ArrayList<>()).add(b.getId());
}
for (Map.Entry<String, List<String>> e : theoSeverity.entrySet()) {
System.out.println(e.getKey() + " (" + e.getValue().size() + "): " + e.getValue());
}
3.4
String cau = "test case pass test case fail test";
Map<String, Integer> dem = new LinkedHashMap<>();
for (String tu : cau.split(" ")) {
dem.put(tu, dem.getOrDefault(tu, 0) + 1);
}
System.out.println(dem); // {test=3, case=2, pass=1, fail=1}
3.5
2 ← put("A",...) lần hai GHI ĐÈ, không thêm khoá mới
3 ← giá trị mới
null ← không có khoá C
0 ← getOrDefault
3.6 HashMap in ra thứ tự tùy nội bộ (thường ổn định giữa các lần chạy với cùng dữ liệu, nhưng không được đảm bảo và có thể khác khi dữ liệu khác). LinkedHashMap → Zebra, Alpha, Mango (thứ tự thêm). TreeMap → Alpha, Mango, Zebra (theo alphabet).
Bài học: nếu thứ tự có ý nghĩa với người đọc báo cáo, phải chọn loại Map có đảm bảo thứ tự. Đừng dựa vào việc HashMap "thấy có vẻ đúng thứ tự".
Bài 4 — Set và equals/hashCode
4.1 Tập hợp không trùng lặp
Set<String> daXuLy = new HashSet<>();
daXuLy.add("TC001");
daXuLy.add("TC002");
daXuLy.add("TC001"); // bị bỏ qua — đã có
System.out.println(daXuLy.size()); // 2
add trả về boolean: true nếu thêm mới, false nếu đã có. Rất tiện để kiểm tra trùng:
if (!daXuLy.add(caseId)) {
System.out.println("Case này đã chạy: " + caseId);
}
Method: add, contains, remove, size, isEmpty. Không có get(index) — Set không có thứ tự nên không có vị trí.
Ba loại: HashSet (nhanh nhất, không thứ tự), LinkedHashSet (giữ thứ tự thêm), TreeSet (sắp xếp).
4.2 Dùng thật
Lọc trùng:
List<String> coTrung = List.of("TC001", "TC002", "TC001", "TC003", "TC002");
Set<String> khongTrung = new LinkedHashSet<>(coTrung);
System.out.println(khongTrung); // [TC001, TC002, TC003]
Một dòng. Rất hữu ích khi kiểm tra file test case có id bị trùng — việc Linh làm thật với các file BM27.
Kiểm tra id trùng:
public static List<String> timIdTrung(List<String> ids) {
Set<String> daThay = new HashSet<>();
List<String> trung = new ArrayList<>();
for (String id : ids) {
if (!daThay.add(id)) trung.add(id);
}
return trung;
}
4.3 Set cần equals/hashCode đúng — đây là điểm chốt
Với Set<String> thì không sao, vì String đã override đúng. Nhưng với class tự viết:
Set<Bug> bugs = new HashSet<>();
bugs.add(new Bug("#101", "Login sai"));
bugs.add(new Bug("#101", "Login sai"));
System.out.println(bugs.size()); // 2 nếu KHÔNG override equals/hashCode
Hai object có cùng dữ liệu nhưng HashSet coi là khác nhau — vì bản mặc định của equals chỉ so sánh địa chỉ (Giai đoạn 2 Bài 7.3).
Override đúng thì ra 1:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return id.equals(((Bug) o).id);
}
@Override
public int hashCode() { return id.hashCode(); }
Và đây là lý do quy tắc "override equals thì phải override hashCode" tồn tại:
HashSet (và HashMap) hoạt động theo hai bước — trước tiên dùng hashCode() để xác định ô lưu, rồi mới dùng equals() để so trong ô đó. Nếu hai object equals nhau mà hashCode khác nhau, chúng rơi vào hai ô khác nhau và equals không bao giờ được gọi. Kết quả: bỏ object vào Set rồi contains() trả false, hoặc Map.get() không tìm thấy khoá mình vừa put.
Lỗi này rất khó tìm vì code trông hoàn toàn hợp lý. Giờ Linh biết chỗ để nhìn.
Bài tập 4
4.1 Cho List<String> các case id có trùng. Dùng Set lọc trùng và in danh sách các id bị trùng.
4.2 Với class Bug, tạo Set<Bug> và thêm hai bug cùng id. Kiểm tra size() trước và sau khi override equals/hashCode.
4.3 Thí nghiệm: override equals nhưng cố ý để hashCode() trả về số random (return new Random().nextInt();). Thêm một bug vào Set rồi gọi contains() với chính object đó. Quan sát và giải thích.
4.4 Viết method Set<String> layCacSeverityDaDung(List<Bug> bugs) trả về tập các severity xuất hiện trong danh sách.
4.5 Chọn List, Set, hay Map cho từng tình huống:
- Danh sách hàng lấy từ
findElements - Các case id đã chạy, cần kiểm tra nhanh "đã chạy chưa"
- Số lượng bug theo từng severity
- Thứ tự các bước trong một test case
- Danh sách email không được trùng
- Cấu hình browser/url/timeout
Đáp án bài 4
4.1
List<String> ids = List.of("TC001", "TC002", "TC001", "TC003", "TC002", "TC001");
Set<String> daThay = new HashSet<>();
Set<String> trung = new LinkedHashSet<>();
for (String id : ids) {
if (!daThay.add(id)) trung.add(id);
}
System.out.println("Duy nhất: " + daThay.size() + " → " + new TreeSet<>(daThay));
System.out.println("Bị trùng: " + trung); // [TC001, TC002]
Dùng Set cho trung để bản thân danh sách trùng cũng không bị lặp (TC001 xuất hiện 3 lần).
4.2 Trước khi override: 2. Sau: 1.
4.3 contains() trả về false — dù object đó vừa được thêm vào và equals với chính nó.
Lý do: mỗi lần gọi hashCode() ra một số khác, nên contains đi tìm ở một ô hoàn toàn khác với ô đã lưu. equals không bao giờ được gọi tới.
Thí nghiệm này đáng làm vì nó cho thấy hợp đồng equals/hashCode không phải quy tắc hình thức — vi phạm nó làm Set/Map hỏng một cách âm thầm.
4.4
public static Set<String> layCacSeverityDaDung(List<Bug> bugs) {
Set<String> kq = new LinkedHashSet<>();
for (Bug b : bugs) kq.add(b.getSeverity());
return kq;
}
4.5
- Hàng từ
findElements→ List (có thứ tự, có thể trùng nội dung, cần truy cập theo vị trí) - Case id đã chạy → Set (chỉ cần biết có/không,
containsnhanh) - Số bug theo severity → Map (khoá → số lượng)
- Thứ tự các bước → List (thứ tự là bản chất)
- Email không trùng → Set
- Cấu hình → Map (tên thuộc tính → giá trị)
Bài 5 — Exception: cơ bản
5.1 Cây phân loại
Throwable
├── Error ← lỗi hệ thống, KHÔNG bắt (OutOfMemoryError, StackOverflowError)
│ └── AssertionError ← TestNG/JUnit Assert ném cái này!
└── Exception
├── IOException ← CHECKED
├── InterruptedException ← CHECKED
└── RuntimeException ← UNCHECKED
├── NullPointerException
├── IllegalArgumentException
├── IndexOutOfBoundsException
└── WebDriverException ← mọi exception của Selenium
├── NoSuchElementException
├── StaleElementReferenceException
├── TimeoutException
└── ...
Hai nhánh quan trọng, ghi lại ngay:
Mọi exception của Selenium đều là unchecked (thuộc nhánh RuntimeException). Đó là lý do gọi driver.findElement(...) không cần try/catch và không cần throws — compiler không bắt buộc.
AssertionError thuộc nhánh Error, không phải Exception. Hệ quả rất quan trọng ở Bài 8: catch (Exception e) không bắt được assertion fail của TestNG.
5.2 Checked vs unchecked
Checked — compiler bắt buộc xử lý:
public void docFile(String path) throws IOException { // khai báo ném ra
...
}
hoặc bắt tại chỗ:
try {
Files.readAllLines(Path.of(path));
} catch (IOException e) {
...
}
Không làm một trong hai thì lỗi biên dịch. Gặp nhiều nhất khi đọc file — chính là Giai đoạn 4.
Unchecked — compiler không quan tâm. NullPointerException không ai khai báo throws cả.
Ý tưởng phía sau: checked cho những việc có thể thất bại vì lý do bên ngoài (file không tồn tại, mạng đứt) — lập trình viên buộc phải nghĩ tới. Unchecked cho lỗi logic của chính code — cách sửa là sửa code, không phải bắt exception.
5.3 try / catch / finally
try {
int x = 10 / 0;
} catch (ArithmeticException e) {
System.out.println("Lỗi: " + e.getMessage());
} finally {
System.out.println("Luôn chạy");
}
finally luôn chạy — kể cả khi có return trong try, kể cả khi exception không được bắt. Dùng để dọn dẹp: đóng file, driver.quit().
Bắt nhiều loại, thứ tự từ cụ thể tới tổng quát:
try {
...
} catch (NoSuchElementException e) {
...
} catch (WebDriverException e) { // cha của cái trên
...
} catch (Exception e) {
...
}
Đảo ngược thứ tự là lỗi biên dịch (exception has already been caught) — vì nhánh cha đã bắt hết, nhánh con không bao giờ tới.
Multi-catch khi xử lý giống nhau:
catch (NoSuchElementException | TimeoutException e) { ... }
5.4 Đọc thông tin từ exception
catch (WebDriverException e) {
e.getMessage(); // mô tả
e.getClass().getSimpleName(); // tên loại
e.printStackTrace(); // in toàn bộ stack — dùng khi debug tay
}
Cách đọc stack trace: dòng đầu là loại exception và thông báo. Các dòng at ... là chuỗi lời gọi, từ trong ra ngoài — dòng at đầu tiên là nơi lỗi xảy ra, dòng cuối là main hoặc method test. Tìm dòng at đầu tiên có tên package của mình — đó thường là chỗ cần sửa, vì các dòng trên nó là code của thư viện.
5.5 try-with-resources
try (BufferedReader br = new BufferedReader(new FileReader("data.csv"))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("Không đọc được file: " + e.getMessage());
}
Tài nguyên khai báo trong try (...) tự động được đóng, kể cả khi có exception. Không cần finally để close(). Đây là cách viết chuẩn khi đọc file — Giai đoạn 4 sẽ dùng liên tục.
5.6 Cái bẫy: return trong finally
public static int test() {
try {
return 1;
} finally {
return 2; // ĂN MẤT return ở trên
}
}
Trả về 2. Đừng bao giờ return trong finally — IntelliJ có cảnh báo cho trường hợp này.
Bài tập 5
5.1 Viết method chia hai số, bắt ArithmeticException, trả về 0.0 khi chia cho 0. Thêm finally in một dòng, xác nhận nó luôn chạy.
5.2 Với mỗi exception, checked hay unchecked?
NullPointerException, IOException, NoSuchElementException, NumberFormatException, InterruptedException, StaleElementReferenceException, IllegalArgumentException
5.3 Đoán thứ tự in:
try {
System.out.println("A");
throw new RuntimeException("loi");
} catch (RuntimeException e) {
System.out.println("B");
return;
} finally {
System.out.println("C");
}
5.4 Tại sao đoạn này lỗi biên dịch?
try {
...
} catch (Exception e) {
...
} catch (NullPointerException e) {
...
}
5.5 Viết method int docSoAnToan(String s) chuyển String sang int, trả về -1 nếu không chuyển được. Xử lý cả null.
5.6 Đọc stack trace này, chỉ ra dòng nào là chỗ cần sửa và tại sao:
java.lang.NullPointerException: Cannot invoke "String.trim()" because "text" is null
at java.base/java.lang.String.trim(String.java:2842)
at com.newwave.pages.BasePage.getText(BasePage.java:45)
at com.newwave.pages.LoginPage.getErrorMessage(LoginPage.java:38)
at com.newwave.tests.LoginTest.loginSaiMatKhau(LoginTest.java:22)
Đáp án bài 5
5.1
public static double chia(int a, int b) {
try {
return a / (double) b;
} catch (ArithmeticException e) {
return 0.0;
} finally {
System.out.println("Đã thực hiện phép chia");
}
}
Lưu ý: với (double) b thì chia cho 0 ra Infinity chứ không ném exception (Giai đoạn 1 Bài 1.4) — nên catch không bao giờ chạy. Muốn thấy exception thật thì bỏ ép kiểu. Đây là bài học kép: try/catch chỉ hữu ích nếu exception thực sự có thể xảy ra, và với double thì kiểm tra b == 0 trước mới đúng cách.
5.2
- Checked:
IOException,InterruptedException - Unchecked: tất cả còn lại.
NoSuchElementExceptionvàStaleElementReferenceExceptionthuộcWebDriverException→ unchecked.
5.3 A, B, C. finally chạy trước khi return thực sự thoát ra.
5.4 catch (Exception e) đã bắt mọi thứ, nên nhánh NullPointerException bên dưới không bao giờ tới được. Compiler báo lỗi. Đảo thứ tự: cụ thể trước, tổng quát sau.
5.5
public static int docSoAnToan(String s) {
if (s == null) return -1;
try {
return Integer.parseInt(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
s.trim() bên trong vì dữ liệu từ Excel/CSV rất hay có khoảng trắng. Guard clause cho null đứng trước để không phải bắt cả NPE.
5.6 Chỗ cần sửa: BasePage.java:45 — dòng at đầu tiên thuộc code của mình. Dòng trên nó là code của thư viện Java (String.trim), không phải chỗ có lỗi.
Nguyên nhân cụ thể: getText() gọi .trim() trên một giá trị null. Nghĩa là driver.findElement(...).getText() trả về null, hoặc biến trung gian chưa được gán. Ba dòng còn lại cho biết đường đi tới đó: test gọi getErrorMessage(), cái đó gọi getText().
Cách đọc này tiết kiệm rất nhiều thời gian — thay vì đọc cả stack trace 40 dòng, tìm dòng đầu tiên có package của mình.
Bài 6 — throw và custom exception
6.1 Tự ném exception
public void setThoiGianGiay(int giay) {
if (giay < 0) {
throw new IllegalArgumentException("Thời gian không thể âm: " + giay);
}
this.thoiGianGiay = giay;
}
Đã gặp ở Giai đoạn 2 Bài 3.2. Nguyên tắc: thất bại sớm, thất bại rõ ràng. Ném exception ngay khi phát hiện dữ liệu sai tốt hơn là để nó đi tiếp và gây lỗi ở một chỗ hoàn toàn khác.
Chọn loại nào cho đúng:
| Tình huống | Exception |
|---|---|
| Tham số sai | IllegalArgumentException |
| Object đang ở trạng thái không cho phép | IllegalStateException |
| Chưa cài đặt | UnsupportedOperationException |
| Lỗi đặc thù nghiệp vụ của mình | custom exception |
6.2 throw và throws khác nhau
public void doc(String path) throws IOException { // KHAI BÁO: method này có thể ném
if (path == null) {
throw new IllegalArgumentException("path null"); // HÀNH ĐỘNG: ném ngay
}
}
throws— ở phần khai báo method, danh sách các checked exception có thể ném rathrow— câu lệnh, ném một exception cụ thể ngay lúc đó
Chỉ checked exception mới cần khai báo throws. Ném RuntimeException thì không cần.
6.3 Custom exception
public class ElementNotFoundException extends RuntimeException {
public ElementNotFoundException(String message) {
super(message);
}
public ElementNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
Đây là inheritance từ Giai đoạn 2 Bài 4 — extends RuntimeException, constructor gọi super.
Chọn RuntimeException (unchecked) cho framework test là quyết định đúng trong hầu hết trường hợp: nếu locator sai thì test phải fail, không có cách "xử lý" nào cứu được, nên bắt buộc mọi lời gọi phải try/catch chỉ làm code rối.
Dùng:
public WebElement findWithMessage(By locator, String moTa) {
List<WebElement> els = driver.findElements(locator);
if (els.isEmpty()) {
throw new ElementNotFoundException(
"Không tìm thấy " + moTa + " với locator: " + locator);
}
return els.get(0);
}
Giá trị thật: thông báo lỗi nói cái gì không tìm thấy theo ngôn ngữ nghiệp vụ, không chỉ nói locator nào. Khi test fail trên CI lúc 2 giờ sáng, Không tìm thấy nút Đăng nhập hữu ích hơn hẳn no such element: {"method":"css selector","selector":".btn-primary"}.
6.4 Giữ nguyên nguyên nhân gốc
try {
return Integer.parseInt(cell);
} catch (NumberFormatException e) {
throw new TestDataException("Ô dữ liệu không phải số: " + cell, e);
}
Tham số thứ hai (e) là cause. Stack trace sẽ hiện cả hai tầng, kèm dòng Caused by:. Bỏ nó đi thì mất thông tin gốc — lỗi rất phổ biến khi bọc exception.
Bài tập 6
6.1 Viết custom exception TestDataException extends RuntimeException với hai constructor như mẫu 6.3.
6.2 Viết method Map<String,String> docHang(String csvLine, String[] cotNames) — tách dòng CSV, ghép với tên cột. Ném TestDataException nếu số ô không khớp số cột, thông báo nói rõ số mong đợi và số thực tế.
6.3 Viết method int parseCaseNumber(String caseId) lấy số từ id dạng TC0042. Ném TestDataException với cause giữ nguyên nếu định dạng sai.
6.4 Với mỗi tình huống, chọn: ném exception, trả về giá trị mặc định, hay trả về list rỗng?
- Locator không khớp phần tử nào, khi đang kiểm tra "nút này có hiện không"
- Locator không khớp, khi đang chuẩn bị click nút submit
- File config không tồn tại
- Ô Excel trống ở cột không bắt buộc
- Ô Excel trống ở cột email đầu vào
6.5 Sửa đoạn này — nó có hai vấn đề:
public int docSo(String s) {
try {
return Integer.parseInt(s);
} catch (Exception e) {
throw new RuntimeException("Loi");
}
}
Đáp án bài 6
6.1
public class TestDataException extends RuntimeException {
public TestDataException(String message) { super(message); }
public TestDataException(String message, Throwable cause) { super(message, cause); }
}
6.2
public static Map<String, String> docHang(String csvLine, String[] cotNames) {
if (csvLine == null) throw new TestDataException("Dòng CSV là null");
String[] o = csvLine.split(",");
if (o.length != cotNames.length) {
throw new TestDataException("Dòng có " + o.length + " ô nhưng cần "
+ cotNames.length + ": " + csvLine);
}
Map<String, String> hang = new LinkedHashMap<>();
for (int i = 0; i < cotNames.length; i++) {
hang.put(cotNames[i], o[i].trim());
}
return hang;
}
Thông báo lỗi có cả con số và nội dung dòng gây lỗi — khi file có 500 dòng thì đó là khác biệt giữa sửa trong 1 phút và 20 phút.
6.3
public static int parseCaseNumber(String caseId) {
if (caseId == null) throw new TestDataException("caseId là null");
try {
return Integer.parseInt(caseId.replaceAll("[^0-9]", ""));
} catch (NumberFormatException e) {
throw new TestDataException("Case id không có số: " + caseId, e);
}
}
replaceAll("[^0-9]", "") bỏ mọi ký tự không phải số. Vẫn cần try/catch vì id không có số nào sẽ thành chuỗi rỗng.
6.4
- Kiểm tra nút có hiện →
findElements+isEmpty(), trảfalse. Không có gì sai xảy ra. - Chuẩn bị click submit → ném exception. Nút submit không có nghĩa là app sai hoặc locator sai; test phải fail.
- File config không tồn tại → ném exception. Không có config thì chạy tiếp là vô nghĩa.
- Ô trống, cột không bắt buộc → giá trị mặc định (chuỗi rỗng).
- Ô trống, cột email đầu vào → ném exception. Dữ liệu test thiếu thì kết quả test không có ý nghĩa.
Nguyên tắc rút ra: câu hỏi không phải "có lỗi không" mà là "chạy tiếp thì kết quả còn ý nghĩa không?" Không còn ý nghĩa thì dừng ngay.
6.5 Hai vấn đề:
catch (Exception e)quá rộng — nó bắt cả NPE khislà null, lẫn những lỗi không lường trước. Nên bắt đúngNumberFormatException.- Mất cause và mất thông tin. Thông báo
"Loi"không nói gì, và không truyềnenên stack trace gốc biến mất.
public int docSo(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
throw new TestDataException("Không chuyển được sang số: '" + s + "'", e);
}
}
Bài 7 — Exception của Selenium
Bài có giá trị thực dụng cao nhất giai đoạn. Mỗi exception ở đây Linh sẽ gặp hàng chục lần.
7.1 Bảng tra
| Exception | Nghĩa thật | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|---|
NoSuchElementException |
locator không khớp phần tử nào lúc đó | locator sai, hoặc trang chưa render xong | sửa locator, hoặc thêm explicit wait |
StaleElementReferenceException |
tham chiếu trỏ vào phần tử đã bị DOM thay thế | trang re-render sau khi tìm element | tìm lại element, đừng cache |
TimeoutException |
chờ hết hạn mà điều kiện vẫn chưa đúng | timeout ngắn, hoặc điều kiện chờ sai | tăng timeout, hoặc xem lại điều kiện |
ElementNotInteractableException |
tìm thấy nhưng không tương tác được | phần tử đang ẩn, display:none, chưa scroll tới |
chờ hiện, scroll tới |
ElementClickInterceptedException |
có phần tử khác che lên chỗ click | overlay, popup, header dính | đóng overlay, scroll, chờ animation xong |
InvalidSelectorException |
cú pháp locator sai | XPath/CSS viết sai | kiểm tra lại trong DevTools |
NoSuchWindowException / NoSuchFrameException |
switch sang tab/iframe không tồn tại | switch sai, hoặc chưa switch về | kiểm tra luồng switch |
Đọc thông báo thay vì đoán: mỗi loại nói một chuyện khác nhau, và phân biệt được là nửa công việc debug.
7.2 StaleElementReferenceException — hiểu cho đúng
Đây là exception gây bối rối nhất, và Linh đã gặp ở project Appium.
Cơ chế: findElement trả về một tham chiếu tới một node cụ thể trong DOM. Nếu sau đó trang re-render — Ajax load lại bảng, React cập nhật state, form validate xong và vẽ lại — thì node cũ bị thay bằng node mới. Node cũ không còn trong DOM, nhưng biến WebElement của Linh vẫn trỏ vào nó. Gọi method trên nó → stale.
Điểm quan trọng: phần tử vẫn hiện trên màn hình. Nhìn thì thấy y nguyên. Nhưng đó là một node khác.
WebElement row = driver.findElement(By.cssSelector("tbody tr"));
driver.findElement(By.id("refresh")).click(); // bảng load lại
row.getText(); // StaleElementReferenceException
Cách phòng:
// SAI — cache element rồi dùng sau
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
clickRefresh();
for (WebElement r : rows) { r.getText(); } // tất cả đều stale
// ĐÚNG — tìm lại sau khi trang đổi
clickRefresh();
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
for (WebElement r : rows) { r.getText(); }
Nguyên tắc: tìm element càng gần lúc dùng càng tốt. Đừng lưu WebElement vào field, đừng truyền nó qua nhiều method.
Một bẫy tinh vi với vòng lặp — chỉ số cũng hỏng:
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
for (WebElement r : rows) {
r.findElement(By.cssSelector("button")).click(); // hành động này làm bảng re-render
// vòng lặp tiếp theo → stale
}
Cách đúng: lặp theo chỉ số và tìm lại mỗi vòng.
int soHang = driver.findElements(By.cssSelector("tbody tr")).size();
for (int i = 0; i < soHang; i++) {
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
rows.get(i).findElement(By.cssSelector("button")).click();
}
7.3 PageFactory và stale — phần riêng cho lựa chọn của Linh
Vì Linh dùng PageFactory, cần biết chính xác nó hoạt động thế nào ở điểm này.
public class LoginPage {
@FindBy(css = "[name='email']")
private WebElement emailInput;
public LoginPage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
}
emailInput không phải một WebElement thật. Nó là một proxy — một object trung gian. Lúc initElements chạy, chưa có gì được tìm cả.
Hệ quả 1 — tìm muộn (lazy). Element chỉ được tìm khi Linh gọi method đầu tiên trên nó (emailInput.sendKeys(...)). Nên initElements không bao giờ ném NoSuchElementException; lỗi xuất hiện muộn hơn, ở dòng dùng element.
Hệ quả 2 — tìm lại mỗi lần gọi. Mặc định, proxy gọi findElement lại cho mỗi lần gọi method. Điều này làm stale ít gặp hơn so với việc tự cache WebElement — một điểm cộng thật của PageFactory.
Hệ quả 3 — @CacheLookup thì ngược lại.
@FindBy(css = "[name='email']")
@CacheLookup
private WebElement emailInput;
Annotation này cho proxy nhớ element sau lần tìm đầu → nhanh hơn một chút, nhưng stale gần như chắc chắn trên trang động. Chỉ dùng cho phần tử không bao giờ re-render (logo, header tĩnh). Nếu gặp stale khó hiểu trong code PageFactory, kiểm tra xem có @CacheLookup không — đó là nghi phạm số một.
Hệ quả 4 — PageFactory không chờ. Việc tìm element diễn ra ngầm, không có synchronization nào. Proxy chỉ tìm, không chờ phần tử hiện hay click được. Explicit wait vẫn phải tự thêm.
Và đây là chỗ vướng cụ thể:
// KHÔNG dùng được với @FindBy — cần By, mà proxy cho WebElement
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(...)));
// Dùng bản nhận WebElement
wait.until(ExpectedConditions.visibilityOf(emailInput));
wait.until(ExpectedConditions.elementToBeClickable(emailInput));
ExpectedConditions có hai họ method — bản ...Located(By) và bản nhận WebElement. Với PageFactory thì dùng họ thứ hai. Chi tiết nhỏ nhưng gây bế tắc khá lâu nếu không biết.
AjaxElementLocatorFactory thêm được thời gian chờ khi tìm:
PageFactory.initElements(new AjaxElementLocatorFactory(driver, 10), this);
Nó thử lại việc tìm element trong 10 giây. Nhưng nó chỉ chờ element có trong DOM — không chờ hiện, không chờ click được. Không thay thế được explicit wait.
Hệ quả 5 — không có locator động. @FindBy cần chuỗi hằng lúc biên dịch, nên không ghép tham số vào được. Mẫu ở Giai đoạn 2 Bài 9.3:
private By statusCell(String caseId) {
return By.xpath("//tr[td[normalize-space()='" + caseId + "']]/td[contains(@class,'status')]");
}
không viết được bằng @FindBy. Cách làm thực tế là lai: @FindBy cho phần tử tĩnh, By thuần cho phần tử động. Nên Page Object của Linh sẽ có cả hai loại, và đó là bình thường, không phải làm sai.
7.4 Vì sao Thread.sleep() là dấu hiệu xấu
Thread.sleep(3000); // luôn chờ đủ 3 giây
Hai vấn đề: chờ dư khi trang nhanh (nhân với 177 case là rất nhiều thời gian), và chờ thiếu khi mạng chậm — test vẫn flaky, chỉ flaky ít hơn.
Explicit wait chờ đúng điều kiện, xong sớm thì đi tiếp ngay:
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.visibilityOf(emailInput));
Thread.sleep cũng là checked exception (InterruptedException) nên phải try/catch hoặc throws — bản thân điều đó cũng làm code rối.
Giai đoạn 5 sẽ đi sâu chiến lược chờ. Giờ chỉ cần nhớ: mỗi Thread.sleep trong code là một chỗ cần thay.
Bài tập 7
7.1 Với mỗi tình huống, đoán exception nào sẽ được ném:
- Locator gõ sai tên class
- Click nút trong modal chưa mở
- Lấy text của element sau khi trang đã refresh
- Chờ 5 giây cho spinner biến mất mà nó không biến mất
- XPath thiếu dấu ngoặc đóng
- Click nút bị cookie banner che
7.2 Đoạn này ném exception gì, ở dòng nào, tại sao?
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
driver.navigate().refresh();
System.out.println(rows.size());
System.out.println(rows.get(0).getText());
Chú ý: rows.size() có crash không?
7.3 Viết lại đoạn 7.2 cho đúng.
7.4 Viết method void clickTatCaNutXem() click lần lượt tất cả nút Xem trong bảng, biết mỗi lần click làm bảng re-render. Dùng mẫu lặp theo chỉ số.
7.5 Với PageFactory, đoạn này sai ở đâu?
@FindBy(css = ".btn-primary")
private WebElement loginButton;
public void clickLogin() {
wait.until(ExpectedConditions.visibilityOfElementLocated(loginButton));
loginButton.click();
}
7.6 Giải thích tại sao @CacheLookup làm tăng nguy cơ stale, dựa vào cơ chế proxy ở 7.3.
7.7 Một test fail với NoSuchElementException khi chạy trên CI nhưng chạy tay ở máy thì pass. Liệt kê ít nhất 3 nguyên nhân có thể, và cách kiểm tra từng cái.
Đáp án bài 7
7.1
- Locator sai tên class →
NoSuchElementException - Click nút trong modal chưa mở →
ElementNotInteractableException(nếu có trong DOM nhưng ẩn) hoặcNoSuchElementException(nếu modal chưa render) - Lấy text sau refresh →
StaleElementReferenceException - Spinner không biến mất →
TimeoutException - XPath thiếu ngoặc →
InvalidSelectorException - Cookie banner che →
ElementClickInterceptedException
7.2 rows.size() không crash — nó chỉ đếm phần tử của List trong bộ nhớ Java, không hỏi browser gì cả. In ra số hàng cũ bình thường.
rows.get(0).getText() mới ném StaleElementReferenceException, vì đó là lần đầu tiên thực sự nói chuyện với browser sau khi refresh.
Đây là chi tiết đáng nhớ: List<WebElement> là list Java thường, các method của List hoạt động offline. Chỉ khi gọi method của WebElement mới có tương tác thật.
7.3
driver.navigate().refresh();
List<WebElement> rows = driver.findElements(By.cssSelector("tbody tr"));
System.out.println(rows.size());
System.out.println(rows.get(0).getText());
Tìm sau khi refresh.
7.4
public void clickTatCaNutXem() {
By nutXem = By.cssSelector("tbody .btn-small");
int soNut = driver.findElements(nutXem).size();
for (int i = 0; i < soNut; i++) {
driver.findElements(nutXem).get(i).click(); // tìm LẠI mỗi vòng
}
}
driver.findElements(nutXem) gọi lại trong mỗi vòng lặp — chậm hơn một chút nhưng không stale. Cách này giả định số lượng nút không đổi; nếu click làm thêm/bớt hàng thì phải tính lại soNut mỗi vòng.
7.5 visibilityOfElementLocated() nhận một By, còn loginButton là WebElement (proxy). Lỗi biên dịch. Sửa:
wait.until(ExpectedConditions.visibilityOf(loginButton));
Đây là lỗi gần như ai dùng PageFactory cũng gặp một lần.
7.6 Không có @CacheLookup, proxy gọi findElement lại cho mỗi lần gọi method — nên nó luôn lấy node hiện tại của DOM, và stale ít khi xảy ra.
Có @CacheLookup, proxy nhớ node tìm được lần đầu và dùng lại mãi. Trang re-render → node đó bị thay → mọi lần gọi sau đều stale. Nói cách khác, @CacheLookup tự tay tạo ra đúng cái vấn đề mà cơ chế proxy vốn đang tránh giúp.
7.7 Ba nhóm nguyên nhân:
- Tốc độ. CI thường chậm hơn hoặc nhanh hơn máy cá nhân, nên timing khác. Kiểm tra: thêm explicit wait ở chỗ fail và xem có hết không. Nếu hết thì nguyên nhân là thiếu wait, không phải locator sai.
- Kích thước cửa sổ. CI chạy headless với kích thước mặc định khác. Ở màn hình nhỏ, giao diện responsive có thể ẩn phần tử hoặc thay bằng menu hamburger — locator không còn khớp. Kiểm tra: set kích thước cửa sổ tường minh, và chụp ảnh lúc fail.
- Dữ liệu và trạng thái. Môi trường CI có dữ liệu khác, user khác, hoặc chưa đăng nhập. Kiểm tra: đọc ảnh chụp và HTML lúc fail — đó là lý do Giai đoạn 5 phải cấu hình chụp ảnh khi fail.
Thêm hai nguyên nhân nữa nếu Linh nghĩ ra: phiên bản browser khác nhau, và cookie banner/popup chỉ xuất hiện ở phiên mới (máy cá nhân đã tắt từ trước).
Bài 8 — Nguyên tắc xử lý exception trong test
Bài ngắn nhưng quan trọng — nó về thái độ, không về cú pháp.
8.1 Nguyên tắc gốc: test fail phải fail
Trong code sản phẩm, try/catch để chương trình không sập. Trong code test, mục đích ngược lại: phát hiện vấn đề. Bắt exception rồi bỏ qua là làm mất chính cái mà test tồn tại để tìm.
// GẦN NHƯ LUÔN SAI
try {
loginPage.clickLogin();
} catch (Exception e) {
// không làm gì
}
Test này luôn pass, kể cả khi nút login không tồn tại. Nó tệ hơn là không có test — nó tạo cảm giác an toàn giả.
8.2 Khi nào catch là hợp lý
Retry một hành động không ổn định — có giới hạn số lần:
public void clickVoiRetry(By locator, int soLan) {
for (int i = 0; i < soLan; i++) {
try {
driver.findElement(locator).click();
return;
} catch (StaleElementReferenceException e) {
if (i == soLan - 1) throw e; // lần cuối thì ném ra
}
}
}
Điểm quyết định: lần cuối vẫn ném exception. Retry không phải là bỏ qua.
Dọn dẹp trong teardown — không muốn lỗi khi đóng driver che mất lỗi thật của test:
@AfterMethod
public void teardown() {
try {
if (driver != null) driver.quit();
} catch (WebDriverException e) {
System.out.println("Không đóng được driver: " + e.getMessage());
}
}
Bổ sung thông tin rồi ném lại:
try {
loginPage.enterEmail(email);
} catch (WebDriverException e) {
throw new RuntimeException("Không nhập được email '" + email
+ "' trên môi trường " + config.get("baseUrl"), e);
}
Vẫn fail, nhưng fail kèm thông tin hữu ích. Giữ e làm cause.
Kiểm tra có/không — nhưng dùng findElements thay vì catch (Bài 1.4).
8.3 AssertionError không phải Exception
Điểm này rất dễ sai và hậu quả nghiêm trọng:
@Test
public void test() {
try {
Assert.assertEquals(actual, expected);
} catch (Exception e) {
System.out.println("Có lỗi");
}
}
Assert của TestNG ném AssertionError, thuộc nhánh Error không phải Exception (xem cây ở Bài 5.1). Nên catch (Exception e) không bắt được — assertion fail vẫn làm test fail bình thường. Trong trường hợp này thì may, vì đó là điều mình muốn.
Nhưng ngược lại:
catch (Throwable t) { } // BẮT ĐƯỢC assertion → test luôn pass
Throwable là gốc của cả hai nhánh. Đừng bao giờ catch (Throwable) trong test.
8.4 Cách TestNG xử lý exception
- Exception không được bắt → test FAIL
AssertionError→ test FAILSkipException→ test bị SKIP, không tính là fail@Test(expectedExceptions = TestDataException.class)→ test PASS nếu exception đó được ném, FAIL nếu không
expectedExceptions là cách đúng để test một tình huống lỗi:
@Test(expectedExceptions = TestDataException.class)
public void docHangThieuCot() {
docHang("a,b", new String[]{"c1", "c2", "c3"});
}
Gọn hơn nhiều so với try/catch + Assert.fail().
SoftAssert — kiểm nhiều thứ trong một test mà không dừng ở lỗi đầu tiên:
SoftAssert soft = new SoftAssert();
soft.assertEquals(page.getTieuDe(), "Dashboard");
soft.assertTrue(page.coHienNutLogout());
soft.assertEquals(page.getSoCase(), 177);
soft.assertAll(); // BẮT BUỘC — thiếu dòng này test luôn pass
Quên assertAll() là bug im lặng phổ biến nhất khi dùng SoftAssert.
Dùng SoftAssert cho nhiều trường liên quan trên cùng một trang. Đừng dùng cho các bước phụ thuộc nhau — nếu bước 1 fail thì bước 2 không có ý nghĩa gì.
Bài tập 8
8.1 Chỉ ra vấn đề trong từng đoạn và sửa:
// (a)
try { loginPage.clickLogin(); } catch (Exception e) { }
// (b)
try { Assert.assertTrue(page.isLoaded()); } catch (Throwable t) { System.out.println("fail"); }
// (c)
try {
driver.findElement(By.id("x")).click();
} catch (NoSuchElementException e) {
System.out.println("Không thấy nút, bỏ qua");
}
8.2 Viết method clickVoiRetry theo mẫu 8.2, nhưng bắt cả StaleElementReferenceException và ElementClickInterceptedException.
8.3 Viết một test dùng SoftAssert kiểm tra 4 điều trên trang dashboard. Rồi cố ý xoá assertAll() và quan sát kết quả.
8.4 Chuyển đoạn này sang dùng expectedExceptions:
@Test
public void testDuLieuSai() {
try {
docHang("a,b", new String[]{"c1","c2","c3"});
Assert.fail("Phải ném exception");
} catch (TestDataException e) {
// đúng như mong đợi
}
}
8.5 Câu hỏi: một test có try/catch bọc toàn bộ phần thân và không có Assert nào. Nó có thể fail được không? Nếu không thì test đó có giá trị gì?
Đáp án bài 8
8.1
(a) Bắt rồi bỏ qua hoàn toàn → test luôn pass. Sửa: bỏ hẳn try/catch. Để exception bay ra, TestNG tự cho test fail kèm stack trace.
(b) catch (Throwable t) bắt được AssertionError → assertion fail nhưng test vẫn pass. Sửa: bỏ try/catch, để Assert.assertTrue(...) đứng trần.
(c) Bắt NoSuchElementException rồi bỏ qua — nếu nút submit không có thì test lẽ ra phải fail. Sửa: bỏ try/catch. Nếu thật sự chỉ muốn kiểm tra nút có tồn tại thì dùng:
if (!driver.findElements(By.id("x")).isEmpty()) {
driver.findElement(By.id("x")).click();
}
Nhưng phải hỏi lại: tình huống nào mà nút submit vắng mặt là bình thường? Thường là không có, và đó là dấu hiệu logic test cần xem lại.
8.2
public void clickVoiRetry(By locator, int soLan) {
for (int i = 0; i < soLan; i++) {
try {
driver.findElement(locator).click();
return;
} catch (StaleElementReferenceException | ElementClickInterceptedException e) {
if (i == soLan - 1) throw e;
}
}
}
8.4
@Test(expectedExceptions = TestDataException.class)
public void testDuLieuSai() {
docHang("a,b", new String[]{"c1","c2","c3"});
}
Thêm expectedExceptionsMessageRegExp nếu muốn kiểm tra cả nội dung thông báo.
8.5 Không, nó không thể fail. Và nó không có giá trị gì — tệ hơn: nó có hại, vì nó xuất hiện trong báo cáo như một test đã pass, làm tỉ lệ pass đẹp hơn thực tế và tạo cảm giác chức năng đó đã được kiểm tra.
Với vai trò QA thì đây là điểm đáng nhớ nhất của bài: một test không thể fail thì không phải là test, nó là một dòng trong báo cáo. Khi review code test của người khác, câu hỏi đầu tiên nên là "test này fail được trong tình huống nào?"
Bài 9 — Dự án: bộ đọc và tổng hợp kết quả test
Không có đáp án. Đây là bài kiểm tra của giai đoạn, và nó dùng gần hết những gì đã học.
Đầu vào
Tạo file ketqua.csv:
caseId,chucNang,ketQua,thoiGianGiay,platform
TC001,Login,PASS,12,Chrome
TC002,Search Artist,FAIL,45,Chrome
TC003,Book Appointment,PASS,8,Chrome
TC004,Payment Stripe,BLOCKED,30,Chrome
TC005,Studio Detail,FAIL,22,Firefox
TC001,Login,PASS,14,Firefox
TC002,Search Artist,PASS,40,Firefox
Yêu cầu
1. Model — class TestResult với field private, constructor, getter, toString(), equals()/hashCode() theo caseId + platform.
2. Đọc file — List<TestResult> doc(String path):
try-with-resources- Bỏ dòng header
- Ném
TestDataExceptionvới số dòng cụ thể nếu dòng sai định dạng hoặc số cột không khớp - File không tồn tại → ném exception với thông báo rõ ràng
3. Tổng hợp — các method riêng:
Map<String, Integer> demTheoKetQua(List<TestResult>)Map<String, List<String>> caseFailTheoPlatform(List<TestResult>)double tiLePass(List<TestResult>)— xử lý list rỗngSet<String> timCaseIdTrung(List<TestResult>)— cùng caseId trên nhiều platformTestResult caseChamNhat(List<TestResult>)String tongThoiGian(List<TestResult>)— dạngx phút y giây
4. Báo cáo — in ra console, thứ tự ổn định giữa các lần chạy.
5. Kiểm tra lỗi — tạo thêm 3 file lỗi và xác nhận chương trình ném exception có thông báo hữu ích:
- Thiếu một cột ở dòng 4
thoiGianGiaylà chữ thay vì số- File rỗng hoàn toàn
Tự kiểm tra
Trả lời không cần mở lại tài liệu:
findElementvàfindElementskhác nhau thế nào khi không tìm thấy gì?- Tại sao
list.size()saurefresh()không crash màlist.get(0).getText()thì crash? @CacheLookuplàm gì và tại sao thường nên tránh?- Vì sao
catch (Exception e)không bắt được assertion fail của TestNG? - Override
equalsmà không overridehashCodethìHashSethỏng thế nào? - Ba trường hợp
catchlà hợp lý trong code test? - Với PageFactory, dùng
visibilityOfhayvisibilityOfElementLocated?
Chỗ nào ngập ngừng — quay lại đúng bài đó.
Xong giai đoạn 3 khi nào
Ba dấu hiệu:
- Làm được dự án Bài 9 mà không mở lại tài liệu.
- Gặp bất kỳ exception nào của Selenium thì đọc tên là biết ngay thuộc nhóm nguyên nhân nào, không phải tra Google.
- Xử lý được
List<WebElement>một cách tự nhiên: đếm, lặp, kiểm tra rỗng, tìm lại khi cần, không copy mẫu.
Giai đoạn 4 (Lambda, Stream, đọc dữ liệu ngoài) ngắn hơn — 15 giờ — và nó là phần cuối trước khi bắt tay vào framework thật. Nội dung chính: hiểu WebDriverWait và ExpectedConditions ở mức tự viết được điều kiện chờ riêng, thay vì chỉ dùng những cái có sẵn.