iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Software Development

30天快速上手製作WPF選股工具 — 從C#基礎到LiteDB與Web API整合系列 第 29

Day 29 — 單元測試(Unit Test):讓程式不再靠祈禱維持穩定

  • 分享至 

  • xImage
  •  

寫程式時最常聽到的一句話是:

「我只是改了一行,結果別的功能壞了。」

這就是沒有測試的代價。
今天,我們要談的是能讓你在重構時依然安心、
改程式不必靠運氣的武器——Unit Test(單元測試)


一、為什麼需要 Unit Test?

單元測試是開發者的「安全網」。
它讓你能確定每個邏輯單元在不同情境下都能輸出正確結果。

好處包括:

  • 提早發現邏輯錯誤
  • 改程式不怕壞舊功能
  • 減少人工測試時間
  • 增加團隊信心

沒測試的程式,就像沒保險的車。能開,但出事只能自己承擔。


二、什麼是「單元」?

「單元」代表最小的可測邏輯範圍(例如一個方法或類別)。

類型 範圍 目的
Unit Test 測試邏輯 驗證演算法、條件邏輯
Integration Test 測試整合 驗證 API 或資料庫互動
UI / E2E Test 測試流程 模擬使用者操作行為

三、3A 原則:好測試的結構

寫測試最簡單也最通用的模式是:

Arrange → Act → Assert

這是幾乎所有測試框架(NUnit、xUnit、MSTest)都遵循的基本原則。

[Test]
public void CalculateK_ShouldReturn50_WhenHighEqualsLow()
{
    // Arrange:準備測試環境
    var service = new IndicatorService();

    // Act:執行要測的邏輯
    var result = service.CalculateK(100, 100, 100);

    // Assert:驗證結果
    result.Should().Be(50);
}

重點:

  • Arrange → 建立資料與環境
  • Act → 執行被測方法
  • Assert → 驗證結果

這樣的結構讓測試清楚、可讀性高。


四、建立測試專案

1️⃣ 建立專案結構

MyStockApp/
 ├─ Core/
 ├─ Services/
 ├─ Tests/
     └─ MyStockApp.Tests/

2️⃣ 建立專案

dotnet new nunit -n MyStockApp.Tests
dotnet add MyStockApp.Tests reference ../MyStockApp.Core

3️⃣ 安裝常用套件

dotnet add package NUnit
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Moq.AutoMock
  • NUnit:測試框架
  • Moq:建立假物件(Mock)
  • FluentAssertions:讓驗證語句自然易讀
  • Moq.AutoMock:自動建立相依(DI)並注入 Mock 物件

五、範例:測試 KD 計算邏輯

假設在 IndicatorService 中有一個方法:

public decimal CalculateK(decimal close, decimal low, decimal high)
{
    if (high == low)
        return 50; // 防止除以零
    return (close - low) / (high - low) * 100;
}

測試範例如下:

using NUnit.Framework;
using FluentAssertions;

[TestFixture]
public class IndicatorServiceTests
{
    private IndicatorService _service;

    [SetUp]
    public void Setup()
    {
        _service = new IndicatorService();
    }

    [Test]
    public void CalculateK_ShouldReturn50_WhenHighEqualsLow()
    {
        // Arrange
        decimal close = 100, low = 100, high = 100;

        // Act
        var result = _service.CalculateK(close, low, high);

        // Assert
        result.Should().Be(50);
    }

    [Test]
    public void CalculateK_ShouldReturnCorrectValue_WhenNormalRange()
    {
        // Arrange
        decimal close = 110, low = 100, high = 120;

        // Act
        var result = _service.CalculateK(close, low, high);

        // Assert
        result.Should().BeApproximately(50, 0.001m);
    }
}

六、Moq:模擬相依 (Mock Dependencies)

如果被測類別依賴外部資源(例如資料庫或 API),
不應在單元測試中真的去呼叫它,而是要「模擬行為」。

[Test]
public void GetStock_ShouldReturnProfile_WhenExists()
{
    // Arrange
    var mockRepo = new Mock<IDatabaseRepository>();
    mockRepo.Setup(r => r.LoadStocks())
            .Returns(new[] { new StockProfile { Code = "2330", Name = "台積電" } });

    var service = new StockService(mockRepo.Object);

    // Act
    var result = service.GetStock("2330");

    // Assert
    result.Name.Should().Be("台積電");
}

這樣測試就能專注在邏輯正確與否,而不是外部連線。


七、AutoMock:快速建立 SUT 的神器

在實務中,你的系統往往透過 DI(Dependency Injection) 建構,
像是 ViewModel 或 Service 同時需要多個相依服務。
這時要手動建立所有 Mock 會非常麻煩。

Moq.AutoMock 套件可以自動幫你建立測試環境(SUT, System Under Test)。

using Moq.AutoMock;
using FluentAssertions;

[Test]
public void ViewModel_ShouldLoadData_WhenRepoHasStocks()
{
    // 建立 AutoMocker
    var mocker = new AutoMocker();

    // 模擬資料
    var fakeStocks = new[]
    {
        new StockProfile { Code = "2330", Name = "台積電" },
        new StockProfile { Code = "2317", Name = "鴻海" }
    };

    // 指定某個介面要用什麼 Mock
    mocker.GetMock<IDatabaseRepository>().Setup(r => r.LoadStocks()).Returns(fakeStocks));

    // 建立被測物件 (會自動注入所有相依)
    var vm = mocker.CreateInstance<StockFilterViewModel>();

    // Act
    vm.LoadFromLocalCommand.Execute(null);

    // Assert
    vm.Stocks.Should().HaveCount(2);
}

AutoMock 的優點:

  • 自動幫你組裝依賴的 Mock(不需一個個手動 new)
  • 可用 .Use<T>() 指定部分 Mock 實作
  • 可與 Moq 搭配使用,依然能設定 Setup()
  • 適合用在大型 WPF 或 WebAPI 專案的 ViewModel / Service 測試

八、平行測試與共用狀態陷阱 ⚠️

在 NUnit、xUnit 等框架中,
為了加快執行速度,測試常會「平行執行」。
這代表多個 [Test] 方法可能同時運行在不同執行緒上。

若共用狀態(特別是 static 或 Singleton),會出問題:

public class SharedTests
{
    private static IndicatorService _service = new IndicatorService();

    [Test]
    public void Test1()
    {
        _service.CurrentStock = "2330";
        var result = _service.CalculateK(...);
    }

    [Test]
    public void Test2()
    {
        _service.CurrentStock = "2317";
        var result = _service.CalculateK(...);
    }
}

Test1Test2 可能同時執行,
導致 CurrentStock 被另一個測試改掉,
結果是「偶爾會錯、重跑又過」——這就是 Flaky Test

解決方式:

  1. 每個測試都建立獨立物件:

    [SetUp]
    public void Setup() => _service = new IndicatorService();
    
  2. 不要共用 static 欄位或物件。

  3. 若真的要共用,確保是 thread-safe(例如使用 ConcurrentDictionary 或 lock)。

  4. 若某組測試不能平行執行,可加上:

    [NonParallelizable]
    public class DatabaseTests { ... }
    

九、測試獨立性是穩定的關鍵

不穩定的測試比失敗更可怕。
因為它「有時過、有時不過」,讓你無法判斷到底是程式錯,還是測試錯。

維持穩定測試的原則:

  • 每個測試都是獨立的
  • 不依賴執行順序
  • 沒有共用可變物件
  • 外部行為都用 Mock

十、小結:讓測試成為開發的基本工

主題 精華
3A 原則 Arrange → Act → Assert,結構清楚
Moq / AutoMock 快速模擬相依、建立 SUT
AutoMocker.Use() 自訂特定 Mock 實例
避免共用狀態 平行測試下最常見問題
FluentAssertions 讓驗證語法自然可讀

單元測試不是「多做一步」,
它是讓你能放心改程式、不怕踩雷的「開發安全網」。



上一篇
Day 28 — 開發者閒聊:你以為你會用 Git,其實只是會打 commit
下一篇
Day 30 — 結語:從零開始打造你的 WPF 選股工具
系列文
30天快速上手製作WPF選股工具 — 從C#基礎到LiteDB與Web API整合30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言