上一篇我們做了一支能動的商品管理程式,然後指出它的四個問題:不能測試、換 UI 要重寫、功能一多就爆炸、多人協作必衝突。根源是同一個——MainForm 同時扮演了畫面、資料、業務規則三種角色。
這篇把它拆開。功能完全不變,跑起來一模一樣,但結束時你會得到一件上一篇做不到的事:在完全不開啟視窗的情況下,用程式驗證「價格輸入負數會被擋下來」。
想邊看邊對照的話,完整原始碼可以先抓下來:👉 下載 MvpDemo 完整原始碼(.zip)。我已經實際解壓縮到另一個資料夾重新建置並跑過測試,確認下載就能用;文章最後也會再放一次連結與執行說明。
先看完成後的樣子
專案結構長這樣,三個專案:
MvpDemo/
├── MvpDemo.slnx 方案檔
├── MvpDemo.Core/ Model + Presenter(不含任何 UI)
│ ├── Models/
│ │ ├── Product.cs 一筆商品
│ │ ├── AddResult.cs 新增結果(成功/失敗+原因)
│ │ └── ProductService.cs 驗證規則、儲存、統計
│ ├── Views/
│ │ └── IProductView.cs 畫面必須提供的能力
│ └── Presenters/
│ └── ProductPresenter.cs 把流程串起來
├── MvpDemo.App/ WinForms 視窗程式
│ ├── MainForm.cs 實作 IProductView
│ └── Program.cs 組裝三個角色
└── MvpDemo.Tests/ xUnit 測試
├── FakeProductView.cs 假的畫面
└── ProductPresenterTests.cs 六個測試上一篇說會拆成四個檔案,這裡多做了一步:把 Model 和 Presenter 放進一個獨立的類別庫 MvpDemo.Core。
這一步值得解釋,因為它是整篇最有力的部分。看一下三個專案的 target framework:
<!-- MvpDemo.Core.csproj -->
<TargetFramework>net10.0</TargetFramework>
<!-- MvpDemo.App.csproj -->
<TargetFramework>net10.0-windows</TargetFramework>
<!-- MvpDemo.Tests.csproj -->
<TargetFramework>net10.0</TargetFramework>MvpDemo.Core 是 net10.0,不是 net10.0-windows——它在編譯層級就無法引用 WinForms。你就算想在 ProductService 裡偷寫一行 MessageBox.Show,編譯器也會直接拒絕你。
這把「Model 不該知道畫面存在」從一句口號變成編譯器強制執行的規則。同樣的道理,測試專案也是 net10.0——它連 WinForms 都沒引用,卻能完整測試你的業務邏輯。這就是分層真正的價值。
建立方式(在空資料夾裡依序執行):
dotnet new sln -n MvpDemo
dotnet new classlib -o MvpDemo.Core -n MvpDemo.Core
dotnet new winforms -o MvpDemo.App -n MvpDemo.App
dotnet new xunit -o MvpDemo.Tests -n MvpDemo.Tests
dotnet sln add MvpDemo.Core MvpDemo.App MvpDemo.Tests
cd MvpDemo.App && dotnet add reference ../MvpDemo.Core && cd ..
cd MvpDemo.Tests && dotnet add reference ../MvpDemo.Core && cd ..注意箭頭都是單向的:App 和 Tests 都依賴 Core,Core 誰也不依賴。這個方向不能反過來——Core 一旦引用了 App,整個設計就白做了。
Model 第一層:Product 與 AddResult
先是最單純的一筆商品資料:
namespace MvpDemo.Core.Models;
public class Product
{
public string Name { get; }
public int Price { get; }
public Product(string name, int price)
{
Name = name;
Price = price;
}
}兩個屬性都只有 get 沒有 set,只能在建構式裡指定。這叫唯讀(immutable)——建立之後就不能改,要改就建立新的一筆。好處是你永遠不必擔心「這筆商品在別的地方被偷偷改掉了」,追 bug 時可以少排除一整類可能性。
接著是一個很多人不會想到要建立的類別:用來表達「新增成功或失敗」的結果。
namespace MvpDemo.Core.Models;
public class AddResult
{
public bool Success { get; }
public string ErrorMessage { get; }
private AddResult(bool success, string errorMessage)
{
Success = success;
ErrorMessage = errorMessage;
}
public static AddResult Ok() => new AddResult(true, string.Empty);
public static AddResult Fail(string message) => new AddResult(false, message);
}為什麼需要它?因為 ProductService.Add() 必須回答兩件事:「成功了嗎」以及「如果失敗,是為什麼」。
你可能會想用其他做法,但都有問題。回傳 bool 只知道成功與否,不知道原因;直接在 Service 裡 MessageBox.Show 就違反了「Model 不碰 UI」;丟 Exception 也不對——使用者打錯字是正常流程的一部分,不是程式出錯,例外應該留給真正意外的狀況。
建構式設成 private、只能透過 AddResult.Ok() 和 AddResult.Fail("...") 建立,是為了讓呼叫端讀起來像句子,也避免有人建立出 Success = true 卻帶著錯誤訊息這種矛盾的東西。
Model 第二層:ProductService
這是整支程式的心臟——所有業務規則集中在這裡:
namespace MvpDemo.Core.Models;
public class ProductService
{
private readonly List<Product> _products = new List<Product>();
public IReadOnlyList<Product> Products => _products;
public int Count => _products.Count;
public int TotalPrice
{
get
{
int total = 0;
foreach (Product p in _products)
{
total += p.Price;
}
return total;
}
}
public AddResult Add(string name, string priceText)
{
if (string.IsNullOrWhiteSpace(name))
{
return AddResult.Fail("請輸入商品名稱");
}
if (!int.TryParse(priceText, out int price))
{
return AddResult.Fail("價格必須是數字");
}
if (price <= 0)
{
return AddResult.Fail("價格必須大於 0");
}
foreach (Product p in _products)
{
if (p.Name == name)
{
return AddResult.Fail("這個商品已經存在");
}
}
_products.Add(new Product(name, price));
return AddResult.Ok();
}
public bool Remove(string name)
{
for (int i = 0; i < _products.Count; i++)
{
if (_products[i].Name == name)
{
_products.RemoveAt(i);
return true;
}
}
return false;
}
}拿它跟上一篇的 BtnAdd_Click 對照,你會發現四條驗證規則一模一樣——只是從按鈕事件裡搬了出來,而且每一條失敗都回傳原因而不是跳訊息框。
三個地方值得說明。
Products 的型別是 IReadOnlyList<Product> 而不是 List<Product>。這是刻意的:外面拿得到清單來讀取,但不能繞過 Add() 直接塞東西進去。如果開放成 List,別人就能寫 service.Products.Add(new Product("", -999)),你的四條規則就形同虛設。
TotalPrice 寫成唯讀屬性而不是欄位,所以它永遠是「當下算出來的」,不會有「加了商品但忘了更新總價」這種不同步的 bug。上一篇那份程式碼裡,總價是在按鈕事件裡臨時算的——你如果加了「刪除商品」功能而忘記重算,畫面就會顯示錯誤的總價,而且沒有任何警告。
Add() 的價格參數收 string 而不是 int,這點可能有點反直覺。理由是「使用者打的不是數字」本身就是一條需要驗證的規則,如果 Service 只收 int,那 int.TryParse 就得寫在 Presenter 或 View 裡——規則就散開了。全部收字串,四條規則才能真的集中在一個地方,也才能一起被測試。
Remove() 這篇還沒用到,先留著給你做文章最後的練習。
View 介面:Presenter 眼中的畫面
namespace MvpDemo.Core.Views;
public interface IProductView
{
string ProductNameText { get; }
string PriceText { get; }
event EventHandler AddClicked;
void AddProductToList(string display);
void ShowSummary(string text);
void ShowError(string message);
void ClearInputs();
}這個介面是整個 MVP 的樞紐,請仔細看它「沒有」什麼:沒有 TextBox、沒有 ListBox、沒有 Label、沒有 MessageBox。它描述的全是能力——「能提供使用者輸入的名稱」「能顯示一則錯誤」——而不是長相。
正因為只描述能力,任何東西都能實作它。WinForms 的 MainForm 可以,等一下測試裡的 FakeProductView 也可以;未來如果改用 WPF、MAUI、甚至做成主控台程式,也都只要實作這七個成員就好,Presenter 一行都不用改。
AddClicked 是 event,這是方向的關鍵:事件從 View 往外送,指令從 Presenter 往內下。View 不知道誰在聽這個事件,它只負責喊一聲。
Presenter:把流程串起來
using MvpDemo.Core.Models;
using MvpDemo.Core.Views;
namespace MvpDemo.Core.Presenters;
public class ProductPresenter
{
private readonly IProductView _view;
private readonly ProductService _service;
public ProductPresenter(IProductView view, ProductService service)
{
_view = view;
_service = service;
_view.AddClicked += OnAddClicked;
_view.ShowSummary(BuildSummary());
}
private void OnAddClicked(object? sender, EventArgs e)
{
AddProduct();
}
public void AddProduct()
{
AddResult result = _service.Add(_view.ProductNameText, _view.PriceText);
if (!result.Success)
{
_view.ShowError(result.ErrorMessage);
return;
}
Product added = _service.Products[_service.Count - 1];
_view.AddProductToList($"{added.Name} {added.Price} 元");
_view.ShowSummary(BuildSummary());
_view.ClearInputs();
}
private string BuildSummary()
{
return $"共 {_service.Count} 項,總價 {_service.TotalPrice} 元";
}
}Presenter 的兩個欄位型別很值得注意:IProductView(介面)和 ProductService(實體類別)。它依賴的是介面而不是 MainForm,所以它根本不知道自己正在跟一個 WinForms 視窗說話——這正是等一下能塞假畫面進去測試的原因。
建構式做兩件事:訂閱 View 的 AddClicked 事件,然後立刻叫 View 顯示一次初始統計(「共 0 項,總價 0 元」)。上一篇那份程式碼是把這行初始文字寫死在 Form 裡的,現在改由 Presenter 提供——畫面上所有會變的文字都來自 Presenter,Form 裡不再有任何寫死的狀態。
AddProduct() 設成 public 而不是 private,是為了讓測試能直接呼叫,不必為了測試而去模擬點擊按鈕。這是一個實用的小取捨。
整個方法讀起來就是一句話:問 Model 能不能加 → 不能就叫 View 顯示錯誤 → 能就叫 View 更新三個地方。沒有任何判斷邏輯,判斷全在 Model;沒有任何控制項操作,操作全在 View。
View 實作:MainForm 瘦下來了
using MvpDemo.Core.Views;
namespace MvpDemo.App;
public class MainForm : Form, IProductView
{
private readonly TextBox txtName = new TextBox();
private readonly TextBox txtPrice = new TextBox();
private readonly Button btnAdd = new Button();
private readonly ListBox lstItems = new ListBox();
private readonly Label lblSummary = new Label();
public MainForm()
{
Text = "商品管理(MVP 版)";
ClientSize = new Size(380, 330);
var lblName = new Label { Text = "商品名稱", Location = new Point(20, 20), AutoSize = true };
txtName.Location = new Point(100, 17);
txtName.Width = 250;
var lblPrice = new Label { Text = "價格", Location = new Point(20, 55), AutoSize = true };
txtPrice.Location = new Point(100, 52);
txtPrice.Width = 250;
btnAdd.Text = "新增";
btnAdd.Location = new Point(100, 88);
btnAdd.Width = 250;
btnAdd.Click += (s, e) => AddClicked?.Invoke(this, EventArgs.Empty);
lstItems.Location = new Point(20, 130);
lstItems.Size = new Size(330, 130);
lblSummary.Location = new Point(20, 275);
lblSummary.AutoSize = true;
Controls.AddRange(new Control[] { lblName, txtName, lblPrice, txtPrice, btnAdd, lstItems, lblSummary });
}
// ---- IProductView 實作 ----
public string ProductNameText => txtName.Text;
public string PriceText => txtPrice.Text;
public event EventHandler? AddClicked;
public void AddProductToList(string display) => lstItems.Items.Add(display);
public void ShowSummary(string text) => lblSummary.Text = text;
public void ShowError(string message) => MessageBox.Show(message);
public void ClearInputs()
{
txtName.Clear();
txtPrice.Clear();
txtName.Focus();
}
}上一篇塞了五件事、三十幾行的 BtnAdd_Click,現在變成這一行:
btnAdd.Click += (s, e) => AddClicked?.Invoke(this, EventArgs.Empty);「按鈕被按了,我喊一聲」——就這樣,不判斷任何事。
IProductView 的實作也全是一行方法:AddProductToList 就是 lstItems.Items.Add(...),ShowError 就是 MessageBox.Show(...)。整個檔案裡找不到任何一條業務規則——你搜尋「價格」「必須」「大於」都搜不到。這就是目標達成的樣子。
?.Invoke 的問號不能省。如果沒有人訂閱 AddClicked(例如你忘了建立 Presenter),AddClicked 會是 null,直接呼叫就會炸掉。
一個真實踩到的雷:屬性名稱撞到 Control
我第一次寫這個介面時,屬性名稱取的是 ProductName,編譯出現警告:
warning CS0108: 'MainForm.ProductName' 會隱藏繼承的成員 'Control.ProductName'。
若本意即為要隱藏,請使用 new 關鍵字。原來 Control 本身就有一個 ProductName 屬性(回傳組件的產品名稱)。MainForm 繼承自 Form、Form 繼承自 Control,所以撞名了。
程式仍然能跑,但這種隱藏是純粹的地雷——哪天有人透過 Control 型別的變數去讀 ProductName,拿到的會是組件資訊而不是使用者輸入的商品名稱,而且不會有任何錯誤。我把它改成 ProductNameText 就乾淨了。
這是 View 繼承自框架類別時的通用陷阱:你的介面成員名稱可能跟框架既有成員撞到。遇到 CS0108 警告不要無視,改個名字最省事。
組裝:Program.cs
三個角色都寫好了,總要有人把它們接起來:
using MvpDemo.Core.Models;
using MvpDemo.Core.Presenters;
namespace MvpDemo.App;
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
var view = new MainForm();
var service = new ProductService();
_ = new ProductPresenter(view, service);
Application.Run(view);
}
}建立 View、建立 Model,把兩個交給 Presenter。Presenter 的建構式會自動訂閱事件,之後整個程式就靠事件運轉,不需要有人再呼叫它。
_ = new ProductPresenter(...) 的底線是「我知道我沒有把它存進變數,這是故意的」。你也可以寫成 var presenter = new ProductPresenter(view, service);,但那會讓編譯器提示變數未使用。Presenter 被 View 的事件參考著,不會被垃圾回收。
跑起來是這樣:

跟上一篇那張圖對照——除了標題多了「(MVP 版)」,畫面與數字完全一樣。使用者感受不到任何差別,這正是重構的定義:改變結構,不改變行為。
現在來寫測試——完全不開視窗
這是整個系列的重點。先做一個假的畫面:
using MvpDemo.Core.Views;
namespace MvpDemo.Tests;
public class FakeProductView : IProductView
{
public string ProductNameText { get; set; } = string.Empty;
public string PriceText { get; set; } = string.Empty;
public event EventHandler? AddClicked;
public List<string> ListItems { get; } = new List<string>();
public List<string> Errors { get; } = new List<string>();
public string Summary { get; private set; } = string.Empty;
public int ClearInputsCallCount { get; private set; }
public void AddProductToList(string display) => ListItems.Add(display);
public void ShowSummary(string text) => Summary = text;
public void ShowError(string message) => Errors.Add(message);
public void ClearInputs() => ClearInputsCallCount++;
public void ClickAdd() => AddClicked?.Invoke(this, EventArgs.Empty);
}它實作了 IProductView,所以在 Presenter 眼中它就是一個合法的畫面——但它不是視窗,它是一台錄音機。「顯示錯誤」對它來說就是把訊息記進 Errors 清單,「清空欄位」就是把計數器加一。這樣測試就能事後檢查 Presenter 到底對畫面下了哪些指令。
兩個輸入屬性有 set(介面只要求 get,實作可以多給),這樣測試才能「假裝使用者打了什麼字」。ClickAdd() 則是「假裝使用者按了按鈕」。
接著是測試本體:
using MvpDemo.Core.Models;
using MvpDemo.Core.Presenters;
namespace MvpDemo.Tests;
public class ProductPresenterTests
{
private static (FakeProductView view, ProductPresenter presenter) Build()
{
var view = new FakeProductView();
var presenter = new ProductPresenter(view, new ProductService());
return (view, presenter);
}
[Fact]
public void 價格是負數_應該顯示錯誤而且不加進清單()
{
var (view, _) = Build();
view.ProductNameText = "無線滑鼠";
view.PriceText = "-450";
view.ClickAdd();
Assert.Equal("價格必須大於 0", Assert.Single(view.Errors));
Assert.Empty(view.ListItems);
}
[Fact]
public void 價格不是數字_應該顯示錯誤()
{
var (view, _) = Build();
view.ProductNameText = "無線滑鼠";
view.PriceText = "四百五";
view.ClickAdd();
Assert.Equal("價格必須是數字", Assert.Single(view.Errors));
Assert.Empty(view.ListItems);
}
[Fact]
public void 名稱空白_應該顯示錯誤()
{
var (view, _) = Build();
view.ProductNameText = " ";
view.PriceText = "450";
view.ClickAdd();
Assert.Equal("請輸入商品名稱", Assert.Single(view.Errors));
}
[Fact]
public void 輸入合法_應該加進清單並清空欄位()
{
var (view, _) = Build();
view.ProductNameText = "無線滑鼠";
view.PriceText = "450";
view.ClickAdd();
Assert.Empty(view.Errors);
Assert.Equal("無線滑鼠 450 元", Assert.Single(view.ListItems));
Assert.Equal(1, view.ClearInputsCallCount);
}
[Fact]
public void 新增多筆_統計文字應該正確累加()
{
var (view, _) = Build();
view.ProductNameText = "無線滑鼠";
view.PriceText = "450";
view.ClickAdd();
view.ProductNameText = "機械鍵盤";
view.PriceText = "1200";
view.ClickAdd();
view.ProductNameText = "27吋螢幕";
view.PriceText = "5990";
view.ClickAdd();
Assert.Equal(3, view.ListItems.Count);
Assert.Equal("共 3 項,總價 7640 元", view.Summary);
}
[Fact]
public void 同名商品_第二次應該被擋下來()
{
var (view, _) = Build();
view.ProductNameText = "無線滑鼠";
view.PriceText = "450";
view.ClickAdd();
view.ProductNameText = "無線滑鼠";
view.PriceText = "999";
view.ClickAdd();
Assert.Equal("這個商品已經存在", Assert.Single(view.Errors));
Assert.Single(view.ListItems);
}
}每個測試都是同樣的三段式:準備(設定假輸入)→ 動作(ClickAdd())→ 驗證(檢查 Presenter 對畫面做了什麼)。這在測試領域叫 Arrange-Act-Assert,是最常見的寫法。
方法名稱用中文加底線是刻意的——測試名稱的讀者是人不是編譯器,看到「價格是負數_應該顯示錯誤而且不加進清單」就知道這條在保護什麼規則,失敗時報表上顯示的也是這行字。
在方案根目錄執行:
dotnet test已通過! - 失敗: 0,通過: 6,略過: 0,總計: 6,持續時間: 94 ms94 毫秒,六條規則全部驗證完畢,沒有任何視窗被打開。
回想上一篇的做法:要驗證這六件事,你得手動開啟程式六次、打六組不同的字、看六次訊息框。現在按一次指令就好,而且以後每次改動都能重跑——這才是「可測試」的實際意義。
前後對照:到底換到了什麼
回頭檢查上一篇提出的四個問題。
能測試了。 剛才那六個測試就是答案。而且注意測試專案的 target framework 是 net10.0——它完全沒有引用 WinForms,卻能測試你所有的業務規則。
換 UI 不用重寫。 今天要做網頁版,MvpDemo.Core 整包搬過去就能用,一行都不用改,因為它裡面沒有任何 WinForms 的痕跡。要重寫的只有 MainForm——那本來就該重寫,因為畫面本來就不一樣。
功能變多不會爆炸。 加「刪除商品」時,規則寫進 ProductService.Remove()(已經幫你留好了),流程寫進 ProductPresenter,畫面加一個按鈕與一個 RemoveProductFromList 方法。三件事各自待在自己該在的地方,MainForm 不會再膨脹。
協作衝突變少。 做畫面的人改 MvpDemo.App,做邏輯的人改 MvpDemo.Core,兩邊碰的是不同檔案甚至不同專案。
代價也要誠實講:檔案從 1 個變成 8 個,程式碼總行數變多了。 對一支只有「新增」功能的程式來說,MVP 確實是殺雞用牛刀——如果你要寫的只是一個五分鐘的小工具,全部塞在 Form 裡沒有錯,不必為了架構而架構。
MVP 值得的時機是:這支程式會活很久、會一直加功能、會有人接手,或者你希望邏輯能被測試。 三個條件中一個成立,拆分就開始回本。
動手練習:加上「刪除商品」
ProductService.Remove() 已經幫你寫好了。你需要動的是另外三個地方:
1. IProductView:加一個 SelectedProductName(使用者在清單裡選了哪一項)、一個 RemoveClicked 事件、一個 RemoveProductFromList(string display) 方法
2. ProductPresenter:訂閱 RemoveClicked,呼叫 _service.Remove(...),成功就叫 View 移除該項並更新統計,失敗就顯示錯誤
3. MainForm:加一個「刪除」按鈕、實作那三個新成員
寫完之後,先別急著開視窗測試——在 FakeProductView 加上對應的假實作,然後寫一個測試:
[Fact]
public void 刪除商品_統計應該正確減少()
{
// 新增兩筆、刪除一筆,驗證 Summary 變成「共 1 項,總價 ... 元」
}如果這個測試過了,你的邏輯就是對的,開視窗只是確認按鈕位置有沒有擺好。這個「先測試、後開視窗」的順序,就是這兩篇文章想帶你抵達的地方。
下載完整程式碼
上面所有程式碼都在這個壓縮檔裡,13 個檔案、三個專案:
解壓縮之後:
cd MvpDemo
dotnet build # 建置三個專案
dotnet test # 跑六個測試
cd MvpDemo.App
dotnet run # 開啟視窗需要 .NET SDK 10(用 dotnet --version 確認)。這份程式碼我實際解壓縮到另一個乾淨的資料夾重新建置過,dotnet build 零錯誤零警告、dotnet test 六個測試全數通過,確認下載後可以直接使用。
如果你是從上一篇一路看下來的,建議把兩個版本並排打開比較——同樣的功能、同樣的畫面,一個是三十行擠在按鈕事件裡,一個是八個檔案各司其職。哪一種比較好,取決於這支程式接下來要活多久。