問題
把 composable 的邏輯寫在 $effect 裡(註冊事件、訂閱、清理),單元測試進不去。
第一版長這樣:
export function useOnline() {
let isOnline = $state(navigator.onLine);
$effect(() => {
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => { /* 清理 */ };
});
return { get isOnline() { return isOnline; }, ... };
}我看到的現象
測試裡用 $effect.root() 包住、加上 flushSync(),事件還是沒註冊:spy window.addEventListener 的呼叫次數是 0,$effect 的內容一次都沒執行。
另外一個觀察:flushSync() 放在 $effect.root 的建構回呼裡面呼叫時無效,要等 root 回傳之後才呼叫。但即使順序改了,我那時的寫法仍然測不到。
官方怎麼說:$effect.root + flushSync 本來就是測 effect 的做法
我原本的筆記把根因寫成「$effect 只在元件的 effect context 裡執行,單元測試沒有元件,所以測不到」。回頭對照官方文件,這個結論站不住:
- Svelte 官方測試文件明寫:被測的程式碼用到 effect 時,把測試包在
$effect.root裡;effect 平常在 microtask 之後才跑,用flushSync讓待執行的 effect 同步跑完。範例裡flushSync()就是在$effect.root的回呼裡面呼叫的(Svelte docs: Testing) $effect文件也說,$effect.root允許在元件初始化階段之外建立 effect;$effect可以在任何地方用,只要呼叫時有一個父 effect 正在執行([Svelte docs: effect))
所以「官方做法不行」不是正確的結論,而是我那次的測試環境有別的問題。
可能的原因(推測,未重現)
以下兩個解釋都跟我看到的現象吻合,但我沒有回頭重現,所以只能列為推測:
- 測試檔名沒有讓 runes 被編譯。官方文件寫,測試檔裡要用 runes,檔名必須含
.svelte(例如multiplier.svelte.test.js)(Svelte docs: Testing)。我的測試檔叫useOnline.spec.ts,裡面的$effect.root不會被當成 rune 編譯。不過照官方的 runtime error 說明,沒被編譯的 rune 通常會直接拋rune_outside_svelte錯誤(「只能在.svelte與.svelte.js/ts檔案裡使用」,Svelte docs: Runtime errors),而不是安靜地什麼都不做——這點跟「呼叫 0 次、沒有報錯」不完全對得上。 - vitest 解析到 Svelte 的 server 版本。同一個 repo 後來發現元件測試一
render就拋mount(...) is not available on the server(見下面附帶段),代表當時 vitest 拿到的是 server 版本。而官方文件寫 effect 只在瀏覽器執行、server-side rendering 時不會跑([Svelte docs: effect))。如果 composable 是在這個狀態下被測,$effect安靜地不執行就說得通。我不確定這個設定問題是發生在那次實驗之前還是之後。
要確認的話,做法是:把測試檔改名成 useOnline.svelte.spec.ts、加上下面的 resolve.conditions 設定,照官方範例重跑一次。(未重現)
解法:核心邏輯做成可手動啟停的函式
不管根因是哪一個,我最後的重構本身是站得住的:把核心邏輯做成可以手動啟停的函式,$effect 只負責在元件裡接線和清理。
/** 核心:狀態與事件註冊分開,start() 回傳停止函式 */
export function createOnlineTracker() {
let isOnline = $state(navigator.onLine);
// ...其餘狀態
return {
get isOnline() { return isOnline; },
// ...
start() {
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
};
}
/** 給元件用:掛載時開始監聽,卸載時自動停掉 */
export function useOnline() {
const tracker = createOnlineTracker();
$effect(() => tracker.start());
return tracker;
}測試直接測 createOnlineTracker,不需要任何 effect:
const runWithOnline = (fn) => {
const tracker = createOnlineTracker();
const stop = tracker.start();
try { fn(tracker); } finally { stop(); }
};改成這樣之後,那組測試 9 支全過。
$state 在我的情況下不用 effect 也能直接讀:測試裡讀 getter 就拿得到當前值。所以只有「需要持續執行的副作用」要拆,純狀態的 composable(例如 useAsyncState、useForm)可以直接測。
同樣的拆法我也用在 useDebouncedCallback:核心是 createDebouncedCallback(回傳的函式帶 cancel),$effect 只做「元件卸載時 cancel」。
為什麼這樣拆是對的
start() 回傳停止函式這個形狀,剛好就是 $effect 要的回傳值,所以包裝層只有一行:$effect(() => tracker.start())。這不是為了測試而扭曲設計,兩邊需要的東西本來就一樣。
另一個好處是清理一定存在:start() 一定要回傳停止函式,忘了寫就是型別錯誤。原本的寫法裡,忘記回傳清理函式不會有任何提示。
而且這樣測試不再依賴「effect 有沒有被正確排程」這件事——上面那種環境設定出問題時,核心邏輯的測試照樣有效。
附帶:Svelte 元件測試跑不起來的設定
同一個 repo 裝了 @testing-library/svelte,卻一個元件測試都沒有。原因是 render 一呼叫就拋:
Svelte error: lifecycle_function_unavailable
`mount(...)` is not available on the servervitest 解析到 Svelte 的 server 版本。要在 vite 設定加:
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,這跟官方測試文件的設定一致:讓 Vitest 即使跑在 Node 裡也使用 package.json 的 browser 進入點(Svelte docs: Testing)。
另外 eslint 不認得 runes($state/$effect 在 .svelte.ts 裡被判成 no-undef),要替 **/*.svelte.ts 補 globals 宣告。
怎麼預防再次發生
- 副作用要能在沒有框架的情況下啟動與停止。 這條不限 Svelte——React 的
useEffect、Vue 的onMounted都一樣。框架的生命週期鉤子負責「什麼時候做」,不該負責「做什麼」。 - 要直接測 effect,先照官方設定來:測試檔名帶
.svelte(*.svelte.test.ts/*.svelte.spec.ts)、包在$effect.root裡、用flushSync推進。測出「effect 完全沒跑」時,先懷疑設定,不要先下「官方做法不行」的結論——我就是這樣寫錯了一次根因。 - 測試工具裝了卻沒有任何測試,先查它是不是根本跑不起來,不要假設只是「還沒寫」。
@testing-library/svelte就是這樣:在package.json裡,但設定缺一行,整個元件測試層等於不存在。
相關
- 測試之間的狀態外洩(同一批測試工作裡踩到的另一類坑)