Storybook 中的視覺化測試
要交付沒有錯誤的 UI 非常困難。過去,開發人員使用單元測試和快照測試來掃描 HTML 程式碼中的錯誤。但是這些方法無法呈現使用者實際看到的東西,因此錯誤始終無法消除。
視覺化測試透過擷取和比較真實瀏覽器中的圖片快照來捕捉錯誤。它讓您可以自動化檢查 UI 外觀是否正確的流程。
什麼是視覺化錯誤?
視覺化錯誤無處不在。元素被截斷、顏色或字體樣式不正確、版面配置損壞,以及缺少錯誤狀態。
現在每間公司都是軟體公司。這表示每間公司都有責任維護 UI。但如果您和我一樣,您可能會注意到公司似乎永遠沒有足夠的人員來隨時監控 UI 的每個部分。
視覺化錯誤是 UI 外觀中非故意的錯誤,使其看起來不可靠。它們是容易用肉眼看出來,但常見的測試方法無法捕捉到的回歸錯誤。
大多數測試旨在驗證邏輯,這是合理的:您執行一個函數,取得其輸出,並檢查其是否正確。電腦非常擅長驗證資料。但是外觀如何呢?
嗯,這個問題有兩個層面。
1. 外觀是否正確?
以這個 Task 元件為例。它的外觀會根據其所處的狀態而有所不同。我們顯示一個已選取(或未選取)的核取方塊、一些關於任務的資訊,以及一個釘選按鈕。當然,還有所有相關的樣式。
第一個挑戰只是驗證元件在所有這些情境中的外觀。這需要大量調整 props 和 state 以設定和測試每個案例。喔,而且電腦真的無法告訴您它是否符合規格。您,開發人員,必須目視檢查它。
2. 外觀仍然正確嗎?
您第一次建置時是正確的。它在所有狀態下看起來都不錯。但是變更會隨著開發的自然進程而發生。錯誤不可避免地會偷偷溜進來。對於介面來說尤其如此。一個小的 CSS 調整可能會破壞元件或其某個狀態。
您無法在每次進行變更時手動檢查 UI 的廣度。您需要更自動化的東西。
視覺化測試
視覺化測試讓您可以使用一個統一的工作流程來處理這兩項任務。它是驗證元件外觀的過程,無論是在您建置它的時候,還是在您迭代以交付新功能的時候。
以下是視覺化測試工作流程的外觀
- 🏷 隔離元件。使用 Storybook 專注於並一次測試一個元件。
- ✍🏽 寫出測試案例。 每個狀態都使用 props 和模擬資料重現。
- 🔍 手動驗證 每個測試案例的外觀。
- 📸 自動捕捉 UI 錯誤。 擷取每個測試案例的快照,並使用機器式差異比對來檢查回歸錯誤。
視覺化測試的重點是將 UI 與應用程式的其餘部分(資料、後端、API)隔離。這讓您可以個別觀察每個狀態。然後您可以手動抽查並自動回歸測試這些狀態。
讓我們詳細了解每個步驟。
1. 隔離元件
透過一次測試一個元件並為其每個狀態編寫測試案例,可以更輕鬆地精確找出錯誤。傳統方法是在首次使用元件的應用程式頁面上建置元件。這使得模擬和驗證所有這些狀態變得困難。有一個更好的方法 — Storybook。
Storybook 是用於隔離建置元件的業界標準。Twitter、Slack、Airbnb、Shopify、Stripe 和 Microsoft 都在使用它。它封裝為一個小型獨立工具,與您的應用程式並存,為您提供
- 📥 一個沙箱,用於隔離呈現每個元件
- 🔭 將其所有狀態視覺化為故事
- 📑 為每個元件記錄 props 和使用指南
- 🗃️ 一個所有元件的目錄,使探索更容易
讓我們回到 Task 元件。隔離它表示我們單獨載入和呈現這個元件本身。為此,我們需要 Storybook。
設定 Storybook
我們的專案已預先設定為使用 Storybook。設定檔位於 .storybook
資料夾中,所有必要的腳本都已新增至 package.json
。
我們可以從為 Task 元件建立故事檔案開始。這會在 Storybook 中註冊元件並新增一個預設測試案例。
import Task from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
export const Default = {
args: {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
},
};
最後,執行以下命令以在開發模式下啟動 Storybook。您應該會看到 Task 元件載入。
yarn storybook
我們現在準備好寫出測試案例。
2. 寫出測試案例
在 Storybook 中,測試案例稱為故事。故事捕捉元件的特定狀態 — 瀏覽器中實際呈現的狀態。
Task 元件有三種狀態 — default、pinned 和 archived。我們將為每個狀態新增一個故事。
import Task from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
export const Default = {
args: {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
},
};
export const Pinned = {
args: {
task: {
id: '2',
title: 'QA dropdown',
state: 'TASK_PINNED',
},
},
};
export const Archived = {
args: {
task: {
id: '3',
title: 'Write schema for account menu',
state: 'TASK_ARCHIVED',
},
},
};
3. 驗證
驗證是您評估元件在 Storybook 中的外觀。也就是說,它是否符合設計規格?
通常的開發工作流程是
- 編輯程式碼
- 取得元件的適當狀態
- 評估其外觀
然後重複整個週期,直到您驗證所有狀態。
透過為每個狀態編寫故事,您可以省略第二個步驟。您可以直接從編輯程式碼到驗證所有測試案例。因此,大幅加快整個流程。
寫出故事也浮現出如果您以更臨時的方式開發,您可能不會考慮到的情境。例如,如果使用者輸入非常長的任務會發生什麼事?讓我們新增該故事以找出答案。
const longTitleString = `This task's name is absurdly large. In fact, I think if I keep going I might end up with content overflow. What will happen? The star that represents a pinned task could have text overlapping. The text could cut-off abruptly when it reaches the star. I hope not!`;
export const LongTitle = {
args: {
task: {
id: '4',
title: longTitleString,
state: 'TASK_INBOX',
},
},
};
現在我們已經驗證了每個測試案例的外觀,我們可以繼續下一步。自動捕捉回歸錯誤。但首先,提交您的變更。
4. 自動捕捉回歸錯誤
Task 元件的外觀符合我們在所有用例中的預期。但是,我們如何確保一條遺漏的 CSS 行不會在未來破壞它?手動瀏覽整個元件目錄以進行每次變更是不切實際的。
這就是為什麼開發人員使用視覺回歸測試工具來自動檢查回歸錯誤。Auth0、Twilio、Adobe 和 Peloton 都使用 Chromatic(由 Storybook 團隊建置)。
在這一點上,我們知道元件處於良好狀態。Chromatic 將擷取每個故事的圖片快照 — 如同它在瀏覽器中顯示的那樣。然後,每當您進行變更時,都會擷取新的快照並與先前的快照進行比較。然後,您會審查發現的任何視覺差異,以判斷它們是故意的更新還是意外的錯誤。
設定 Chromatic
登入並建立新專案並取得您的專案權杖。
Chromatic 是專為 Storybook 建置的,無需任何設定。執行以下命令將觸發它擷取每個故事的快照(使用雲端瀏覽器)。
npx chromatic --project-token=<project-token>
第一次執行將設定為基準,即起點。並且每個故事都有自己的基準。
執行測試
在每次提交時,都會擷取新的快照並與現有基準進行比較,以偵測 UI 變更。讓我們看看實際運作的檢查。
首先,對 UI 進行調整。我們將變更釘選圖示和文字樣式。更新 Task 元件,然後提交並重新執行 Chromatic。
import PropTypes from 'prop-types';
export default function Task({
task: { id, title, state },
onArchiveTask,
onTogglePinTask,
onEditTitle,
}) {
return (
<div
className={`list-item ${state}`}
role="listitem"
aria-label={`task-${id}`}
>
<label
htmlFor="checked"
aria-label={`archiveTask-${id}`}
className="checkbox"
>
<input
type="checkbox"
disabled={true}
name="checked"
id={`archiveTask-${id}`}
checked={state === "TASK_ARCHIVED"}
/>
<span
className="checkbox-custom"
onClick={() => onArchiveTask("ARCHIVE_TASK", id)}
role="button"
aria-label={`archiveButton-${id}`}
/>
</label>
<label htmlFor="title" aria-label={title} className="title">
<input
type="text"
value={title}
name="title"
placeholder="Input title"
style={{ textOverflow: "ellipsis" }}
onChange={(e) => onEditTitle(e.target.value, id)}
/>
</label>
{state !== "TASK_ARCHIVED" && (
<button
className="pin-button"
onClick={() => onTogglePinTask(state, id)}
id={`pinTask-${id}`}
aria-label={state === "TASK_PINNED" ? "unpin" : "pin"}
key={`pinTask-${id}`}
>
+ <span className={`icon-star`} />
</button>
)}
</div>
);
}
Task.propTypes = {
/** Composition of the task */
task: PropTypes.shape({
/** Id of the task */
id: PropTypes.string.isRequired,
/** Title of the task */
title: PropTypes.string.isRequired,
/** Current state of the task */
state: PropTypes.string.isRequired,
}),
/** Event to change the task to archived */
onArchiveTask: PropTypes.func.isRequired,
/** Event to change the task to pinned */
onTogglePinTask: PropTypes.func.isRequired,
/** Event to change the task title */
onEditTitle: PropTypes.func.isRequired,
};
您現在將看到差異。
回歸測試確保我們不會意外引入變更。但仍然由您決定變更是否為故意的。
✅ 如果變更是故意的,請按一下「接受」。新的快照現在將設定為基準。
❌ 如果變更不是故意的,請按一下「拒絕」。建置將會失敗。修正程式碼並再次執行 Chromatic。
在我們的案例中,變更是故意的。繼續並按一下所有故事的「接受」。整個工作流程如下圖所示。
阻止一個錯誤變成多個
一點點洩漏的 CSS 或一個損壞的元件可能會像滾雪球般變成多個問題。這些錯誤特別令人沮喪,難以偵錯。在下一章中,我們將以這些概念為基礎,學習如何捕捉這種級聯問題。