Kiểm thử component Vue ngay trên trình duyệt
Hé lô anh em! Một trong những "dự án dài hơi" mà mình cực kỳ tâm huyết chính là tìm cách viết JS frontend theo lối "thuần khiết": không Node.js, không npm, không build step cồng kềnh.
Nhưng mình luôn gặp một nỗi đau thầm kín: mình thực sự loay hoay trong việc viết test cho đống code đó. Trước đây, mình từng thử dùng các framework nổi tiếng, nhưng cảm giác chúng quá cồng kềnh. Mỗi lần chạy test lại phải khởi động một trình duyệt ảo, rồi còn phải viết thêm code để điều phối công việc. Kết quả là mình tặc lưỡi bỏ qua, code chạy bằng "niềm tin" và manual test. Nhưng chính vì thế, mình lại ngại cập nhật tính năng mới vì thiếu sự tự tin mỗi khi refactor code.
Ý tưởng: Chạy test trực tiếp trên trình duyệt Mình từng đọc được vài bài viết chia sẻ về việc tự xây dựng một bộ test framework siêu nhỏ chạy trực tiếp trên trình duyệt. Ý tưởng đó rất hay cho phần unit testing, nhưng mình lại trăn trở về việc viết test tích hợp (end-to-end integration tests) cho các Vue component.
Tình cờ, trong một lần chém gió cùng người bạn, mình nhận được gợi ý rằng hoàn toàn có thể thực thi các bài test Vue trực tiếp trên trình duyệt. Điều này làm mình nảy số ngay lập tức.
Mình đã bắt tay vào vọc vạch thử nghiệm. Dù vẫn còn nhiều chỗ cần tối ưu, nhưng trải nghiệm rút ra cực kỳ đáng giá. Quả thật là ban đầu cũng hơi khoai, vì hệ sinh thái Vue mặc định đều hướng người dùng đến việc cài đặt Node.js và các công cụ build. Nhưng mình đã quyết tâm tìm ra một lối đi riêng mà không phụ thuộc vào Node. Cuối cùng, mọi chuyện hóa ra lại không quá phức tạp như mình tưởng.
Mình đã chọn một trang web feedback từng viết từ năm 2023 làm "chuột bạch" để thử nghiệm hướng đi này.
Bộ khung test (Test Framework): QUnit
Mình chọn QUnit. Nó chạy cực kỳ mượt mà, dù thú thật là cũng không có gì quá cao siêu để chém gió. Mình nghĩ tự build framework vẫn dư sức cân được. Anh em cứ setup theo hướng dẫn này là xong.
Điểm mình cực thích ở QUnit là nó có cái nút "rerun test" cho phép chạy lại đúng 1 test duy nhất đang chọn. Vì các test case của mình tương tác qua mạng (network requests) rất nhiều, nên việc cô lập để debug từng test một giúp đầu óc bớt căng thẳng đi bao nhiêu.
Bước 1: "Lên đồ" (Setup) Component để test
Việc đầu tiên cần làm là lôi đống Vue component vào môi trường test.
Mình sửa lại app chính để nhét tất cả components vào biến window._components, kiểu như này:
const components = {
'Feedback': FeedbackComponent,
...
}
window._components = components;
Sau đó, mình viết một hàm mountComponent. Về cơ bản, nó hoạt động y hệt cách app chính của mình render một template nhỏ chứa component cần dùng. Chỉ khác ở chỗ:
- Mình có thể truyền thêm data tùy ý để làm
propscho nó. - Nó sẽ mount component vào một thẻ
divtạm thời và ẩn nó đi (sẽ bị xóa khỏi DOM sau khi test xong). Thẻdivnày được đẩy ra rìa màn hình (position: absolute; top: -10000, ...) nên người dùng bình thường sẽ không thấy gì hết.
Đây là cách dùng hàm mountComponent thực tế:
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 />',
{feedbacks: [testFeedback]},
);
Và đây là "nội y" của hàm đó:
function mountComponent(template, data) {
const app = Vue.createApp({
template: template,
data: () => data,
})
for (const [c, v] of Object.entries(window._components)) {
app.component(c, v);
}
const div = document.getElementById('qunit-fixture')
.appendChild(document.createElement('div'));
return div;
}
Kết quả là chúng ta có một cái div ngon lành cành đào để có thể dùng code click thử, điền form, check xem content hiển thị lên có chuẩn không, v.v.
Bước 2: Nạp dữ liệu mẫu (Fixture Data)
Vì mình đang viết end-to-end integration test để đảm bảo JS client tương tác mượt mà với server, nên bắt buộc phải có dữ liệu test trong database. Thế là mình quất tầm 25 dòng SQL để khởi tạo dữ liệu mẫu, đồng thời mở thêm một API endpoint ở dev server để reset đống data này về trạng thái ban đầu bất cứ lúc nào cần.
async function reset() {
return fetch('/api/reset_test_data', {method: "POST"})
}
Sau đó, cứ đầu mỗi test case cần dữ liệu sạch là mình chỉ việc tống await reset() vào là xong.
Thực ra thì hàm reset() này của mình đôi khi vẫn chưa dọn dẹp triệt để 100% đâu (hơi "bẩn" tí), nhưng tạm thời thế là đủ dùng, từ từ cải tiến sau lo gì!
Bước 3: Viết một test case cơ bản
Thử viết một cái test case cơ bản xem sao nhé! Đại khái là ta render cái div ra rồi kiểm tra xem nó có chứa đúng phần text mong muốn hay không.
QUnit.test('renders feedback content', async function (assert) {
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
{feedbacks: [testFeedback]},
);
assert.ok(div.textContent.includes('loved this section'));
})
Thế là hòm hòm mấy bước cơ bản rồi đấy! Giờ mới đến phần hấp dẫn: những pha "ăn hành" thực tế dọc đường đi.
Chờ đợi mòn mỏi để các phần của trang render xong
Trong đống test của mình có ngập tràn các API call (network requests). Mà anh em biết rồi đấy, phải mất một khoảng thời gian thì request mới hoàn thành, rồi Vue mới nhận data, xử lý rồi mới cập nhật lên DOM.
Chắc hẳn chúng ta đều đã nếm trải bài học xương máu từ thời "đồ đá": nhét bừa mấy hàm sleep() vô tội vạ vào test rồi cầu nguyện cho timing chuẩn xác là một trò đùa tai hại. Test vừa chậm, vừa chập chờn (flaky) phát điên. Phải tìm cách khác thôi.
Theo như mình biết, cách chuẩn chỉ nhất là dựa vào DOM để biết khi nào thì nên đi tiếp. Kiểu như: "Nếu cái button này hiển thị trên màn hình rồi thì chúng ta mới bấm".
Thế là mình tự chế một hàm waitFor() nhỏ xinh, cứ mỗi 20ms lại vào thọc (poll) DOM một lần xem điều kiện đã thỏa mãn chưa. Quá 2 giây (timeout) là báo tạch.
Nhìn cách dùng cũng "ra gì và này nọ" phết:
QUnit.test("click item", async function (assert) {
const {div} = mountComponent(
'<Feedback zine_id="test123" image_width="800px" />',
{});
const item = await waitFor(() => div.querySelector('.feedback-item'));
item.click();
// phần còn lại của test case...
})
Thực ra trên mạng người ta viết mấy trò này đầy ra rồi, mà cái nào nhìn cũng xịn xò, bài bản hơn đồ tự chế của mình nhiều (Google nhẹ phát ra ngay: qunit-wait-for, playwright expect.poll).
Tìm đúng cái để "chờ" cũng là cả một nghệ thuật
Đôi lúc mình tự tin nghĩ rằng đã chọn đúng thứ để chờ trong DOM ("chờ cái textarea này hiện ra là ngon ngay!"). Nhưng không, đời không như là mơ. Do cơ chế vận hành ngầm bên trong của app, thực tế là mình lại phải chờ một thứ khác xuất hiện sau đó nữa cơ, mà thứ đó thì cực kỳ khó bắt mạch.
Cuối cùng, mình đành phải "chơi chiêu" bằng cách sửa code component, bắt nó nhét một cái attribute ngẫu nhiên vào DOM khi hoàn thành một tác vụ quan trọng (ví dụ: data-this-thing-is-ready=true). Nhìn code kiểu này thì hơi "phèn" thật sự.
Mình tự rút ra bài học là: cách tốt nhất để giải quyết triệt để vấn đề này là refactor lại code cho ứng dụng chạy mượt mà hơn cho cả người dùng thật. Nếu có một element trên DOM xuất hiện nhưng người dùng chưa thể tương tác ngay được, thì tốt nhất là ta... đừng hiển thị nó lên vội!
Thêm CSS class để định vị element (nhưng làm thế có đúng không nhỉ?)
Mình đã phải thêm kha khá class vào các phần tử HTML để dễ dàng "tóm" được chúng trong test case, dù là để click hay để chờ chúng xuất hiện.
Có lẽ sau này mình sẽ phải đổi cách tiếp cận này. Mấy cái frontend testing framework hiện đại thường khuyên không nên dùng CSS class để query. Thay vào đó, hãy dùng những thứ như getByRole, hoặc hạ sách cuối cùng là dùng data-testid. Cách này vừa giúp app tăng khả năng hỗ trợ tiếp cận (accessibility - a11y) tốt hơn, lại vừa dễ viết test hơn. Tiện cả đôi đường!
Điền form: Tưởng không khó mà khó không tưởng
Để điền dữ liệu vào một cái form, nếu anh em chỉ set mỗi value cho element thôi là chưa đủ đâu nhé. Mình còn phải dispatch (kích hoạt) một cái event để báo cho Vue biết là element đó đã thay đổi. Đã thế, checkbox và textarea lại cần các loại event khác nhau mới chịu cơ.
textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
Đoạn này khá là phiền hà và làm mình chợt nhận ra: "À, hèn chi người ta sinh ra mấy cái UI testing library!". Ví dụ:
- Nhìn ví dụ điền form của Testing Library mà xem, nó tinh giản và khác bọt hoàn toàn so với đống code thủ công của mình.
- Hay như Vue Test Utils: phần xử lý form của họ trông gọn gàng, đơn giản hơn hẳn.
Đo lường độ phủ của test (Test Coverage)
Viết test xong thì phải tò mò xem test coverage (độ phủ) đạt được bao nhiêu phần trăm chứ đúng không? Hóa ra Chrome có sẵn tính năng đo code coverage cực xịn cho cả JS lẫn CSS luôn anh em ạ!
Vì code JS của mình được đóng gói (bundle) vào một file duy nhất là bundle.js bằng esbuild, nên mình chỉ việc mở bundle.js lên là thấy ngay dòng code nào chưa được sờ gáy.
Tuy nhiên, quá trình này hơi "lắm bước" một tí: mình phải tắt sourcemaps trong Chrome DevTools thì mới hoạt động chuẩn xác, và các thao tác để xem được dữ liệu coverage cũng hơi loằng ngoằng, không trực quan lắm.
Vui vcl anh em ạ!
Như mình đã từng chia sẻ, mình chưa từng thực sự đi làm dev frontend hay backend chuyên nghiệp cho ai cả (toàn tự code dự án cá nhân thôi!). Thế nên cảm giác lúc nào cũng như đang đi khai phá và học lại những thứ siêu cơ bản từ đầu ấy.
Cơ mà thề là làm cái này cuốn cực kỳ! Mấy dự án frontend của mình trước giờ cứ mỏng manh dễ vỡ như thủy tinh vì không có test, hy vọng một ngày không xa mình sẽ sở hữu một bộ test suite xịn xò để tự tin bấm deploy!
Mấy thứ mình vẫn đang nung nấu trong đầu:
- Trong lúc viết bài này, mình tình cờ phát hiện ra một thư viện test frontend tên là Testing Library. Triết lý viết test của tụi này khác hẳn những gì mình tự nghĩ ra ban đầu. Mình đã thử viết lại toàn bộ test sử dụng thư viện này và thấy "phê" hơn hẳn, để xem hành trình sắp tới thế nào. Điểm cộng cực lớn là họ có phân phối file
.umd.jschạy ngon ơ mà không cần sờ đến Node. - Cơ mà mình vẫn hơi lăn tăn vụ không có cách nào để chạy đống test này trực tiếp trên command line (CLI). Liệu có cách nào vừa giúp mình code sướng mắt trên trình duyệt, vừa có thể chạy ngon lành trong môi trường CI (Continuous Integration) khi cần không nhỉ?
Comments
Please login to post a comment.