問題
在一個 Vue 元件庫裡,我反覆遇到「宣告了某個東西,但沒有任何地方真的用它」。表面上是好幾種不同的錯:死型別、死變數、錯誤的依賴、沒開的設定、算完卻沒傳下去的值、跟實作對不上的文件。後來把同一份規範套到多個後端專案時,最常見的缺陷形態也不是「做錯」,而是「做了但沒接上」:元件存在、有單元測試、測試全綠,但 production 流程一次都沒呼叫過它。
這種缺陷對行為測試完全無感——繞過的路徑跑出來的結果一樣是對的。宣稱的檔案大小上限從沒生效,上傳照樣成功;驗證訊息永遠不顯示,登入失敗照樣顯示「登入失敗」。沒有人會因此收到錯誤,所以也沒有人會去找。
為什麼四種工具都抓不到
- 型別檢查抓不到,因為型別本身就是那個宣告。它檢查「用法符不符合宣告」,不檢查「有沒有人用」。
- 測試抓不到,因為沒人會替一個沒接線的東西寫測試;就算寫了,也是單元測試自己 new 出來的組裝,跟 production 的接法無關。
- lint 只抓得到最表層的那種(未使用的區域變數),抓不到跨檔案的死型別、壞掉的 exports 路徑、沒 import 的依賴。
- CI 綠燈,因為以上三者都過。
共通點是:每一種檢查都只驗證「宣告內部一致」,沒有任何一種在問「這個宣告有下游嗎」。
實際遇過的形態
下面每一種都是真的踩到的,前端與後端的例子放在一起,因為病灶是同一個。
宣告層:型別說了一件實作沒做的事
型別說了謊,而且有人信了。 BreadcrumbProps.customSeparator 宣告成 boolean,元件卻自己另寫一份 inline 型別用 string。兩份型別並存時,型別檢查各自都過——因為元件根本沒引用型別檔那份。真正踩到的是 Storybook:Breadcrumb.stories.ts 引用的正是錯的那份,等於照著假的契約在寫。
型別描述了一個不存在的元件。 MarqueeProps 宣告 message: string,元件實際收的是 sectionData.list。沒有任何地方 import 它。任何人照著它寫必壞,而型別檢查永遠不會提醒。
型別開了一個沒接線的分支。 DropdownTriggerType = "button" | "input",template 只寫了 v-if="type === 'button'"。傳 type="input" 的人拿到一個沒有觸發器的元件,選單永遠打不開——型別檢查說這是合法的值。同類的還有 MarqueeSectionData.vertical:宣告了 boolean,元件完全沒有實作,傳 vertical: true 不會有任何效果。
enum 孤兒。 後端的通知 channel enum 定義了好幾種 channel,但 dispatch 端只寫死一條路,enum 沒有任何 consumer。
值的流向:算出來了,但沒有人讀、沒有傳下去
變數賦值之後沒人讀。 FileInput 的 selectFiles 每次選檔都更新,但從頭到尾沒有一行讀它;同一個元件的 emit 宣告了 update:modelValue,props 裡卻沒有 modelValue。Tabs 我自己寫的時候也留了一個 activeIndex——同一個坑,我剛修完別人的就自己踩了一次。
算出來了但沒傳下去。 這一類最隱蔽,因為程式碼看起來完全正常。Login.vue 的兩個輸入框傳了 :valid-message 卻沒傳 :show-validation,而 Input 的預設是 false——驗證訊息永遠不會顯示,hasSubmitted 這個 ref 被設定了卻沒接到任何元件上。使用者只看得到「登入失敗」,不知道哪個欄位錯。Form.vue 一樣:算出了「請勾選同意隱私權政策」,但 template 沒把它傳給 Checkbox。
寫了 setter 但沒有人寫入。 Pagination 的 currentPage 是 computed({ get, set }),template 從頭到尾只讀不寫,setter 從未被呼叫。
呼叫端:元件存在,production 從沒呼叫
factory/handler 存在但 production 零 caller。 一個 .NET 專案的檔案上傳有一個 handler factory,底下三個 handler、都有單元測試,但上傳流程從沒呼叫它——宣稱的 10MB/5MB 大小上限從未生效。
helper 存在但沒有端點。 webhook 驗簽 service 只是一個 static helper,沒有路由、沒有呼叫端。既有測試只驗「簽章算得對」,沒有一支測「請求打進來會被驗」。
執行體存在但沒有觸發管道。 排程清理 service 預設關閉,而且沒有任何環境設定過開關——這支 service 從來沒在任何環境跑過。另一個版本是 runNow 方法存在,但沒有任何端點打得到它。
寫在不可達的位置。 測試的清理呼叫寫在 return 之後,編譯器的 CS0162(偵測到無法執行到的程式碼)抓得到,但只是警告;開 warnings-as-errors 之前完全零症狀,那段清理從沒執行過。
平行複製品讓產品碼 0%。 測試自己複製了一份「InMemory 友善」的 specification 類別來測,產品裡真正的 specification 覆蓋率是 0%,連排序欄位都跟產品不同。
掛了權限碼但沒 seed,等於死端點。 路由掛了 [RequirePermission],權限表卻沒種那個碼,整組端點對所有人 403,包括 super-admin。
發布與設定:路徑、依賴、開關、CI
發布路徑指向不存在的檔案。 package.json 的 exports["./components/*"] 指向 src/components/*/index.vue,實際上一個都不存在(元件叫 Button/Button.vue)。這條路徑對 31 個元件全部是壞的,而且不裝起來跑一次不會知道。
依賴裝了但沒人 import。 @storybook/react 出現在一個 Vue 專案;@storybook/vue(Vue 2 專用)出現在 Vue 3 專案,而且放在 dependencies 不是 devDependencies。這幾個套件在原始碼裡 grep 不到任何 import。
設定裝好了但沒開。 addon-essentials 一直含著 docs 功能,31 個元件的 argTypes 都認真寫了說明——但沒有任何 story 帶 autodocs,所以那些說明沒有出口。加一行 tags: ["autodocs"] 之後,Storybook 從 0 頁文件變成 34 頁。
CI 沒跟上新增的產出。 加了 pnpm build 之後,CI 的 build 階段仍然只有 build-storybook——「給別人用的產物」從來沒在 CI 驗過。同一份 CI 裡 type-check 還帶著 allow_failure: true:pre-commit 會擋,CI 反而放行,守門只守一半。
文字層:註解和文件說了程式碼沒做的事
註解宣稱、程式碼沒有。 後端的 base service 註解逐字寫著 “transaction-wrapped”,內文一行交易程式碼都沒有。接真 DB 實測,中途失敗就留下半套資料。
文件描述了不存在的做法。 README 說 Icon 要「先 import Line from ... 再在 iconComponentList 加入 line: Line」,實際上那份表存的是延遲載入的函式,照著文件寫會壞。更早之前還有一整段 Nx 整合說明,而它提到的專案、nx.json、project.json 都不存在。
怎麼發現:對每一個宣告反過來問「誰在用?」
只有一種方法可靠:實際去用它。
- 死型別是在寫測試、要 import 型別時發現的
- 壞掉的
exports是在檢查「別的專案要怎麼引用」時發現的 - 沒開的 autodocs 是在盤點「Storybook 有哪些功能沒用」時發現的
type="input"沒接線是在寫「每個型別允許的值都試一次」的測試時發現的- 沒接上的 handler factory,是寫了走 HTTP 整條路的測試才證明接上了:上傳偽裝成 PNG 的檔案要回 422,真的 PNG 回 201
所以檢查的方式不是讀程式碼找 bug,而是對每一個宣告反過來問:誰在用?「有沒有這個類別」和「有沒有被用」是兩個問題,驗收一律查後者。新元件的 PR 裡如果只有元件和單元測試、沒有任何呼叫端的 diff,就是這個形態的高危訊號。
前端這邊,實務上可以直接做的三件事:
- 對每個
export的型別,grep 一次有沒有下游。沒有的就是候選。 - 對每個 union type 的每個值,各寫一條測試。沒接線的分支會自己現形。
- 對
package.json的每個依賴,grep 一次 import。對每個exports路徑,確認檔案存在。
守門手法:讓「沒接」從零症狀變成會紅
後端專案多,靠人記得去問「誰在用」不夠,要把它做成會擋的門。依形態選:
| 手法 | 做法 | 對付哪種形態 |
|---|---|---|
| 原始碼掃描 | 掃 Services/ 目錄禁止出現 ORM context 字樣、掃「呼叫端有沒有出現」 | 零 caller、繞過分層 |
| DI 容器反射 | 從真實的組裝根(WebApplicationFactory.Services)resolve,斷言 dispatcher 涵蓋整個 enum | enum 孤兒、註冊漏一條 |
| 路由表反射 | 走訪整張路由表,比對契約測試清單、授權決策、權限碼集合 | 死端點、helper 沒端點 |
| 啟動期走訪 | 缺授權決策就讓 app 起不來,並逐條指名缺哪一條 | 新端點漏接授權 |
| HTTP 整條路的主測試 | 從 production 入口打進去(上傳假 PNG 要 422) | 所有「單元測試全綠但沒接」的形態 |
行為測試無感的,掃原始碼和反射有感。DI 容器反射特別值得一提:單元測試自己 new 的組裝可以完美,production 註冊表少一條照樣會被抓到,因為它讀的是真的那一份。
最後一條不在表裡,因為它適用於上面每一道:每道門立起來都要先證明它會紅。 塞一條違規進去,看門炸不炸。門自己也可能是 no-op——讀錯 DataSource 的門對什麼都綠。
檢查方法本身也要驗證
這是後來補上的,因為我在做「反過來問:誰在用?」時踩到了。
我寫了一段 grep 掃「每個匯出的型別有沒有下游」,跑出 42 個死型別。
數字大到不合理,看一眼清單就發現不對:AvatarSize、BadgeSize、SelectOption
這些明明就在同檔的 Props 裡用著。
原因是那段 grep 排除了定義檔本身(想避免「定義處也算一次」),
結果同檔內 AvatarProps.size: AvatarSize 這種引用一起被排除了。
改成「在 src 內出現次數 ≤1」之後,只剩 1 個是真的。
如果我沒有先看清單合不合理就直接動手,會照著一份錯了 41 個的清單去刪型別。 掃描腳本跑出來的東西是待驗清單,不是結論——尤其是當它「抓到很多」的時候, 先懷疑的應該是判準而不是程式碼。
同一輪還有另一個變體:我寫的三條測試「元件有沒有發出自訂事件」用的是
「排除原生事件名稱」的判斷方式,而新加的自訂事件剛好也叫 submit,
被一起排除掉——測試綠著,但什麼都沒驗到。綠色不等於守得住。
再往前一步:沒跑到的分支,與不留下來的證據
上面講的是「驗了,但判準寫錯」。下面兩個更前面一步——根本沒驗到,或驗了但證據消失。
一、用不會觸發的樣本去驗證會觸發的邏輯。
一支長時間量測腳本沒有中途進度,使用者兩次問「是不是卡住了」。 我加了三件事:每頁印進度、每 10 頁落盤一次部分結果、整體逾時保護。 然後跑 6 頁驗證,宣布「三項都實測過」。
6 < 10。那段落盤的程式碼從來沒有被執行過。
騙過我的是畫面上那行 ✓ 檔案存在——那是我自己 echo 出來的字串,
不是那段程式碼的產物。我看到綠勾就結案了,沒問「這個勾是誰印的」。
抓到的方式是另一位檢查者去找那個檔案,找不到。
二、驗收產物被當成暫存檔清掉。
修正後我把落盤門檻調成每 2 頁,重跑 6 頁,真的產生了部分結果檔、確認 6 筆內容正確——然後在「清掉測試殘留」
那一步,把它跟其他暫存檔一起 rm 了。
另一位檢查者再來問:那個檔在哪?答案是:被我刪了。 驗收當下我確實看到了,但那一刻結束後,沒有任何人(包括未來的我)能覆核。
這兩種特別難自己發現。前面那些變體,至少「跑出來的東西是錯的」——數字不合理、清單有假陽性, 盯著看還有機會起疑。這兩種沒有錯誤的產出可以看:該跑的分支沒跑,所以什麼都沒產生,而我把別處印的訊息當成它的產物;或者產生了、也對,但隨即消失,之後任何覆核都只能得到「查無此檔」。
「我看到了」不等於「它存在過」,更不等於「它還在」。 這兩件事都只有從外部去找那個產物才驗得出來——同一個人自己跑再多次也不會發現,因為驗的是「有沒有報錯」,不是「那一行有沒有真的跑過、跑出來的東西還在不在」。
所以多兩條做法:
- 驗證觸發條件時,樣本必須跨過門檻。 門檻是 N 就要跑超過 N 次;
跑不動就把門檻參數化(例如用
PARTIAL_EVERY環境變數控制), 調小門檻去驗,而不是改 code 再改回來。 - 驗收產物要活得比驗收那一刻久。 放在會被清掉的暫存目錄等於沒有留。 要放進一個不會被例行清理掃到的固定位置,並在那個目錄的 README 第一行寫明「這裡的東西不要刪」。
還有一條判準可以直接用:證明某段程式碼跑過的證據,不能是另一段程式碼印的字。
要嘛是那段程式碼自己產生的檔案,要嘛是它自己寫的日誌行——
echo "✓ 完成" 永遠只證明 echo 跑過了。
沒解決的部分
前端這邊,我沒有找到能自動化這件事的工具。knip 或 depcheck 這類工具能抓未使用的依賴與匯出,但我還沒在這個 repo 實測過,所以不寫成建議——待驗。
相關
- 守門有沒有在擋,只能證明、不能推定:「每道門立起來都要先證明它會紅」的完整版,六個「設定寫了但沒生效」的實例。
- 驗收假綠形態目錄:第 8 種「門本身是 no-op」與第 7 種「掃描空集合」,就是上面守門手法要防的那兩種門自身的問題。
- 覆蓋率守門的假綠:「平行複製品讓產品碼 0%」的近親——測試全綠、覆蓋率門檻在,但量到的不是產品碼。
- 工具報的錯不一定是它說的那個問題:同樣是「訊號看起來對、實際指向別處」,那篇是錯誤訊息,這篇是綠燈。