Testing Vue components in the browser

Julia Evans

在瀏覽器中測試 Vue 元件

哈囉!我在這個部落格上的其中一個長期計畫,就是摸索如何在不使用 Node 或任何其他伺服器端 JS 執行環境的情況下撰寫前端 JavaScript

我在前端 JS 專案中經常遇到的一個問題,就是不知道該怎麼為它們寫測試。之前我試過用 Playwright,但感覺很慢又很笨重,得一直啟動新的瀏覽器處理程序,而且還需要一些 Node 程式碼來協調測試。

結果就是我乾脆不測試前端程式碼,感覺不太好。通常我也不太常更新這些專案,所以這個問題不常出現,但如果能更有信心地進行修改就太好了!所以,找出一種我喜歡的前端測試方式,已經在我的願望清單上很久了。

想法:直接在瀏覽器分頁中執行測試

不久前,Alex Chan(艾力克斯·陳)寫了一篇很棒的文章,叫做 Testing JavaScript without a (third-party) framework(《不用第三方框架測試 JavaScript》),回應了我在這個系列中之前的其中一篇文章,那篇文章說明了如何撰寫一個在瀏覽器頁面中執行的小型 unit-testing framework(單元測試框架)。

我當時很喜歡這篇文章,但它只談到 unit testing(單元測試),而我想為我的 Vue 元件撰寫 end-to-end integration tests(端對端整合測試),卻不知道該怎麼做。

所以,前幾天我在跟 Marco(馬可) 聊天時,他說了類似「你知道嗎,你可以直接在瀏覽器中為你的 Vue 元件執行測試」這樣的話,我就想:「嘿,我應該再試試看!!!」

這些都是我昨天才剛做完的,所以當然還有很多可以改進的地方,但在我忘記之前,我想先把過程中注意到的幾件事記錄下來。

這對我來說有點棘手,因為 Vue 官網通常會假設你在建置流程中會以某種方式使用 Node(有很多「步驟 1:npm install THING」這樣的說明),而我不想使用 Node/Deno 等等。不過,後來發現其實沒有那麼複雜。

我接下來要談的測試對象,就是這個我在 2023 年寫的 zine 回饋網站

測試框架:QUnit

我用的是 QUnit。效果很好,但關於它的運作方式我沒有什麼特別想說的,就先這樣。我覺得艾力克斯·陳的「自己寫一個測試框架」的做法應該也行得通。我是照著這些說明做的。

我很欣賞 QUnit 有一個「rerun test」按鈕,只會重新執行 1 個測試。因為我的測試中有非常多的網路請求,能夠只執行單一測試,讓除錯時清楚多了,不會那麼混亂。

步驟 1:為測試設定元件

我需要做的第一件事,就是在測試環境中把我的 Vue 元件設定好。

我把主應用程式改成把所有元件放到 window._components 裡,大致像這樣:

const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;

接著我就能寫一個 mountComponent 函式,它做的事情基本上跟我平常的主應用程式一模一樣(用我想使用的元件來渲染一個小小的模板)。唯一的差別在於:

  1. 我可以選擇性地傳入一些額外的資料來當作它的 props。
  2. 它會把元件掛載到一個暫時的隱形 div 上,這個 div 會在測試結束後從 DOM 中移除。這個 div 被定位在頁面之外(position: absolute; top: -10000, ...),所以你看不到它。

以下是使用 mountComponent 函式的樣子:

const {div} = mountComponent(
  '<Page :feedbacks="feedbacks" id=2 />',
  {feedbacks: [testFeedback]},
);

而這是它的程式碼:

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;
}

結果會得到一個 div,我可以在上面用程式化的方式點擊、填寫表單資料、檢查正確的內容是否有出現等等。

步驟 2:加入一些 fixture data(測試資料)

因為我想撰寫 end-to-end integration tests 來確保我的客戶端 JS 能跟伺服器正常搭配運作,所以需要在資料庫中準備一些測試資料。因此我寫了大約 25 行的 SQL 來在資料庫中建立一些測試資料,並在開發伺服器上新增了一個端點來執行這段 SQL,將測試資料重設為已知的狀態。

async function reset() {
    return fetch('/api/reset_test_data', {method: "POST"})
}

接著,我只要在任何需要測試資料的測試開頭執行 await reset() 就好。

我的 reset() 函式其實不一定每次都會完全重設所有東西,這有點不好,但一開始還算堪用,之後也隨時可以改進。

步驟 3:一個基本的測試

以下是一個基本測試的樣子!基本上就是渲染出 div,並確認它包含大致正確的資料。

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'));
})

這些就是所有的基本要素了!接下來分享一下過程中遇到的幾個問題

等待頁面中的部分內容渲染完成

我的測試中有很多網路請求,需要一些時間才能完成,讓 Vue 程式碼處理好結果並更新 DOM。

我想我們很久以前就學到,在測試中隨便加入 sleep() 呼叫、祈禱時間剛好對上,既緩慢又不穩定,而且非常令人沮喪,所以我需要另一種做法。

就我所知,常見的處理方式是想辦法透過 DOM 來判斷是否可以繼續。例如「如果這個按鈕是可見的,我們就可以……」。

所以我寫了一個小小的 waitFor() 函式,每 20 毫秒輪詢一次,看看條件是否已經完成。它會在 2 秒後逾時。

以下是使用它的樣子:

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();
  // rest of test goes here... 
})

看起來外面已經有很多這個概念的實作,而且都比我的考慮得更周全。(快速 Google 一下:qunit-wait-forplaywright expect.poll

要找出該等待的正確目標並不容易

在某些情況下,我以為已經在 DOM 中找到了正確的等待目標(「只要等這個 textarea 出現就好!」),但結果因為程式內部的一些細節,其實我需要等待之後出現的另一個東西,而那很難精確掌握。

最後我修改了其中一個元件,讓它在完成某個重要動作時,在 DOM 中加入一個隨意的屬性值(像是 data-this-thing-is-ready=true),感覺不太好。

我最好的猜測是,修正這類測試問題的正確做法,是進行重構,讓應用程式對使用者來說也更可靠:如果 DOM 中有一個元素實際上還沒準備好讓使用者互動,或許我根本就不該先把它顯示出來!

加入一些 CSS 類別來識別物件(但這樣做對嗎?)

我最後在一些需要在測試中找到的 HTML 元素上加入了幾個類別,不論是因為需要點擊它們,或是需要等待它們出現在 DOM 中。

我之後可能會想改變這個做法——前端測試框架似乎建議避免使用 CSS 類別,而是改用像是 getByRole 這類方式,或是退而求其次使用像是 data-testid 這樣的屬性。感覺應該有辦法同時讓應用程式更無障礙、也更容易測試。

填寫表單很棘手

要填寫表單,我不能只是設定 value,還需要派發一個事件來告訴 Vue 這個元素已經改變。例如,checkboxtextarea 需要不同類型的事件。

textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));

這有點煩人,也讓我意識到為什麼我可能會想使用某種 UI 測試函式庫,例如:

測試涵蓋率

我想了解一下自己的 test coverage(測試涵蓋率)大概是多少,結果發現 Chrome 其實內建了針對 JS 和 CSS 的 code coverage(程式碼涵蓋率) 功能!

我的 JS 是用 esbuild 打包成一個叫做 bundle.js 的檔案,所以我只要查看 bundle.js,就能看到哪些程式碼行沒有被涵蓋到。

這個過程有點挑剔:我必須在 Chrome 開發者工具中關掉 sourcemaps(原始碼對應檔) 才能讓它正常運作,而且要看到涵蓋率資料,還得執行一系列不太直覺的特定操作。

這實在太有趣了!

跟這些系列文章一貫的情況一樣,我其實從來沒有真正擔任過前端或後端開發者(除了為自己開發!),而且我覺得自己一直在學習如何完成一些超級基礎的任務。

做這件事我真的玩得很開心。我的前端專案總是感覺很脆弱,因為它們都沒有測試,或許有一天我會有一個讓自己有信心的測試套件!

還有一些我仍在思考的事情:

  • 在寫這篇文章的過程中,我發現了一個叫做 Testing Library 的前端測試函式庫,它對如何撰寫測試有很多指南,跟我一開始的想法非常不同。我嘗試把所有東西都改寫成使用 Testing Library,感覺還不錯,所以就看看後續發展如何。他們有發布一個不需要 Node 就能運作的 .umd.js 檔案。
  • 我還不太確定對於完全沒有辦法在命令列上執行這些測試這件事該怎麼看。也許有個簡單的方法可以主要在瀏覽器中工作,但如果我想的話,也能在 CI 中執行它們?

原文由 Julia Evans 發布

本文章由 muse-spark-1.2-contributor 進行翻譯