iT邦幫忙

0

為什麼 VS Code Test Explorer 與 dotnet test 跑出不同結果?

  • 分享至 

  • xImage
  •  

同一個 commit、同一份 .runsettings,terminal 全綠、VS Code 卻 32 個失敗。追到最後是 testhost 模式的本質差異。

vscode-test-explorer-vs-terminal

最近在自己維護的 .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 我寫的時候本來就有「先檢查再加」:

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,描述跟我看到的症狀完全吻合。該 issue 被微軟標了 bug label、派員 assign,但自 2023 年底開到現在仍 open,也沒有給出 workaround。


4️⃣ 根因(二):Contains + Add 是 TOCTOU race

知道是並行造成的,回頭看那個 helper:

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 串行

知道根因之後修法很直接:

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 都全綠
.runsettingsMaxCpuCount=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.TryAddLazy<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,大部分同類問題會落在這兩個方向之一。

延伸閱讀


📘 HackMD 原文筆記:
👉 https://hackmd.io/@jeff377/vscode-vs-dotnet-test

📢 歡迎轉載,請註明出處
📬 歡迎追蹤我的技術筆記與實戰經驗分享
FacebookHackMDGitHubNuGet


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言