某天,我們從設計師那裡拿到了設計圖

於是我們開始切版,跟往常一樣,主要注重在畫面的樣式與互動上,此時並不會考慮到 E2E 測試

若開發期間,很幸運地拿到了 QA 的測試案例(或任何寫測試案例的人),我們是否可以在切版的當下,也將未來寫測試案例的情況也加入考慮?

此外,這裡提到的測試案例是指 E2E 測試(自動化測試),而非人工的整合測試

為了讓寫測試的過程能更順利,依據測試案例上的各種 action,例如:點擊某個 button、在 input 輸入文字,或是從某個 div 元素上獲取文字。在這些計畫做事的 DOM 元件上安插屬性(attribute)作為 querySelector 的查詢指標

若手上沒有測試案例又該如何呢?沒有測試案例,就無法準確知道哪些元素會被查詢,只能用推測的。此時腦中閃過一個想法:

如果制定一些規則,然後按照這個規則去添加屬性,是否就可以維持一定的覆蓋率?

因此我根據自己寫測試的經驗,嘗試制定了一些規則。我將會分成以下幾個部分說明:

  1. 元件取向
  2. 位置取向
  3. 狀態取向
  4. 無形資料取向
  5. 結構取向

以下所有程式,我會以 React 元件來解釋。實際上無論框架,最終都會回到 HTML 元素身上

元件取向

頁面元件(Page Component)

插入 data-test-page 屬性於最外層的 HTML 元素:data-test-page='<頁面元件名稱>'

有了 data-test-page 屬性,我們可以很快地辨認出這個頁面元件的範圍(我更喜歡稱之為「邊界」)

註:屬性名稱全看個人喜好,這裡展示的是我自己覺得不錯的命名方式 🙃

例如我們有一個 home 元件:

function HomePage() {
  return <div data-test-page="home">{/* ... */}</div>;
}

基本元件(Basic Component)

插入 data-test-comp 屬性於最外層的 HTML 元素:data-test-comp='<元件名稱>'

有了 data-test-comp 屬性,我們可以很快地辨認出這個基本元件的範圍

function Button() {
  return <div data-test-comp="button">{/* ... */}</div>;
}

位置取向

元件會在不同地方重複使用(這也是我們要抽成元件的初衷,DRY 原則),因此,data-test-comp 就會出現重複的情況,而且如果在相近的地方有多個重複的元件,就不太好快速辨識目標元件

比如說我們在一個頁面上,用了 5 個 button 元件,而測試案例的動作是:「選取其中一個 button,然後點擊它。」此時,我們必須先找出所有 button 的元素,然後在裡面尋找我們要做點擊的那個 button(比對 button 的文字;或是直接指定第幾個 button 元素 ⋯⋯ 等)

這個過程不困難,但我認為這個過程很煩,而且不斷重複在做這個查找的動作。於是我開始思考,是否有更精確、更快速的方式。如果有一個唯一值(或接近唯一的值,亦即很少重複),這個元素尋找的過程將會簡單許多

因此,像是這樣的情境,我們需要另一個表示功能的特徵1 屬性(這個特徵屬性最好是唯一值)

使用 data-test-feat 屬性,用來辨識這個元件在這裡所提供的功能(或目的)

我認為這個屬性比較偏向是非強制性的。倘若只有在元件上標示屬性,仍然可以拿到我們想要的元件。例如:在導覽列(navbar 元件)上的登入 button 及選單裡(menu 元件)的登入 button,分別包含在不同元件裡面。此外,過度使用可能會造成最後很多重複的屬性,唯一性的特徵消失了,精準打擊(aka 準確查詢)的初衷也會隨之化為泡影。因此,要在這之間取得平衡

所以,data-test-feat 屬性大概有兩種加入方式:

在容器元素(container element)

有時候,基於元件的樣式設計(有外層決定寬高、背景色 ⋯⋯ 等),我們會在元件的外面包一層容器元素

function HomePage() {
  return (
    <>
      {/* ... */}
      <div data-test-feat="confirmBtn">
        <Button />
      </div>
      <div data-test-feat="cancelBtn">
        <Button />
      </div>
      {/* ... */}
    </>
  );
}

上面的範例,可以很清楚地知道,在 home 頁面裡,有兩個 button:確認 button 及取消 button

如果在元件外面包一層容器元素不是你的菜,可以透過 props 的方式穿進去元件裡:

透過 props 安插在元件上

若沒有外層的容器元素,透過 props 傳進去是另一個選擇:

function HomePage() {
  return (
    <>
      {/* ... */}
      <Button dataTestFeat="confirmBtn" />
      <Button dataTestFeat="cancelBtn" />
      {/* ... */}
    </>
  );
}

在 button 元件裡:

function Button({ dataTestFeat, btnText, labelText }) {
  return (
    <div data-test-comp="button">
      <label>
        {labelText}
        <button data-test-feat={dataTestFeat}>{btnText}</button>
      </label>
    </div>
  );
}

狀態取向

有時候我們想知道某個元件的狀態,假如我們有一個 toggle button 元件:

toggle button

測試案例中,我們想要在點擊 toggle button 之後,確認它會確實從 on 狀態轉變成 off 的狀態

我們當然可以透過辨認 style 的變化得知它的狀態。如果是用 SASS 的話,或許可以很簡單地從 class 上辨認出(端看 class 怎麼設計了)。然而我最近比較常用的是 tailwindcss,此時就會變得不太直覺。以下是我的 toggle button 元件:

function ToggleButton() {
  const [isOn, setIsOn] = useState(false);
  const handleChange = () => setIsOn((prev) => !prev);
  return (
    <button
      type="button"
      className="rounded-full overflow-hidden relative w-16 h-7 shrink-0 text-white font-semibold text-sm uppercase ml-2.5 bg-neutral-500"
      onClick={handleChange}
    >
      {/* here is what the difference by state */}
      <div className={`absolute transition left-1.5 top-1/2 -translate-y-1/2 ${isOn && "translate-x-9"}`}>
        <div className="rounded-full bg-white w-4 h-4 relative">
          <div className="absolute right-full top-1/2 -translate-y-1/2 px-2 whitespace-nowrap">On</div>
          <div className="absolute left-full top-1/2 -translate-y-1/2 px-2 whitespace-nowrap">Off</div>
        </div>
      </div>
    </button>
  );
}

這個元件裡,translate-x-9 是 on 與 off 狀態上差異的 class。我們仍然可以辨識,但會造成修改樣式時後容易抓不到元素的情況。Utility first 樣式庫的優點,在此時卻成了缺點

像這種情況,建議加入表示狀態的屬性:

function Button({ btnText, labelText }) {
  const [isOn, setIsOn] = useState(false);
  const handleChange = () => setIsOn((prev) => !prev);

  return (
    <div data-test-comp="button">
      <label>
        {labelText}
        <button data-test-state={isOn} onClick={handleChange}>
          {btnText}
        </button>
      </label>
    </div>
  );
}

另一個常見的情境是載入狀態(loading),假如有一個列表與搜尋列元件:

function SearchPage() {
  // ...some state here
  return (
    <div data-test-page="searchPage">
      <div data-test-comp="searchBar">
        <input value={searchValue} data-test-feat="searchInput" />
        <button onClick={handleSearch} data-test-feat="searchBtn">
          Search
        </button>
      </div>
      <div data-test-comp="list" data-test-loading={isLoading}>
        {/* list data here */}
      </div>
    </div>
  );
}

註:基於可讀性,我直接用 HTML 表示

點擊搜尋 button 之後,頁面就會開始 loading 打 API 拿資料,並在結束後,結束 loading 狀態,並更新資料至底下的列表

無形資料取向

假如有一個 card 元件要顯示商品資訊,如在元素上存在 product id 的話,就能快速地找到目標商品。然而商品資訊卡片通常不會顯示 product id,此時就必須將這個值加到屬性當中:

function ProductCard({ productId, ...restProps }) {
  return (
    <div data-test-comp="productCard" data-test-product-id={productId}>
      {/* product card content */}
    </div>
  );
}

至於命名原則,我認為只要夠語意化就行了

結構取向

模擬元素(Simulated Element)

在一些特殊情境下,我們會需要用 div 元素去模擬其他元素

這裡舉個例子,用 div 元素去模擬 table 元素的結構。加入 data-test-el 屬性:data-test-el='<模擬的元素名稱>'

function Table() {
  return (
    <div data-test-el="table">
      <div data-test-el="tbody">{/* ... */}</div>
    </div>
  );
}

或許會有人不太懂為何要這樣做,但我還是可以簡單說明一下

在開發的時候,遇到某些樣式在與 table 有關的元素上(也就是<table><thead><tbody>⋯⋯ 等),在 Safari 瀏覽器下的顯示會不如預期(Safari 的 bug ⋯⋯),因此必須用 div 元素重建整個 table 的結構,因此在加入屬性之後,可以很快地了解整個 table 的結構,藉此增加可讀性

元件的結構語意化

誠如模擬元素,為了增加可讀性,我們也可以在其他地方加入屬性,可以更快地了解整個元件結構。例如一個 dialog 元件:

function Dialog() {
  return (
    <div data-test-comp="dialog">
      <div data-test-el="dialogHeader">this is dialog header</div>
      <div data-test-el="dialogBody">this is dialog body</div>
      <div data-test-el="dialogFooter">this is dialog footer</div>
    </div>
  );
}

加入 data-test-el 屬性之後,可以有效率地一眼看出整個元件的結構(在這裡,dialog 分為 dialog header、dialog body、dialog footer 三個區塊)

結語

開發時,加入屬性將會是一個漫長的過程。在開發的過程中,測試案例並非我們的優先考量。制定規則並遵守它,可以讓我們無腦地加入屬性,並將專注力放在實作樣式外觀、操作互動以及資料上。當開發結束後,撰寫測試案例將會更為順利,並感謝以前的自己

最後,說到底這些規則畢竟只是自己的想法,並非什麼業界標準那麼偉大的東西 😎

開發愉快


  1. Feature,我翻譯成「特徵」 ↩︎