---
# System prepended metadata

title: 為什麼 VS Code Test Explorer 與 dotnet test 跑出不同結果？
tags: [xunit, csharp, vscode, testing, vstest, dotnet]

---

---
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)
