你有沒有遇過這種狀況:改了一行程式碼,結果三天後某個完全沒碰過的功能壞掉了?
或是每次要交付前,都得手動把整個流程再點一遍,深怕漏掉什麼?
還有,大家都說要寫單元測試,但「單元」到底是多大一塊?一個函式?一個檔案?還是一整個功能?
手動測試跟自動化測試差在哪?
講到測試,你可能會想:我每次改完程式都會自己跑一次看看,這不算測試嗎?
算,那叫手動測試,而且它是每個人的起點。
打開瀏覽器點一點、在 console 印個值出來看、丟幾筆假資料進去試試看,這些都是在測試,只是執行的人是你,判斷對錯的也是你。
問題從來不是手動測試不對,而是它撐不到專案長大。
這件事用一個例子最好講,下面這個函式,這篇文章之後都會拿它當例子:
function calculateDiscount(price, memberLevel) {
if (memberLevel === 'gold') return price * 0.8
if (memberLevel === 'silver') return price * 0.9
return price
}它做的事情很單純:看會員等級決定打幾折。
手動測試的三個先天限制
現在假設這個函式在專案裡被 30 個地方用到,而你今天改了它其中一行。
這時候你要怎麼確定自己沒有改壞任何東西?
第一,它會累
理論上這 30 個地方你都該重新點過一次,因為你沒辦法確定哪些頁面會受影響。
實際上你會點幾個?大概是你「覺得有關」的那三四個。
剩下的二十幾個就是靠運氣,而 bug 最愛躲的就是那二十幾個裡面。
第二,它會忘
三個月前你修過一個 bug:後端回傳的會員等級有時候是大寫的 'Gold',字串比對不到,金卡會員被收了原價。
你在函式裡加上 memberLevel.toLowerCase() 修好它,然後手動試了 'Gold'、'GOLD'、'gold',確認三種都會打八折。
三個月後,另一個人來重構這段程式,他把等級比對改寫成查一張對照表,順手把那行 .toLowerCase() 拿掉了。
他有測嗎?有,他點開畫面上的金卡會員,看到八折,覺得沒問題。
因為那筆測試資料剛好是小寫的。
大寫那條路從此沒有人再走過一次,於是同一個 bug 又回到了正式環境。
這種「修好之後又壞掉」的狀況有個名字,叫回歸(regression),它正是自動化測試最主要想擋住的東西。
第三,它不留下紀錄
回想一下你剛剛修大小寫那個 bug 的時候,腦袋裡其實有一份清單:'Gold' 要打折、'GOLD' 要打折、原本的 'gold' 不能壞掉。
你照著這份清單一個一個試過,確認都對,然後關掉 console。
那份清單就這樣消失了。
專案裡不會有任何一個地方寫著「calculateDiscount 該滿足這三件事」,程式碼只寫了它現在怎麼算,沒寫哪些是當初刻意訂下的規則。
所以三個月後接手的人看到 .toLowerCase() 那一行,分不出這是為了修 bug 特地加的,還是誰順手寫的防禦性程式碼。
他只能猜,然後帶著不確定改下去。
如果那份清單當初是寫成測試的,這裡就不用猜了。
他會在測試檔案裡看到一個叫「大寫的 Gold 也要打八折」的測試,那就是答案:這一行是刻意的,別動。
從 console.log 到一個測試
自動化測試就是把「我剛剛用手做的檢查」寫成程式碼,讓電腦幫你重複做。
它的門檻其實比你想的低很多,因為你早就寫過很像的東西了:
// 手動測試:你在 console 裡打這一行,然後用眼睛看結果對不對
console.log(calculateDiscount(100, 'gold')) // 你期待看到 80這行 console.log 已經很接近一個測試了,它只缺了一塊:判斷。
程式印出 80,但「80 是對的」這個結論是你在腦袋裡下的,程式本身完全不知道。
把這個判斷也交給程式,它就變成自動化測試了:
// 自動化測試:讓程式自己判斷對不對
test('gold member gets 20% off', () => {
expect(calculateDiscount(100, 'gold')).toBe(80)
})這幾行你現在還不用會寫,先看懂它在說什麼就好:
test('...')開一個測試,引號裡那句話是它的名字,用來說明這個測試在檢查什麼expect(...)包住你要檢查的東西,這裡包的是calculateDiscount(100, 'gold')的回傳值.toBe(80)是你的預期,意思是「它應該等於 80」,不是的話這個測試就失敗
連起來唸其實就是一句英文:expect calculateDiscount(100, ‘gold’) to be 80。
至於 test 和 expect 是哪裡來的、為什麼可以直接用,後面講測試框架的時候會回頭交代。
這兩段做的事情其實一樣,都是「呼叫函式、檢查結果」。
差別在三個地方。
判斷的人不同
上面那段要你用眼睛看,下面那段由 expect 自己比對,結果不對就直接報錯。
成本不同
上面那段每測一次就要花掉你一次注意力,下面那段寫完之後,跑一百次的成本跟跑一次幾乎一樣。
壽命不同
console.log 通常在你確認完就順手刪掉了,測試則會留在專案裡,變成之後每一次修改的安全網。
自動化取代的是「重複」,不是「人」
不過有件事要講清楚:自動化測試不是要取代手動測試。
像「這個畫面好不好看」「這段流程順不順」這種問題,人的眼睛還是可靠得多。
自動化真正取代的是重複的那一部分,也就是那些你已經確認過一次、但之後每次改動都得再確認一遍的檢查。
換句話說,自動化測試不是什麼新技術,只是把你本來用眼睛做的檢查交給程式碼去做而已。
不過「自動化測試」是個很大的詞,它底下有好幾種,差別在於你一次測多大一塊。
由大到小,大致可以分成三層:
- E2E 測試(end-to-end,端對端測試):模擬一個真實使用者,開瀏覽器、點按鈕、填表單、送出訂單,從頭到尾把整個流程跑完一遍
- 整合測試(integration test):測幾塊程式串起來會不會動,例如結帳流程去呼叫折扣計算、庫存檢查、金流串接,這幾塊接得對不對
- 單元測試(unit test):一次只測一塊獨立的邏輯,例如
calculateDiscount自己算得對不對,不管誰呼叫它,也不管畫面長什麼樣
範圍越大,越接近使用者真正會遇到的情況,但也跑得越慢、越容易因為無關的小改動就失敗。
範圍越小則相反:跑得飛快、壞掉時一眼就知道是哪裡壞的,但它保證不了「這些零件湊起來真的能用」。
所以這三種不是互相取代的關係,實務上通常三種都會有,只是範圍越大的寫得越少——單元測試數量最多,E2E 最少。
這個分布畫出來像個三角形,所以常被叫做測試金字塔(testing pyramid)。
而這篇文章要講的,是最底下也最多的那一層:單元測試。
那「單元」到底是多大一塊?
「單元」是什麼?
單元測試(unit test)裡的「單元」,指的是程式中可以獨立被呼叫、而且有明確輸入輸出的最小一塊邏輯。
在大多數情況下,這一塊就是一個函式(function)或一個 class 的一個方法(method)。
白話講,如果你可以指著一段程式碼說「給它這些輸入,它應該吐出那個結果」,那它就是一個單元。
剛剛那個 calculateDiscount 正好就是一個很典型的單元。
它有清楚的輸入(price 和 memberLevel),有清楚的輸出(打折後的價格),而且不需要資料庫、不需要網路、不需要任何外部東西就能執行。
這也是為什麼我們可以用一行 expect 就把它測完:你只要餵資料進去、比對結果就好。
反過來說,如果一個函式又要打 API、又要讀設定檔、又要改全域變數,它就不是一塊乾淨的單元,測起來也會相對痛苦,這件事我們後面會再回頭談。
所以「單元」沒有想像中那麼玄,它就是一個你能單獨呼叫、而且知道它該回傳什麼的函式。
一個測試長什麼樣子:Arrange-Act-Assert
幾乎所有單元測試都可以拆成三個階段,業界習慣叫它 AAA。
Arrange(準備)
把測試需要的資料、物件準備好。
Act(執行)
呼叫你要測的那個單元。
Assert(驗證)
檢查結果是不是你預期的。
把剛才的例子按這三段拆開:
test('gold member gets 20% off', () => {
// Arrange:準備輸入資料
const price = 100
const memberLevel = 'gold'
// Act:執行要測試的單元
const result = calculateDiscount(price, memberLevel)
// Assert:驗證結果
expect(result).toBe(80)
})這三行註解你不一定要真的寫在程式裡,但腦袋裡最好有這個結構。
當你不知道測試該怎麼開始寫的時候,就問自己這三個問題:我需要什麼資料?我要呼叫哪個函式?我期待什麼結果?
而這三步裡面,真正決定一個測試有沒有用的是最後那個 Assert,我們接著看它。
Assertion:測試的判斷句
剛才三步裡的 Assert,也就是 expect(result).toBe(80) 那一行,正式名稱叫 assertion(斷言)。
它是整個測試裡唯一會下判斷的地方:前面兩步做得再漂亮,只要少了這一行,這個測試就等於沒測。
這不是誇飾,是真的會過。
沒有 assertion 的測試
// ❌ 這個測試永遠會通過,因為它沒有做任何判斷
test('calculateDiscount works', () => {
calculateDiscount(100, 'gold')
})這段程式碼只是呼叫了函式,沒有檢查回傳值。
只要函式不爆炸,測試就會過,就算它回傳 undefined 也一樣。
有 assertion 的測試
// ✅ 有 assertion,結果不對就會失敗
test('gold member gets 20% off', () => {
expect(calculateDiscount(100, 'gold')).toBe(80)
})這段明確宣告了「結果必須等於 80」。
如果哪天有人改壞折扣邏輯,這個測試就會紅燈,你馬上知道。
常見的 assertion
除了 toBe,還有幾種你很快就會用到:
toBe/toEqual:檢查值相不相等,前者比對純值,後者會逐層比對物件內容toThrow:檢查有沒有丟出錯誤toHaveBeenCalled:檢查某個函式有沒有被呼叫過toBeNull/toBeUndefined:檢查是不是特定的空值
不同框架的名字會不太一樣,但概念是一樣的。
所以之後在看別人寫的測試時,可以先找 assertion 那一行,那才是這個測試真正在乎的事情。
測試框架幫你做了什麼?
前面我們一直在用 test() 和 expect(),但一直沒說它們是哪裡來的。
這些是測試框架提供的。
JavaScript 圈常見的有 Jest、Vitest、Mocha。
這篇文章的範例用的是 Jest 和 Vitest 的語法。
這兩套的 API 幾乎一模一樣,test、expect、describe 在兩邊都能直接用,換過去不太需要改寫。
框架主要幫你做三件事。
test():把一段程式碼圈成一個測試
前面那個什麼都沒檢查的例子,你可能會想:既然它不做判斷,那 test() 到底有什麼用?
test() 負責的不是判斷,而是另外三件事:圈範圍、取名字、隔離失敗。
圈範圍的意思是,大括號裡那段程式碼會被當成一個獨立的單位執行,框架知道它從哪開始、到哪結束。
取名字就是第一個參數那個字串,測試失敗時印出來的就是它。
隔離失敗是最容易被忽略、但最關鍵的一個:如果沒有 test() 包著,一個檔案裡第一段程式碼丟出錯誤,整個檔案就停在那裡,後面的檢查全部不會執行。
有 test() 包著,框架會把那個錯誤記成「這個測試失敗」,然後繼續跑下一個測試。
所以你最後拿到的是「10 個測試,3 個失敗,這 3 個分別是什麼」,而不是「程式在第 12 行爆炸了」。
回到那個沒有 assertion 的例子。
test() 確實有把它圈起來、有跑、也有回報結果,該做的事一件都沒少。
只是裡面沒有任何會讓它失敗的東西,所以它回報的永遠是通過。
順帶一提,test() 和 it() 是同一個東西的兩個名字,it 是為了讓句子讀起來像英文(it('returns 80 for gold members')),選你喜歡的就好。
expect():幫你寫判斷跟失敗訊息
沒有框架的話,一個 assertion 得自己這樣寫:
const result = calculateDiscount(100, 'gold')
if (result !== 80) {
throw new Error(`預期 80,實際拿到 ${result}`)
}expect(result).toBe(80) 做的就是這件事,但你不用每次都手寫比較、拋錯、還有那句錯誤訊息。
而且框架印出來的失敗訊息會比你手寫的完整得多,它會告訴你預期什麼、實際拿到什麼、差在哪裡,物件的話還會標出是哪個欄位對不上。
test runner:把測試全部找出來跑一遍
你不會想要每個測試檔案都自己 node 執行一次。
Test runner 會依照命名規則(通常是 *.test.js 或 *.spec.js)自動掃出專案裡所有的測試檔案,一次跑完,然後彙整成一份報告:總共幾個、過了幾個、failed 的是哪幾個、各自壞在哪一行。
它通常還有 watch 模式,你存檔的當下就自動重跑相關的測試——這正是前面說「快到你願意每次存檔都跑一次」能成立的原因。
用 describe 把測試分組
describe 一樣是 Jest / Vitest(還有 Mocha)都有的函式,它讓你把相關的測試收在同一組底下:
describe('calculateDiscount', () => {
test('gold member gets 20% off', () => {
expect(calculateDiscount(100, 'gold')).toBe(80)
})
test('silver member gets 10% off', () => {
expect(calculateDiscount(100, 'silver')).toBe(90)
})
test('normal member pays full price', () => {
expect(calculateDiscount(100, 'normal')).toBe(100)
})
})describe 把同一個對象的測試收在一起,執行結果印出來的時候比較好讀。
裡面每個 test 各自獨立,一個失敗不會影響其他的執行。
測試的名字就是它的文件
好的測試名稱是一句完整的話,描述「在什麼情況下應該發生什麼」。
當測試失敗時,你光看名字就該知道壞掉的是哪個行為,不用點進去看程式碼。
說穿了,框架只負責跑測試跟印結果,「什麼情況下應該得到什麼」還是得你自己想清楚。
什麼是好的單元測試?
會寫測試之後,下一個問題是:怎樣算寫得好?
有幾個特質是公認的。
獨立(Independent)
每個測試都要能單獨執行,而且順序打亂也要能過。
互相依賴的寫法
// ❌ 測試之間互相依賴
let cart = []
test('add item to cart', () => {
cart.push({ id: 1, price: 100 })
expect(cart.length).toBe(1)
})
test('calculate total', () => {
expect(getTotal(cart)).toBe(100) // 依賴上一個測試留下的 cart
})問題出在最上面那個 cart。
它宣告在兩個測試的外面,所以是兩個測試共用的同一份資料。
第一個測試往裡面 push 了一筆 100 元的商品,第二個測試則預期總金額算出來是 100——但第二個測試自己從頭到尾沒有準備過任何商品。
它拿去計算的那筆資料,其實是第一個測試跑完之後留在 cart 裡的殘留物。
也就是說,第二個測試能不能過,取決於第一個測試有沒有先跑、而且有沒有成功。
如果你只想單獨跑第二個測試,它會失敗;如果有人調換順序,它也會失敗。
各自準備資料的寫法
// ✅ 每個測試自己準備資料
test('add item to cart', () => {
const cart = []
addItem(cart, { id: 1, price: 100 })
expect(cart.length).toBe(1)
})
test('calculate total', () => {
const cart = [{ id: 1, price: 100 }]
expect(getTotal(cart)).toBe(100)
})現在兩個測試各自準備自己需要的資料,誰先跑誰後跑都無所謂。
如果多個測試需要同樣的初始化,可以用框架提供的 beforeEach,它會在每個測試前重新執行一次。
可重複(Repeatable)
同樣的測試跑一百次,結果要一樣。
會破壞這件事的通常是三種東西:目前時間、亂數、外部服務。
直接讀取當下時間
// ❌ 這個測試明天就會壞
test('should be today', () => {
expect(formatDate(new Date())).toBe('2026-08-02')
})new Date() 每天回傳不同的值,今天過的測試明天就紅了。
把時間變成參數
// ✅ 把不確定的東西變成輸入
test('formats date correctly', () => {
const fixedDate = new Date('2026-08-02')
expect(formatDate(fixedDate)).toBe('2026-08-02')
})把時間當成參數傳進去,函式的行為就變成完全可預測的。
快(Fast)
單元測試應該在毫秒等級跑完。
因為只有夠快,你才會願意每次存檔都跑一次;跑一次要等三分鐘的測試,大家最後都會關掉。
要夠快,就代表測試裡不該有真的資料庫查詢、真的 API 呼叫、真的檔案讀寫。
獨立、可重複、快,看起來是三個要求,但你會發現它們常常是同一個問題的三個面向:只要測試碰到外面的世界,這三件事就會一起壞掉。
那如果我的函式就是需要這些東西呢?這就要講到下一段了。
遇到相依性怎麼辦?Test Double
先看看什麼叫「有相依性的程式碼」:
async function getUserGreeting(userId) {
const user = await fetchUser(userId) // 打 API
return `Hello, ${user.name}!`
}這個函式做的事情很單純,但它中間呼叫了 fetchUser,而 fetchUser 會真的發出網路請求。
如果直接測它,會有一堆問題:網路斷線測試就失敗、API 回傳的資料變了測試也失敗、而且每次跑都要等好幾百毫秒。
重點是,你現在想測的是「字串有沒有正確組出來」,不是「API 通不通」。
解法是用 test double:做一個假的替身,取代真正的相依對象。
Stub、Mock、Spy 差在哪?
Test double 有幾種常見類型:
- Stub:回傳你指定的固定資料,用來控制輸入
- Mock:除了回傳資料,還會記錄「有沒有被呼叫、被呼叫幾次、參數是什麼」
- Spy:包在真實實作外面,讓真的邏輯照跑,但同時記錄呼叫過程
實務上大家的用詞常常混著用,你不用太糾結名詞,重點是知道「我到底是要控制回傳值,還是要驗證呼叫行為」。
用 stub 改寫這個測試
把 fetchUser 換成一個寫死回傳值的假函式,測試就跟網路無關了:
test('greets user by name', async () => {
// Arrange:用假的 fetchUser 取代真的
const fakeFetchUser = async () => ({ id: 1, name: 'Amy' })
// Act
const result = await getUserGreeting(1, fakeFetchUser)
// Assert
expect(result).toBe('Hello, Amy!')
})現在測試不碰網路了,執行時間是毫秒級,而且結果完全可預測。
把外部相依換成可控的替身,你才能專心測自己寫的那段邏輯,而不是順便幫別人家的服務做健康檢查。
不過你可能發現了:上面的 getUserGreeting 多了第二個參數。
這不是巧合,這帶到單元測試最重要的一個副作用。
難測的程式碼,通常是設計有問題
相依寫死在函式裡
原本的版本裡,fetchUser 是寫死在函式內部的:
// ❌ 相依寫死在裡面,外面無法替換
import { fetchUser } from './api'
async function getUserGreeting(userId) {
const user = await fetchUser(userId)
return `Hello, ${user.name}!`
}問題在於 fetchUser 是從模組外面 import 進來的,函式內部直接抓來就用。
它沒有出現在參數列裡,所以你在外面呼叫 getUserGreeting(1) 的時候,除了 userId 之外沒有別的東西可以傳。
想指定「這次請你用另一個 fetchUser」,是做不到的,因為根本沒有地方可以指定。
不管是正式環境還是測試裡,跑的都是同一個會真的發出網路請求的 fetchUser。
那還是想測,怎麼辦?
框架有提供一個辦法,叫 module mock,在測試檔案最上面加一行:
jest.mock('./api')這行的意思是「在這個測試檔案裡,凡是從 ./api 拿到的東西,通通換成假的」。
它確實有效,但你應該感覺得到哪裡不太對勁:為了測一個只是把名字組成字串的函式,你得先去干預整個模組的載入過程。
而且這個寫法認的是檔案路徑。
哪天有人把 api.js 搬到別的資料夾,或是把它拆成兩個檔案,getUserGreeting 的行為一點都沒變,你的測試卻會跟著壞掉。
把相依從外面傳進來
// ✅ 把相依從參數傳進來
async function getUserGreeting(userId, fetchUser) {
const user = await fetchUser(userId)
return `Hello, ${user.name}!`
}現在誰要呼叫它,誰就決定要傳哪個 fetchUser 進去。
正式環境傳真的,測試時傳假的,函式本身完全不用改。
這個做法叫依賴注入(dependency injection),意思就是「不要自己去拿相依的東西,讓外面把它給你」。
這裡有個很值得記住的觀察:當你覺得某段程式碼很難寫測試的時候,通常不是測試技巧不夠,而是那段程式碼把太多責任綁在一起了。
「難測」是一個訊號,它在告訴你這段程式碼的耦合太高。
所以下次測試寫不動的時候,先別急著找更厲害的 mock 工具,回頭看看那段程式碼的設計。
測試涵蓋率是什麼?能相信嗎?
測試涵蓋率(code coverage)是一個數字,表示你的測試執行過原始碼的多少比例。
框架通常會分幾種算法,最常看的是行涵蓋率(line coverage)跟分支涵蓋率(branch coverage)。
行涵蓋率算的是「有多少行程式碼被跑過」,分支涵蓋率算的是「每個 if-else 的每條路徑有沒有都走過」。
這個數字有用,但它只能告訴你一件事:哪些程式碼完全沒被測到。
它不能告訴你測試寫得好不好。
看這個例子:
test('runs without error', () => {
calculateDiscount(100, 'gold')
calculateDiscount(100, 'silver')
calculateDiscount(100, 'normal')
})這個測試把 calculateDiscount 的每一行都跑過了,涵蓋率 100%。
但它沒有任何 assertion,就算有人把八折寫成八倍,它也照樣通過。
所以涵蓋率適合拿來找「完全沒人測過的角落」,不適合拿來當 KPI。
一個 60% 涵蓋率但每個測試都認真驗證的專案,比 95% 涵蓋率但都在空跑的專案可靠得多。
把涵蓋率當成照明燈就好,它負責照出你還沒測到的角落,但它不是成績單。
什麼時候該寫單元測試?
不是所有程式碼都值得寫單元測試,這件事要說清楚。
該測的跟不該測的
很值得寫的
- 計算邏輯,例如金額、折扣、稅率、日期換算
- 資料轉換,例如把 API 回傳格式轉成畫面要的格式
- 條件判斷多的地方,例如各種權限規則、狀態機
- 修過的 bug,把觸發 bug 的情境寫成測試,確保它不會再回來
這些的共同點是:輸入輸出明確、邏輯有分支、而且改壞了不容易一眼看出來。
不太適合用單元測試的
- 純粹的 UI 排版,比較適合用視覺測試或人眼看
- 只是把參數轉手傳給別人的薄薄一層函式,測了等於在測語言本身
- 「多個模組串起來會不會動」,這是前面提過的整合測試的守備範圍,不該用單元測試硬做
測行為,不要測實作
這裡也要講清楚取捨:測試不是免費的。
測試是程式碼,程式碼就要維護。
當你需求一改,對應的測試也要跟著改;如果測試寫得太貼著實作細節,重構時會變成沉重的負擔。
一個實用的判準是:測試應該綁在「行為」上,不要綁在「實作方式」上。
綁在實作上的測試
// ❌ 綁在實作細節上,把內部改成 map 就壞了
test('uses forEach to sum', () => {
const spy = jest.spyOn(Array.prototype, 'forEach')
sumPrices([10, 20])
expect(spy).toHaveBeenCalled()
})這個測試在檢查「你有沒有用 forEach」,但那是實作選擇,不是需求。
你把 forEach 改成 reduce,行為完全一樣,測試卻紅了。
綁在行為上的測試
// ✅ 綁在行為上,內部怎麼寫都無所謂
test('sums all prices', () => {
expect(sumPrices([10, 20])).toBe(30)
})這個測試只關心「加起來對不對」。
不管內部改用哪種寫法,只要結果正確,測試就會一直是綠的——這才是重構時真正保護你的東西。
這也是決定要不要寫、該怎麼寫的最後一把尺:挑改壞了不容易發現的地方測,而且永遠測行為,不要測實作。
最後:從一個函式開始
回到開頭那三個問題。
改一行程式碼會不會弄壞別的地方、交付前要不要把整個流程再點一遍、「單元」到底是多大一塊——這三件事現在應該都有答案了。
不過知道跟做得到之間還有一段距離,所以最後給你一個起點。
不用從整個專案開始,挑一個函式就好,最好是那種輸入輸出明確、但裡面有點分支的:算折扣、轉資料格式、判斷權限都很適合。
幫它寫三個測試:一個正常情況、一個邊界情況,還有一個你上次真的踩到的 bug。
寫的時候守住三件事就好。
第一,每個測試都要有 assertion,不然它只是在執行程式碼,不是在測試。
第二,如果寫到一半覺得綁手綁腳、非得先 mock 掉一堆東西才動得了,那通常不是你不會寫測試,是那個函式的耦合在跟你抗議。
第三,測它做出什麼,不要測它怎麼做,這樣之後重構的時候,測試才會站在你這邊。
寫完那三個測試,你就有了一份不會消失的清單。