寫程式時最常聽到的一句話是:
「我只是改了一行,結果別的功能壞了。」
這就是沒有測試的代價。
今天,我們要談的是能讓你在重構時依然安心、
改程式不必靠運氣的武器——Unit Test(單元測試)。
單元測試是開發者的「安全網」。
它讓你能確定每個邏輯單元在不同情境下都能輸出正確結果。
好處包括:
沒測試的程式,就像沒保險的車。能開,但出事只能自己承擔。
「單元」代表最小的可測邏輯範圍(例如一個方法或類別)。
類型 | 範圍 | 目的 |
---|---|---|
Unit Test | 測試邏輯 | 驗證演算法、條件邏輯 |
Integration Test | 測試整合 | 驗證 API 或資料庫互動 |
UI / E2E Test | 測試流程 | 模擬使用者操作行為 |
寫測試最簡單也最通用的模式是:
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);
}
重點:
這樣的結構讓測試清楚、可讀性高。
MyStockApp/
├─ Core/
├─ Services/
├─ Tests/
└─ MyStockApp.Tests/
dotnet new nunit -n MyStockApp.Tests
dotnet add MyStockApp.Tests reference ../MyStockApp.Core
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 物件
假設在 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);
}
}
如果被測類別依賴外部資源(例如資料庫或 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("台積電");
}
這樣測試就能專注在邏輯正確與否,而不是外部連線。
在實務中,你的系統往往透過 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 的優點:
.Use<T>()
指定部分 Mock 實作Setup()
在 NUnit、xUnit 等框架中,
為了加快執行速度,測試常會「平行執行」。
這代表多個 [Test]
方法可能同時運行在不同執行緒上。
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(...);
}
}
Test1
與 Test2
可能同時執行,
導致 CurrentStock
被另一個測試改掉,
結果是「偶爾會錯、重跑又過」——這就是 Flaky Test。
每個測試都建立獨立物件:
[SetUp]
public void Setup() => _service = new IndicatorService();
不要共用 static
欄位或物件。
若真的要共用,確保是 thread-safe(例如使用 ConcurrentDictionary
或 lock)。
若某組測試不能平行執行,可加上:
[NonParallelizable]
public class DatabaseTests { ... }
不穩定的測試比失敗更可怕。
因為它「有時過、有時不過」,讓你無法判斷到底是程式錯,還是測試錯。
維持穩定測試的原則:
主題 | 精華 |
---|---|
3A 原則 | Arrange → Act → Assert,結構清楚 |
Moq / AutoMock | 快速模擬相依、建立 SUT |
AutoMocker.Use() | 自訂特定 Mock 實例 |
避免共用狀態 | 平行測試下最常見問題 |
FluentAssertions | 讓驗證語法自然可讀 |
單元測試不是「多做一步」,
它是讓你能放心改程式、不怕踩雷的「開發安全網」。