--- title: 為什麼 VS Code Test Explorer 與 dotnet test 跑出不同結果? tags: [vscode, dotnet, xunit, testing, csharp, vstest] --- # 為什麼 VS Code Test Explorer 與 dotnet test 跑出不同結果? > 同一個 commit、同一份 .runsettings,terminal 全綠、VS Code 卻 32 個失敗。追到最後是 testhost 模式的本質差異。 ![vscode-test-explorer-vs-terminal](https://hackmd.io/_uploads/ByBvgKgC-x.png) 最近在自己維護的 .NET 測試專案遇到一個怪問題:terminal 跑 `dotnet test` 全部 2556 個測試通過,但同一份程式碼在 VS Code Test Explorer 上會出現 32 個紅燈。沒改程式碼、沒改設定,差別只在「換了地方按執行」。 這篇是整個追查過程的紀錄,給遇到同類問題的 .NET 工程師參考。 --- # 1️⃣ 症狀:兩邊跑出不一樣的結果 我手上的測試專案設定: - .NET 10 + xUnit `2.9.3` + `xunit.runner.visualstudio 3.1.5`(傳統 VSTest 模式) - 9 個 test assembly,每個都有自己的 `[CollectionDefinition("Initialize")]` 與對應的 `GlobalFixture` / `DbGlobalFixture`(fixture 是 xUnit 提供的「測試共用初始化資源」,由框架管理生命週期、不是每個測試各自 new 一份) - Fixture 內會做幾件 process-wide 的初始化:設定全域路徑、註冊資料庫 provider、把資料庫設定條目加進一個 process-wide 的 collection **Terminal 跑 `./test.sh`**(包裝過的 `dotnet test`): ``` Total Tests: 2557 Passed: 2556 Failed: 0 Skipped: 1 Result: ✅ SUCCEEDED ``` **VS Code Test Explorer 跑同樣的測試**: ``` Total Tests: 2557 Passed: 2524 Failed: 32 Skipped: 1 Result: ❌ FAILED ``` 差了 32 個。第一個直覺是「VS Code 沒帶到 `.runsettings`」,但檢查 `.vscode/settings.json` 已經設了 `dotnet.unitTests.runSettingsPath`,環境變數確實有注入;也試過 Debug build 在 terminal 跑,仍然全綠。差異不在這。 --- # 2️⃣ 線索:所有失敗都指向同一個 fixture init 從 VS Code Test Explorer 點開任一個紅色測試,錯誤訊息長這樣: ``` System.ArgumentException : An item with the same key has already been added. Key: common_sqlserver at System.Collections.Generic.Dictionary`2.Add(...) at Bee.Tests.Shared.GlobalFixture.AddDatabaseItemIfMissing(...) line 189 at Bee.Tests.Shared.GlobalFixture.RegisterSqlServer() line 83 at Bee.Tests.Shared.GlobalFixture..ctor() line 56 at Bee.Tests.Shared.DbGlobalFixture..ctor() line 17 ``` Stack trace 顯示:在 `GlobalFixture` 建構時,呼叫 `AddDatabaseItemIfMissing` 試著把 key `common_sqlserver` 加進 collection,但這個 key 早就存在了。 問題是這個 helper 我寫的時候本來就有「先檢查再加」: ```csharp private static void AddDatabaseItemIfMissing(string id, ...) { if (settings.Items.Contains(id)) return; // 先檢查 settings.Items.Add(new Item { Id = id, ... }); // 再加 } ``` 照常理它應該重複呼叫也沒事才對。實際卻炸了,而且只在 VS Code 炸、terminal 不炸。 --- # 3️⃣ 根因(一):terminal 與 VS Code 的 testhost 模式不同 `dotnet test` 在 terminal 跑時,VSTest 預設行為是「每個 test DLL 啟動一個獨立的 testhost process」。9 個 test assembly = 9 個 process,每個 process 各自跑自己的測試,所有 process-wide static state(static field、singleton、各種 registry)天然完全隔離,沒有跨 assembly 共享的可能。 VS Code Test Explorer(C# Dev Kit)走的是 single-host 模式——所有 test assembly 載進同一個 process 跑。9 個 assembly 共享同一塊 static state,9 個 collection 對應的 fixture instance 會並行創建,全部都在 process-wide collection 上 read/write。 terminal 的 process 隔離把問題藏了起來;VS Code 的 single-host 把它暴露出來。這對應到 GitHub issue [microsoft/vscode-dotnettools#825 — VSCode test runner seems to ignore test collections / test fixtures](https://github.com/microsoft/vscode-dotnettools/issues/825),描述跟我看到的症狀完全吻合。該 issue 被微軟標了 `bug` label、派員 assign,但自 2023 年底開到現在仍 open,也沒有給出 workaround。 --- # 4️⃣ 根因(二):Contains + Add 是 TOCTOU race 知道是並行造成的,回頭看那個 helper: ```csharp if (settings.Items.Contains(id)) return; // 第 1 步:檢查 settings.Items.Add(new Item { Id = id, ... }); // 第 2 步:加入 ``` 這是經典的 **TOCTOU**(Time-Of-Check / Time-Of-Use)race——「檢查狀態」與「依賴該狀態做動作」之間留了一個讓別的執行緒插隊的空隙。在單執行緒下完全沒問題,但兩個 fixture 並行進來時: ``` Thread F1: Contains("common_sqlserver") → false Thread F2: Contains("common_sqlserver") → false ← 兩邊都看到「不存在」 Thread F1: Add(...) → OK Thread F2: Add(...) → 💥 ArgumentException ``` 第一個 fixture 通過 check,第二個也在 F1 還沒 Add 之前就通過了 check,於是兩邊都去 Add,後到的撞牆。 這個 helper 在 single-thread 下看起來重複呼叫沒事,並行下完全失效。而 terminal 多 process 模式下根本沒有並行的 fixture,這個 bug 永遠不會浮上來。 --- # 5️⃣ 修法:把 init 用 lock + once flag 串行 知道根因之後修法很直接: ```csharp public class GlobalFixture : IDisposable { private static readonly object _initLock = new(); private static bool _initialized; public GlobalFixture() { lock (_initLock) { if (_initialized) return; // 後續 fixture instance 直接 short-circuit InitializeOnce(); _initialized = true; } } private static void InitializeOnce() { // ... 原本的 init 全部搬進來 } } ``` `DbGlobalFixture`(繼承 `GlobalFixture`)也加同樣的 lock + once flag,避免並行重複跑 schema build 與 seed insert(後者也會有 PK 衝突 race)。 修完後在 VS Code 重跑: ``` Total Tests: 2557 Passed: 2556 Failed: 0 Skipped: 1 Result: ✅ SUCCEEDED ``` 跟 terminal 一致了。 --- # 6️⃣ 走過的彎路:嘗試找 VS Code 設定可解 我不甘心只在測試端打補丁,畢竟根因是 IDE 端的 single-host 行為。所以花了一段時間找:「能不能透過 VS Code 設定強制走 multi-process?」 我翻了 C# Dev Kit 與 C# 兩個擴充提供的所有測試相關設定,沒找到能控制 in-process / multi-process 切換或 parallelization 的選項;對應的 GitHub issue 也沒給 workaround。當下結論是只能改測試端來適應 single-host 模式,不能改 IDE 端。如果有人知道有設定能直接解,歡迎告知。 --- # 7️⃣ 解法選項對比 幾個方向我都想過,最後選了第一個: | 解法 | 工程量 | 評估 | |------|--------|------| | **Fixture lock + once flag**(我選的) | 小 | 解 race,無侵入既有測試代碼,terminal 與 VS Code 都全綠 | | `.runsettings` 加 `MaxCpuCount=1` + `DisableParallelization=true` | 小 | 限制並行可能有效,但會讓 terminal 也變慢;不保證對 single-host 內部並行有效 | | 重構:把 process-wide static state 改成可注入 | 巨大 | 最徹底,但會動到 src/ 內各種 static singleton 與 registry,牽連既有架構 | 如果你的測試專案 static state 不多,方案二(runsettings 串行)可能最省事。我選方案一是因為我的測試 fixture 很「重」(要設全域、註冊 DB、預埋 schema 與 seed),無論如何都需要 init 能重複安全執行(thread-safe),這個改動本身就值得做。 --- # 8️⃣ 啟示:跨 assembly 的 process-wide static state 是隱性債 寫完這個修法之後留下幾個觀察。 最直接的是 **check-then-act 模式(先檢查再動作)只對 single-thread 安全**。並行下不是包 lock,就是改用一步式的 thread-safe 操作(例如 `ConcurrentDictionary.TryAdd`、`Lazy<T>`)。 另一層更隱性:xUnit 的 `[Collection]` 串行保證只覆蓋「同一 assembly 內」。跨 assembly 的並行控制不歸 xUnit 管,只能靠 testhost 的 process 隔離(terminal 模式)或自己加 lock(single-host 模式)。換句話說,任何依賴 process-wide static state 的測試 fixture,在 `dotnet test` 多 process 模式下看起來都正常,換到 single-host 環境(VS Code、IDE Test Explorer、CI 上某些 runner)就可能炸——多 process 模式只是把問題藏住了,不代表沒問題。 --- # ✅ 結語 這篇是特定情境的紀錄:.NET、xUnit 2、測試 fixture 重度依賴 process-wide static state、跨多個 test assembly。如果你的測試結構不一樣(fixture 輕、static state 少、單一 assembly),可能永遠遇不到。 如果遇到了,建議的查證順序是:先看 testhost 模式(per-process 還是 single-host)、再看 fixture 是不是 thread-safe,大部分同類問題會落在這兩個方向之一。 ## 延伸閱讀 - [microsoft/vscode-dotnettools#825 — VSCode test runner seems to ignore test collections / test fixtures](https://github.com/microsoft/vscode-dotnettools/issues/825) — confirmed bug,無官方 workaround - [Testing with C# Dev Kit](https://code.visualstudio.com/docs/csharp/testing) — VS Code C# Dev Kit 官方文件 --- 📘 **HackMD 原文筆記:** 👉 https://hackmd.io/@jeff377/vscode-vs-dotnet-test **📢 歡迎轉載,請註明出處** **📬 歡迎追蹤我的技術筆記與實戰經驗分享** [Facebook](https://www.facebook.com/profile.php?id=61574839666569) | [HackMD](https://hackmd.io/@jeff377) | [GitHub](https://github.com/jeff377) | [NuGet](https://www.nuget.org/profiles/jeff377)