HTML, DOM và Selector cho Web Automation
Mục lục (53)
Thời lượng: ~12 giờ, 30–45 phút/tuần trong 6–7 tuần
Chạy song song với: Giai đoạn 1 và 2 của lộ trình Java
Công cụ cần: chỉ Chrome. Không cần IntelliJ, không cần Java, không cần gõ code.
Kết quả mong đợi: mở bất kỳ trang web nào, tự viết được locator ổn định cho mọi phần tử, và biết khi nào locator mình viết sẽ hỏng
Cách dùng tài liệu này
Kèm theo là file qa-practice-page.html. Tải về, click đôi mở bằng Chrome — đó là sân luyện tập. Tất cả bài tập trong tài liệu này đều làm trên trang đó, nên đáp án là cố định và kiểm tra được.
Ba quy tắc:
- Mọi selector phải được xác nhận trong DevTools trước khi tin. Cách làm ở Bài 2. Viết selector mà không kiểm tra là nguồn gốc của test flaky.
- Đáp án ở cuối mỗi bài. Tự viết trước, kể cả viết sai — DevTools sẽ nói ngay là sai, đó là vòng phản hồi nhanh nhất trong toàn bộ nghề này.
- Không copy selector từ menu chuột phải của Chrome. Lý do ở Bài 7, và đây là thói quen hỏng phổ biến nhất của người mới.
Lịch học gợi ý
| Tuần | Nội dung | Phút |
|---|---|---|
| 2 | Bài 1 — HTML và cây DOM | 45 |
| 3 | Bài 2 — DevTools · Bài 3 — 4 mẫu CSS cốt lõi | 45 |
| 4 | Bài 4 — Quan hệ cha con anh em | 45 |
| 5 | Bài 5 — Lọc theo vị trí và chuỗi con | 45 |
| 6 | Bài 6 — XPath | 60 |
| 7 | Bài 7 — Locator ổn định · Bài 8 — Ca đặc biệt | 60 |
| 8 | Bài 9 — Tổng hợp trên trang thật | 45 |
Bài 1 — HTML và cây DOM
1.1 Một thẻ HTML gồm gì
<input type="email" name="email" class="field required">
input— tên thẻ (tag)type,name,class— thuộc tính (attribute), dạngtên="giá trị"- Thẻ có nội dung thì cần thẻ đóng:
<button class="btn">Đăng nhập</button> Đăng nhậplà text của phần tử
Bốn thứ đó là toàn bộ nguyên liệu để viết locator: tag, attribute, text, và vị trí trên cây.
1.2 DOM là cây
Các thẻ lồng nhau tạo thành quan hệ cha–con:
<form class="login-form">
<label>Email</label>
<input type="email" name="email">
<button class="btn">Đăng nhập</button>
</form>
form.login-form
├── label ← con của form
├── input ← con của form, ANH EM với label
└── button ← con của form, anh em với cả hai
Thuật ngữ dùng suốt tài liệu:
| Tiếng Việt | Tiếng Anh | Nghĩa |
|---|---|---|
| cha | parent | thẻ bao ngoài trực tiếp |
| con | child | thẻ bên trong trực tiếp |
| tổ tiên | ancestor | mọi thẻ bao ngoài, bao nhiêu tầng cũng được |
| hậu duệ | descendant | mọi thẻ bên trong, bao nhiêu tầng cũng được |
| anh em | sibling | cùng một cha |
Selector chính là đường chỉ tới một nút trên cây này. Đó là tất cả những gì cần hiểu về bản chất.
1.3 Ba thuộc tính quan trọng nhất
id — theo chuẩn HTML là duy nhất trên toàn trang. Nếu phần tử có id ổn định, đó gần như luôn là locator tốt nhất. Thực tế nhiều trang không có, hoặc có nhưng sinh tự động (id="input-9f3a2") nên vô dụng.
class — dùng cho CSS, không duy nhất, và một phần tử có nhiều class cách nhau bằng khoảng trắng:
<input class="field required">
Phần tử này có hai class riêng biệt: field và required. Đây là chỗ người mới hay nhầm — không phải một class tên là "field required".
data-testid (hoặc data-test, data-qa) — thuộc tính do dev thêm vào chỉ để test dùng. Đây là locator lý tưởng: không dùng cho CSS nên không ai sửa khi đổi giao diện. Nếu project web của Linh chưa có, đề xuất với NamNT1 — chi phí thêm gần bằng 0 và nó tiết kiệm rất nhiều giờ maintain về sau. Đây là đề xuất kỹ thuật hợp lý từ phía QA, không phải đòi hỏi vô lý.
Bài tập 1
Mở qa-practice-page.html trong Chrome, bấm F12 → tab Elements.
1.1 Tìm phần tử <input type="email">. Cha trực tiếp của nó là thẻ gì? Liệt kê các tổ tiên của nó từ trong ra ngoài.
1.2 Ô input email có mấy class? Tên là gì?
1.3 Trong <section id="cases">, một <tr class="case-row"> có mấy con? Chúng là thẻ gì?
1.4 Tìm tất cả phần tử có thuộc tính data-testid. Có bao nhiêu cái?
1.5 Trong <li class="bug-item"> đầu tiên, <span class="bug-title"> và <span class="badge"> có quan hệ gì với nhau? Và với <ul class="bug-list">?
Đáp án bài 1
1.1 Cha trực tiếp: <form class="login-form">. Tổ tiên từ trong ra ngoài: form.login-form → section#login-box → main → body → html.
1.2 Hai class: field và required.
1.3 Năm con, tất cả đều là thẻ <td>. Riêng <td> cuối cùng lại có một con nữa là <button> — nên button là cháu của tr, không phải con.
1.4 Chín cái: password-input, case-table, năm cái view-TC00x, và bug-count-card. Nếu Linh đếm ra 9 bằng cách nhìn mắt thì tốt; Bài 2 sẽ chỉ cách để Chrome tự đếm.
1.5 bug-title và badge là anh em — cùng cha là li.bug-item. Với ul.bug-list thì chúng là hậu duệ (cháu), không phải con — vì còn li ở giữa. Phân biệt con/hậu duệ này quyết định chọn dấu hay > ở Bài 4.
Bài 2 — DevTools: vòng phản hồi 2 giây
Bài ngắn nhất nhưng đừng bỏ. Không có kỹ năng này thì mọi bài sau chỉ là lý thuyết.
2.1 Kiểm tra selector trong tab Elements
F12→ tab ElementsCtrl+F(Mac:Cmd+F) — hộp tìm kiếm xuất hiện ở dưới- Gõ selector vào
Chrome tô sáng các phần tử khớp và hiện 1/3 — nghĩa là đang ở kết quả 1 trong 3 phần tử khớp. Enter để nhảy qua từng kết quả.
Con số đó là thông tin quan trọng nhất trong toàn bộ module này. Locator dùng cho findElement phải cho ra đúng 1/1. Ra 1/5 nghĩa là Selenium sẽ lấy phần tử đầu tiên — có thể đúng, có thể sai, và sẽ sai vào lúc dev thêm một phần tử mới.
Hộp Ctrl+F này nhận cả CSS selector, cả XPath, cả chuỗi text thường — rất tiện.
2.2 Kiểm tra trong Console — cách chính xác hơn
Tab Console, gõ:
$$("input.field") // trả về mảng các phần tử khớp CSS selector
$$("input.field").length // đếm số lượng — con số cần nhìn
$x("//input[@type='email']") // dùng cho XPath
$$ chính là document.querySelectorAll viết ngắn. Ưu điểm so với Ctrl+F: thấy được chính xác từng phần tử trả về, và hover vào kết quả thì Chrome tô sáng nó trên trang.
Đây là thứ Linh sẽ dùng mỗi ngày khi làm automation thật — viết selector, dán vào Console, xem .length có bằng 1 không, rồi mới đưa vào code.
2.3 Inspect nhanh
Chuột phải vào phần tử trên trang → Inspect. DevTools nhảy tới đúng thẻ đó trong Elements. Hoặc Ctrl+Shift+C rồi click.
Bài tập 2
Làm hết bằng Console trên trang luyện tập, ghi lại số lượng từng cái.
2.1 $$(".field").length ra bao nhiêu?
2.2 $$(".btn").length ra bao nhiêu?
2.3 $$("input").length ra bao nhiêu? Có bao gồm checkbox không?
2.4 $$(".status").length và $$(".status.fail").length
2.5 $$("[data-testid]").length — so với con số Linh đếm tay ở bài 1.4.
2.6 Gõ $$(".card") rồi hover chuột lên từng phần tử trong kết quả. Quan sát Chrome tô sáng gì trên trang.
Đáp án bài 2
2.1 3 — username, email, password. Checkbox không có class field.
2.2 8 — 2 nút trong form login, 5 nút Xem trong bảng, và... thực ra là 10: còn 2 nút trong #confirm-modal. Modal đang bị ẩn bởi display: none nhưng vẫn nằm trong DOM, nên selector vẫn tìm thấy. Đáp án đúng: 10.
Đây là bài học thật, không phải mẹo đánh đố: phần tử ẩn vẫn khớp selector. Trong Selenium, findElement tìm thấy nó nhưng click() sẽ ném ElementNotInteractableException. Rất nhiều test fail vì lý do này.
2.3 4 — ba ô field cộng checkbox. Checkbox cũng là thẻ input, chỉ khác type.
2.4 .status ra 5, .status.fail ra 2. Viết liền hai class nghĩa là "phần tử có cả hai class".
2.5 9, khớp với đếm tay.
2.6 Ba thẻ div.card trong #report. Việc hover để xác nhận đúng phần tử mình nghĩ là thói quen nên giữ — nó bắt được lỗi "selector đúng số lượng nhưng sai phần tử".
Bài 3 — Bốn mẫu CSS cốt lõi
3.1 Bốn mẫu
| Cú pháp | Chọn theo | Ví dụ | Nghĩa |
|---|---|---|---|
#giaTri |
id |
#login-box |
phần tử có id là login-box |
.giaTri |
class |
.btn-primary |
mọi phần tử có class btn-primary |
tag |
tên thẻ | input |
mọi thẻ input |
[attr='giaTri'] |
thuộc tính bất kỳ | [type='email'] |
mọi phần tử có type là email |
Nhớ # là id, . là class — đây là hai ký hiệu Linh sẽ đọc hàng nghìn lần.
3.2 Ghép — viết liền nghĩa là "cùng một phần tử"
input.field /* thẻ input VÀ có class field */
input[type='email'] /* thẻ input VÀ type là email */
.field.required /* có CẢ HAI class */
input[type='email'].required /* ghép bao nhiêu điều kiện cũng được */
Không có khoảng trắng nào giữa các phần — mọi điều kiện áp lên một phần tử.
3.3 Biến thể của match thuộc tính
[data-testid] /* CÓ thuộc tính này, giá trị gì cũng được */
[type='email'] /* bằng chính xác */
[data-testid^='view-'] /* BẮT ĐẦU bằng "view-" */
[data-testid$='-TC001'] /* KẾT THÚC bằng */
[class*='btn'] /* CHỨA chuỗi "btn" ở bất kỳ đâu */
Ba ký hiệu ^ $ * rất hữu ích khi giá trị thuộc tính có phần cố định và phần thay đổi — chuyện xảy ra liên tục với id sinh tự động.
Cẩn thận với *=: [class*='btn'] cũng khớp cả class="btn-danger-outline" và class="submit-button". Nó khớp chuỗi con, không khớp cả class.
3.4 Nhiều selector cùng lúc
.btn-primary, .btn-danger /* khớp CẢ HAI nhóm — dấu phẩy là "hoặc" */
Ít dùng trong automation vì thường cần đúng một phần tử, nhưng hay gặp khi đếm.
Bài tập 3
Viết selector, xác nhận .length bằng Console, đảm bảo ra đúng số lượng yêu cầu.
3.1 Chọn đúng một ô input email. Viết ít nhất hai cách khác nhau.
3.2 Chọn nút "Đăng nhập" (không lấy nút Đăng ký).
3.3 Chọn ô nhập mật khẩu bằng data-testid.
3.4 Chọn tất cả 5 nút "Xem" trong bảng — dùng data-testid với một selector duy nhất.
3.5 Chọn nút "Xem" của riêng TC003.
3.6 Chọn tất cả ô trạng thái FAIL (2 cái).
3.7 Chọn nút đang bị disabled. Gợi ý: disabled là thuộc tính không có giá trị.
3.8 Selector .card ra 3. Làm sao chọn đúng thẻ card có data-testid?
3.9 Cái nào sai và tại sao: .field required — đoán trước, rồi chạy $$(".field required").length để xem.
Đáp án bài 3
3.1 Vài cách đều ra 1:
input[type='email']
[name='email']
input.required[type='email']
.login-form input[type='email']
Bài 7 sẽ nói cái nào bền nhất. Lưu ý #username-input không dùng được ở đây vì id đó thuộc ô username.
3.2 .btn-primary — ra 1. Còn .btn ra 10, không dùng được.
3.3 [data-testid='password-input']
3.4 [data-testid^='view-'] — ra 5. Đây là lúc ^= phát huy tác dụng.
3.5 [data-testid='view-TC003']
3.6 .status.fail — ra 2. Viết .status .fail (có khoảng trắng) sẽ ra 0, xem Bài 4 để biết tại sao.
3.7 [disabled] hoặc button[disabled] hoặc .btn:disabled. Thuộc tính không có giá trị vẫn dùng được với cú pháp [attr].
3.8 .card[data-testid] — ghép class với điều kiện có thuộc tính. Ra 1.
3.9 .field required có khoảng trắng, nên Chrome hiểu là "thẻ tên required nằm bên trong phần tử có class field". Không có thẻ HTML nào tên là required → ra 0.
Muốn "có cả hai class" thì phải viết liền: .field.required → ra 2. Khoảng trắng trong CSS selector không bao giờ là vô nghĩa — nó luôn là toán tử quan hệ.
Bài 4 — Quan hệ trên cây
4.1 Bốn toán tử
| Toán tử | Tên | Nghĩa |
|---|---|---|
(khoảng trắng) |
descendant | nằm bên trong, bao nhiêu tầng cũng được |
> |
child | con trực tiếp, đúng một tầng |
+ |
adjacent sibling | anh em ngay sau |
~ |
general sibling | mọi anh em phía sau |
.bug-list .badge /* mọi .badge bên trong .bug-list — dù lồng sâu bao nhiêu */
.bug-list > .badge /* ra 0 — badge là CHÁU của ul, không phải con */
.bug-list > .bug-item /* ra 4 — li đúng là con trực tiếp của ul */
Đây là chỗ cần cẩn thận nhất: > chỉ đi một tầng. Nếu không chắc cấu trúc, dùng khoảng trắng an toàn hơn.
4.2 Sibling — dùng cho label
<label>Email</label>
<input type="email" name="email">
label + input /* input ngay sau một label */
Rất hữu ích khi ô input không có gì đặc biệt nhưng label bên cạnh thì có text rõ ràng. Hạn chế: CSS không chọn được label theo text, nên mẫu này chỉ hoạt động nếu label đó xác định được bằng cách khác. Muốn dựa vào text thì phải dùng XPath — Bài 6.
4.3 Thu hẹp phạm vi bằng tổ tiên
Đây là kỹ thuật dùng nhiều nhất trong thực tế:
.login-form input[type='email'] /* email TRONG form login */
#cases .status.fail /* ô fail TRONG section cases */
Thêm một tổ tiên có id ổn định ở đầu selector giúp locator vừa rõ ràng vừa tránh trùng với phần tử tương tự ở nơi khác trên trang. Nhưng đừng thêm quá nhiều tầng — mỗi tầng là một điểm có thể hỏng khi dev sửa layout.
Bài tập 4
4.1 Đoán số lượng trước, rồi kiểm tra:
$$(".bug-list .bug-id").length
$$(".bug-list > .bug-id").length
$$(".case-table td").length
$$(".case-table > td").length
$$("tbody > tr").length
4.2 Chọn tất cả .badge nhưng chỉ trong các li có data-severity='critical' (2 cái).
4.3 Chọn ô input email bằng cách dựa vào label đứng trước nó.
4.4 Chọn .card-value của thẻ card có data-testid='bug-count-card'.
4.5 Chọn tất cả td trong hàng của TC002 — chỉ hàng đó.
4.6 Tại sao .status .fail ra 0 mà .status.fail ra 2?
Đáp án bài 4
4.1
4 ← .bug-id là hậu duệ của .bug-list
0 ← nhưng KHÔNG phải con trực tiếp (có li ở giữa)
25 ← 5 hàng × 5 ô
0 ← td là con của tr, không phải của table
5
Dòng 3 và 4 đáng chú ý: .case-table td ra 25 dù td cách table tới hai tầng (tbody rồi tr) — khoảng trắng không quan tâm sâu bao nhiêu.
4.2 [data-severity='critical'] .badge — ra 2.
4.3
label + input[type='email']
Ra 1. Hoặc chỉ label + input — nhưng cái đó ra 3 (mỗi label đều có input theo sau), không dùng được.
4.4 [data-testid='bug-count-card'] .card-value — ra 1, chứa text 12.
4.5 Không có cách nào gọn bằng CSS thuần dựa trên text TC002. Cách khả thi: dựa vào vị trí hàng — tbody tr:nth-child(2) td (Bài 5), ra 5.
Nếu muốn chọn theo nội dung TC002 thì buộc phải dùng XPath. Đây là giới hạn thật của CSS và là lý do Bài 6 tồn tại.
4.6 .status .fail = "phần tử có class fail nằm bên trong phần tử có class status". Trên trang này, status và fail là hai class của cùng một td, không lồng nhau → ra 0. Viết liền không khoảng trắng mới đúng.
Bài 5 — Lọc theo vị trí
5.1 nth-child
tbody tr:nth-child(1) /* hàng thứ 1 */
tbody tr:nth-child(3) /* hàng thứ 3 */
tbody tr:first-child
tbody tr:last-child
tbody tr:nth-child(odd) /* các hàng lẻ */
tbody tr:nth-child(2n) /* các hàng chẵn */
Đếm từ 1, không phải từ 0 — khác với array trong Java. Chỗ này rất dễ nhầm khi vừa viết Java vừa viết selector.
nth-child(n) nghĩa là "phần tử con thứ n của cha nó", và nó tính mọi con, không riêng loại thẻ mình đang chọn. p:nth-child(2) chọn thẻ p nếu nó đúng là con thứ 2 — nếu con thứ 2 là thẻ khác thì ra 0. Muốn "thẻ p thứ 2 trong số các thẻ p" thì dùng p:nth-of-type(2).
5.2 :not()
.btn:not(.btn-primary) /* nút không phải primary */
.bug-item:not([data-severity='minor'])
input:not([type='checkbox'])
Rất tiện để loại trừ mà không cần liệt kê.
5.3 :has() — chọn theo con
CSS hiện đại có thứ ngày trước không có:
tr:has(.status.fail) /* hàng NÀO CHỨA ô fail */
:has() hoạt động trên Chrome/Edge/Firefox/Safari bản mới, và Selenium truyền selector xuống cho browser xử lý nên nó dùng được trong code thật. Đây là điều duy nhất "đi lên cha" mà CSS làm được.
Lưu ý thực tế: nếu project phải chạy trên browser cũ hoặc CI dùng bản Chrome cũ, XPath vẫn là lựa chọn chắc chắn hơn. Cứ biết :has() tồn tại, nhưng đừng dựa vào nó khi chưa xác nhận môi trường.
5.4 Điều CSS không làm được
Nhớ ba giới hạn này, vì chúng là toàn bộ lý do phải học XPath:
- Không chọn được theo text. Không có
:contains()trong CSS — từng có trong bản nháp, đã bị bỏ, và jQuery có nhưng đó không phải CSS chuẩn. - Không đi lên cha (ngoài
:has()). - Không lùi về anh em phía trước. Chỉ có
+và~đi về sau.
Bài tập 5
5.1 Chọn hàng thứ 3 trong bảng (TC003).
5.2 Chọn ô "Chức năng" (cột 2) của tất cả các hàng.
5.3 Chọn .bug-item cuối cùng.
5.4 Chọn tất cả .btn trừ những nút trong bảng. Gợi ý: kết hợp :not() với class.
5.5 Chọn tất cả .bug-item không phải mức critical (2 cái).
5.6 Dùng :has() chọn các hàng có kết quả FAIL (2 cái). Sau đó thử tr:has(.status.pass).
5.7 Đoán rồi kiểm tra:
$$("tbody tr:nth-child(1) td:nth-child(3)") // ô nào?
$$(".card:nth-child(2) .card-value") // giá trị gì?
$$("thead tr:nth-child(2)").length // ?
Đáp án bài 5
5.1 tbody tr:nth-child(3) — ra 1, chứa TC003.
5.2 tbody td:nth-child(2) — ra 5: Login, Search Artist, Book Appointment, Payment Stripe, Studio Detail.
5.3 .bug-item:last-child — ra 1, bug #288-104.
5.4 .btn:not(.btn-small) — ra 5: hai nút form, hai nút modal, và... thực ra .btn-small chỉ có 5 nút trong bảng nên .btn:not(.btn-small) ra 10 - 5 = 5. Đúng: 5.
5.5 .bug-item:not([data-severity='critical']) — ra 2 (major và minor).
5.6 tr:has(.status.fail) ra 2 (TC002, TC005). tr:has(.status.pass) ra 2 (TC001, TC003). Đây chính là việc mà "CSS không đi lên cha" — :has() giải quyết được.
5.7
1 ← ô kết quả của hàng 1 → td chứa "PASS"
1 ← "88.14%" — card thứ 2 là Tỉ lệ pass
0 ← thead chỉ có MỘT tr, không có tr thứ 2
Bài 6 — XPath
6.1 Khi nào dùng XPath
Nguyên tắc: mặc định CSS, chuyển XPath khi cần một trong ba việc CSS không làm được — chọn theo text, đi lên cha, lùi về anh em phía trước.
Lý do ưu tiên CSS: ngắn hơn, dễ đọc hơn, và trên các engine cũ thì nhanh hơn (khác biệt này ngày nay nhỏ, nhưng tính dễ đọc thì vẫn quan trọng khi maintain hàng trăm locator).
6.2 Cú pháp cơ bản
| XPath | CSS tương đương | Nghĩa |
|---|---|---|
//input |
input |
mọi thẻ input, ở bất kỳ đâu |
//input[@type='email'] |
input[type='email'] |
thuộc tính dùng dấu @ |
//*[@id='login-box'] |
#login-box |
* là thẻ bất kỳ |
//form//input |
form input |
hậu duệ |
//form/input |
form > input |
con trực tiếp |
// = "ở bất kỳ đâu trở xuống". / = "con trực tiếp". @ đứng trước tên thuộc tính.
6.3 Chọn theo text — lý do chính để dùng XPath
//button[text()='Đăng nhập']
//button[contains(text(),'Đăng')]
//span[normalize-space()='New']
text()='...'— khớp chính xác, và rất dễ hỏng vì khoảng trắng hoặc dấu xuống dòng trong HTMLcontains(text(),'...')— khớp một phần, an toàn hơnnormalize-space()— bỏ khoảng trắng đầu/cuối và gộp khoảng trắng liên tiếp. Đây thường là lựa chọn tốt nhất.
Một cái bẫy quan trọng: text() chỉ lấy text node trực tiếp, còn . lấy toàn bộ text kể cả của các thẻ con.
<li><span>#288-101</span> <span>Login sai</span></li>
//li[text()='Login sai']→ không khớp, vì text đó thuộcspan, không phải text trực tiếp củali//li[contains(.,'Login sai')]→ khớp
Khi selector theo text không hoạt động dù text rõ ràng đúng, đây là nguyên nhân số một.
6.4 contains với class
//div[contains(@class,'btn')]
Cẩn thận cùng lý do như *= ở CSS: nó khớp chuỗi con, nên contains(@class,'btn') cũng khớp class="submit-button". Muốn khớp đúng một class trong danh sách:
//div[contains(concat(' ',normalize-space(@class),' '),' btn ')]
Dài và khó đọc — đây chính là lúc nên quay về CSS .btn.
6.5 Axes — di chuyển trên cây
Thứ CSS không có:
//span[text()='#288-101']/.. parent — cha
//span[text()='#288-101']/parent::li cha, kèm điều kiện là thẻ li
//td[text()='TC002']/following-sibling::td các anh em PHÍA SAU
//td[text()='PASS']/preceding-sibling::td[1] anh em phía trước, gần nhất
//td[text()='TC002']/ancestor::tr tổ tiên là tr
//tr[td[text()='TC002']]//button hàng nào chứa td đó, rồi lấy button
Dòng cuối là mẫu quan trọng nhất trong automation web: "tìm hàng theo nội dung, rồi click nút trong hàng đó". Nó xuất hiện ở mọi bảng dữ liệu, và CSS thuần không viết được (trừ khi dùng :has() kết hợp, mà vẫn không chọn được theo text).
6.6 Chỉ số — bẫy lớn
//input[2] KHÔNG phải "input thứ 2 trên trang"
(//input)[2] MỚI là "input thứ 2 trên trang"
//input[2] nghĩa là "input nào là con thứ 2 loại input của cha nó" — có thể ra nhiều phần tử, hoặc không ra gì. Muốn đánh số trên toàn bộ kết quả thì phải bọc ngoặc trước.
Và XPath đếm từ 1, giống nth-child.
6.7 Đừng dùng XPath tuyệt đối
/html/body/main/section[1]/form/input[2] ← KHÔNG BAO GIỜ
Dev thêm một <div> bọc ngoài là toàn bộ locator hỏng. Đây chính là thứ mà chức năng "Copy full XPath" của Chrome tạo ra, và là lý do Bài 7 khuyên không dùng nó. Luôn bắt đầu bằng // kèm một điều kiện.
Bài tập 6
Kiểm tra bằng $x("...") trong Console.
6.1 Chọn nút "Đăng nhập" theo text.
6.2 Chọn li chứa bug "Thanh toán Stripe trừ tiền 2 lần" — chú ý bẫy text() vs .
6.3 Từ td chứa "TC002", lấy nút "Xem" cùng hàng. Viết hai cách: dùng ancestor:: và dùng //tr[td[...]].
6.4 Lấy ô trạng thái (td.status) của hàng TC004.
6.5 Chọn tất cả .badge có text "New" (2 cái).
6.6 Lấy .card-value của thẻ card có label "Tỉ lệ pass" — dựa vào text của label, không dựa vào vị trí.
6.7 Đoán rồi kiểm tra:
$x("//input[2]").length
$x("(//input)[2]").length
$x("//li[text()='Sai mật khẩu vẫn đăng nhập được']").length
$x("//li[contains(.,'Sai mật khẩu')]").length
6.8 Với mỗi bài từ 6.1 đến 6.6, tự trả lời: cái này CSS làm được không? Nếu được thì viết bằng CSS luôn.
Đáp án bài 6
6.1 //button[normalize-space()='Đăng nhập'] — ra 1. //button[text()='Đăng nhập'] cũng ra 1 ở đây vì text sạch, nhưng normalize-space() là thói quen tốt hơn.
6.2 //li[contains(.,'Thanh toán Stripe')] — ra 1.
Dùng text() sẽ ra 0, vì text nằm trong span con. Nếu Linh vướng ở đây thì đúng là đã học được điều quan trọng nhất của bài.
6.3
//td[normalize-space()='TC002']/ancestor::tr//button
//tr[td[normalize-space()='TC002']]//button
Cả hai ra 1. Cách 2 thường dễ đọc hơn khi maintain — bên trong [...] là một điều kiện lồng, đọc là "tr nào có td chứa TC002".
6.4 //tr[td[normalize-space()='TC004']]/td[contains(@class,'status')] — ra 1, chứa BLOCKED.
6.5 //span[@class='badge'][normalize-space()='New'] — ra 2. Viết hai [...] liền nhau nghĩa là "và".
6.6 //p[normalize-space()='Tỉ lệ pass']/following-sibling::p[@class='card-value'] — ra 1, chứa 88.14%.
Đây là mẫu rất giá trị: neo vào label (text ổn định, dev ít đổi) rồi nhảy sang giá trị bên cạnh. Bền hơn nhiều so với nth-child(2), vì thứ tự các card có thể thay đổi.
6.7
3 ← mỗi input là "input thứ 2 của cha" ở đâu? Thực tế: các input trong form
không phải anh em liền nhau về loại... chạy thử để thấy con số thật
1 ← (//input)[2] — đúng một, là ô email
0 ← bẫy text()
1 ← contains(.,...) hoạt động
Với dòng 1, quan trọng không phải nhớ con số mà là thấy rằng nó không phải 1 — chỉ số không bọc ngoặc cho ra kết quả không như trực giác.
6.8
- 6.1 — CSS không làm được (theo text)
- 6.2 — không (theo text)
- 6.3 — không (theo text + đi lên cha)
- 6.4 — không, trừ khi dùng vị trí:
tbody tr:nth-child(4) .status - 6.5 — không (theo text)
- 6.6 — không (theo text)
Nhận ra chưa? Gần như mọi lần cần XPath đều vì cùng một lý do: text. Nếu app có data-testid đầy đủ, nhu cầu dùng XPath giảm đi phần lớn — đó là lý do bài 1.3 khuyên đề xuất thêm data-testid với dev.
Bài 7 — Locator ổn định
Bài quan trọng nhất của module. Selector đúng thì dễ; selector không hỏng sau mỗi lần dev sửa UI mới là kỹ năng.
7.1 Thứ tự ưu tiên
data-testid— dev thêm riêng cho test. Không ai sửa nó khi đổi giao diện. Tốt nhất.idổn định — kiểm tra xem có phải sinh tự động không (id="mui-9fa3"là sinh tự động).- Thuộc tính có ý nghĩa chức năng —
name,type,placeholder,aria-label. Đổi thì chức năng cũng đổi, nên tương đối bền. - Text (qua XPath) — bền về mặt cấu trúc nhưng hỏng khi đổi ngôn ngữ hoặc sửa câu chữ. Cẩn thận với app đa ngôn ngữ.
- Class có ý nghĩa —
.login-form,.btn-primarythì ổn. - Vị trí (
nth-child, chỉ số XPath) — dùng khi hết cách. Thêm một phần tử là hỏng. - XPath tuyệt đối — không bao giờ.
7.2 Dấu hiệu locator sẽ hỏng
Class sinh tự động. Các framework CSS hiện đại sinh ra tên class dạng hash:
<div class="css-1a2b3c"> <!-- CSS-in-JS / Emotion -->
<div class="makeStyles-root-42"> <!-- Material UI -->
Những chuỗi này đổi mỗi lần build lại. Trên trang luyện tập, .css-1a2b3c và .css-9z8y7x là ví dụ giả lập — nhận ra chúng và đừng dùng.
Chuỗi nth-child dài. div > div:nth-child(3) > div > span:nth-child(2) — đúng hôm nay, hỏng tuần sau.
Selector quá dài. Mỗi tầng là một điểm phụ thuộc vào cấu trúc. #login-box input[type='email'] (2 tầng) bền hơn body > main > section > form > div > input (6 tầng).
7.3 Tại sao không dùng "Copy selector" của Chrome
Chuột phải → Copy → Copy selector cho ra kết quả kiểu:
#login-box > form > input:nth-child(4)
Chrome không biết ý định của Linh. Nó chỉ tạo ra đường đi nào cũng được miễn tới đúng phần tử — luôn là đường phụ thuộc cấu trúc, tức là đường dễ hỏng nhất. Dùng nó để xem cấu trúc, rồi tự viết lại locator dựa trên thuộc tính.
7.4 Nội dung động
<span class="badge">Còn 3 phút</span>
Text đổi liên tục → đừng khớp cả câu, dùng contains(.,'Còn') hoặc khớp vào class/testid.
Với danh sách sinh động (bảng kết quả test, danh sách bug), đừng bao giờ khớp theo vị trí hàng — hôm nay TC002 ở hàng 2, mai thêm case mới là nó thành hàng 5. Luôn neo theo nội dung định danh:
//tr[td[normalize-space()='TC002']]//button
Bài tập 7
7.1 Câu hỏi còn nợ từ trước. Hai selector này đều chọn đúng ô email:
input[type='email']
[name='email']
Chọn cái nào? Cái nào có nguy cơ hỏng cao hơn khi dev đổi giao diện, và tại sao?
7.2 Xếp bốn selector sau theo độ bền, từ bền nhất tới dễ hỏng nhất. Giải thích từng cái:
#login-box > form > input:nth-child(4)
[data-testid='password-input']
input[type='password']
.field.required[type='password']
7.3 Chuột phải vào nút "Xem" của TC003 → Copy → Copy selector. Dán vào Console kiểm tra. Rồi trả lời: nếu dev thêm một cột "Ưu tiên" vào giữa bảng, selector đó còn chạy không? Selector Linh tự viết ở bài 3.5 thì sao?
7.4 Trên trang luyện tập, tìm hai selector dùng class sinh tự động. Viết lại locator cho hai phần tử đó bằng cách bền hơn.
7.5 Viết locator cho nút "Xem" của case đang FAIL đầu tiên. Hai cách: một dựa vào vị trí, một dựa vào nội dung. Cái nào nên đưa vào code thật?
7.6 Giả sử dev đổi text nút "Đăng nhập" thành "Đăng nhập ngay". Trong các selector Linh đã viết ở bài 3.2 và 6.1, cái nào hỏng, cái nào sống?
Đáp án bài 7
7.1 Chọn [name='email'].
Lý do: name là thuộc tính chức năng — nó là khoá mà form dùng để gửi dữ liệu lên server. Đổi nó thì backend phải sửa theo, nên dev gần như không đổi khi chỉ chỉnh giao diện.
type='email' thì rủi ro hơn: nó chỉ ảnh hưởng bàn phím hiển thị trên mobile và validate của browser. Dev đổi sang type='text' để tự viết validate riêng — chuyện xảy ra thường xuyên — và test hỏng ngay dù giao diện không đổi gì trong mắt người dùng.
Cách bền hơn cả: yêu cầu data-testid.
7.2 Từ bền nhất:
[data-testid='password-input']— tồn tại chỉ để test dùng.field.required[type='password']— nhiều điều kiện nhưng đều là class có ý nghĩa; hỏng nếu dev đổi cách quản lý CSSinput[type='password']— bền hơn tưởng thật, vìtype=passwordlà chức năng che ký tự, khó bỏ; nhưng không phân biệt được nếu trang có thêm ô "xác nhận mật khẩu"#login-box > form > input:nth-child(4)— hỏng ngay khi thêm/bớt/đổi thứ tự bất kỳ phần tử nào trong form
Về số 3: nó cũng cho thấy vấn đề của locator "đúng nhưng không duy nhất" — thêm ô confirm password là ra 2 phần tử, và findElement âm thầm lấy cái đầu.
7.3 Chrome cho ra đường dẫn theo vị trí, kiểu #cases > table > tbody > tr:nth-child(3) > td:nth-child(5) > button. Thêm một cột vào giữa bảng → td:nth-child(5) không còn là cột hành động → hỏng.
[data-testid='view-TC003'] không quan tâm bảng có bao nhiêu cột, thứ tự thế nào → vẫn chạy. Đây là toàn bộ lập luận của bài 7, gói trong một ví dụ.
7.4 a.nav-link.css-1a2b3c (link Reports) và div.card.css-9z8y7x (card Tổng case).
Viết lại:
- Link Reports:
//a[normalize-space()='Reports']hoặca[href='#report']—hreflà thuộc tính chức năng, khá bền - Card Tổng case:
//p[normalize-space()='Tổng case']/following-sibling::p— neo vào label
7.5
tbody tr:nth-child(2) .btn-small /* theo vị trí */
//tr[td[normalize-space()='TC002']]//button /* theo nội dung */
Đưa cách thứ hai vào code. Cách thứ nhất giả định TC002 luôn ở hàng 2 — giả định sai ngay khi có case mới được thêm vào bảng, mà bảng test case thì thêm case liên tục.
7.6 .btn-primary (bài 3.2) sống — không liên quan tới text.
//button[normalize-space()='Đăng nhập'] (bài 6.1) hỏng — khớp chính xác nên không còn đúng.
//button[contains(.,'Đăng nhập')] thì sống, vì "Đăng nhập ngay" vẫn chứa "Đăng nhập".
Bài học: khớp text bằng contains bền hơn khớp chính xác. Nhưng cả hai đều hỏng khi app đổi ngôn ngữ — nên với app đa ngôn ngữ, tránh locator theo text.
Bài 8 — Bốn ca đặc biệt
Chưa cần làm được ngay, nhưng phải nhận ra khi gặp — nếu không sẽ mất nhiều giờ debug một thứ vốn không phải lỗi của locator.
8.1 iframe
<iframe> nhúng một document hoàn toàn khác vào trang. Selector của trang ngoài không thấy được gì bên trong.
Dấu hiệu: Ctrl+F trong Elements tìm thấy phần tử, nhưng code báo NoSuchElementException. Kiểm tra xem phần tử có nằm trong <iframe> không.
Cách xử lý (Selenium): driver.switchTo().frame(...) trước, xong việc thì driver.switchTo().defaultContent(). Trang luyện tập có #chart-frame để Linh thấy hình dạng của nó trong Elements.
8.2 Shadow DOM
Web component (<video> mặc định của browser, nhiều thư viện UI) có DOM riêng bị đóng kín. Trong Elements hiện dòng #shadow-root. Cả CSS selector lẫn XPath đều không xuyên qua được ranh giới này — cần getShadowRoot() hoặc JavaScript.
8.3 Phần tử ẩn
Đã gặp ở bài 2.2: display:none vẫn nằm trong DOM, vẫn khớp selector, nhưng không click được. Nếu findElement thành công mà click() báo ElementNotInteractableException, gần như chắc chắn là đang bắt được phần tử ẩn — hoặc bắt đúng một phần tử trùng selector nhưng đang ẩn (ví dụ modal có sẵn trong DOM từ đầu).
8.4 Flutter Web — cảnh báo riêng cho project của Linh
InkedInn là Flutter/FlutterFlow. Nếu sau này có yêu cầu automation cho bản web của một app Flutter, cần biết trước: Flutter Web thường render bằng CanvasKit, tức là vẽ toàn bộ giao diện lên một thẻ <canvas> duy nhất. Không có <input>, không có <button>, không có DOM để viết selector.
Cách xử lý phụ thuộc cấu hình build (bật semantics tree để sinh ra các phần tử flt-semantics, hoặc dùng Flutter integration test / Patrol thay vì Selenium). Điều cần làm ngay khi nhận yêu cầu: mở DevTools trên bản web đó và xem DOM có phần tử thật hay chỉ có canvas. Câu trả lời quyết định chọn công cụ nào — và biết điều này trước khi cam kết timeline sẽ tiết kiệm cho Linh rất nhiều.
Điểm quan trọng: kỹ năng CSS/XPath trong module này vẫn dùng được cho mọi web app thông thường. Chỉ riêng Flutter Web là ngoại lệ cần kiểm tra trước.
Bài 9 — Tổng hợp trên trang thật
Không có đáp án. Đây là bài kiểm tra.
9.1 Trên trang luyện tập
Viết locator ổn định (theo tiêu chuẩn Bài 7, không dùng vị trí) cho:
- Ô nhập mật khẩu
- Checkbox "Ghi nhớ đăng nhập"
- Nút Đăng ký đang disabled
- Ô trạng thái của TC004
- Nút Xem của case Payment Stripe
- Tất cả bug mức critical
- Text tiêu đề của bug #288-103
- Giá trị "88.14%" — không dùng nth-child
- Tất cả hàng có kết quả không phải PASS
- Badge "In Progress"
Với mỗi cái: xác nhận .length bằng Console, và ghi một câu về locator sẽ hỏng trong tình huống nào.
9.2 Trên một trang web thật
Chọn một trang có form đăng nhập thật — trang nội bộ của công ty, hoặc bất kỳ trang public nào. Làm lần lượt:
- Viết locator cho ô username, ô password, nút submit
- Kiểm tra từng cái ra đúng 1 phần tử
- Với mỗi locator, xếp nó vào bậc nào trong thang ưu tiên ở Bài 7.1
- Tìm ít nhất một phần tử trên trang mà không thể viết locator ổn định — và giải thích vì sao
- Nếu là trang của công ty: ghi lại danh sách chỗ nên có
data-testidvà gửi cho dev
Bước 5 là bước có giá trị thật ngoài việc học. Danh sách đó biến module này thành một đóng góp cho project, không chỉ là bài tập.
9.3 Tự kiểm tra
Trả lời không cần mở lại tài liệu:
- Khác nhau giữa
.a .b,.a.b,.a > .b? - Ba việc CSS không làm được?
//input[2]và(//input)[2]khác nhau thế nào?- Vì sao
//li[text()='abc']không khớp khi text nằm trong<span>con? - Vì sao không dùng Copy selector của Chrome?
- Locator nào bền nhất, và vì sao?
Chỗ nào ngập ngừng — quay lại đúng bài đó, 15 phút là đủ.
Xong module khi nào
Khi mở một trang web bất kỳ chưa từng thấy, Linh viết được locator cho phần tử bất kỳ trong khoảng một phút, không cần copy từ Chrome, và giải thích được locator đó sẽ hỏng trong tình huống nào.
Lúc đó phần khó của Selenium chỉ còn là Java — mà đó đúng là việc Giai đoạn 1 và 2 đang lo.