本章以薪資系統的使用者介面為例,介紹 Model View Presenter(MVP) 模式——一種將業務邏輯與 UI 呈現徹底分離的架構模式,使業務邏輯可以在不依賴任何 UI 框架的情況下被測試。

介面設計#

Figure 38.1: Initial payroll user interface

  • 薪資系統的 UI 需要一個「新增員工」的視窗
  • 包含欄位:EmpId、Name、Address、薪資類型(Hourly/Salaried/Commissioned)、以及對應的金額欄位
  • 設計目標:UI 邏輯與業務邏輯完全分離

MODEL VIEW PRESENTER 架構#

Figure 38.4: Design of the Payroll view

  • MVP 將系統分為三個角色:
    • Model:業務邏輯與資料(在本例中是 PayrollDatabase 與 Transaction 類別)
    • View:UI 呈現層,只負責顯示資料與接收使用者輸入(在本例中是 AddEmployeeWindow
    • Presenter:協調者,從 View 取得使用者輸入,操作 Model,再更新 View

為什麼不是 MVC?#

  • MVP 與 MVC 的差異在於控制流的方向
    • 在 MVC 中,View 直接觀察 Model 的變化
    • 在 MVP 中,所有的互動都經過 Presenter——View 不直接與 Model 互動
  • MVP 的優勢:Presenter 完全不依賴 UI 框架,可以用純粹的單元測試驗證
sequenceDiagram
    actor User as 使用者
    participant View as AddEmployeeWindow<br/>(View)
    participant Presenter as AddEmployeePresenter<br/>(Presenter)
    participant Tx as AddHourlyEmployee<br/>(Transaction)
    participant DB as PayrollDatabase<br/>(Model)

    User->>View: 填入員工資料
    User->>View: 點擊 Submit
    View->>Presenter: AddEmployee()
    Presenter->>View: 讀取 EmpId, Name, Address
    Presenter->>View: 讀取 IsHourly, HourlyRate
    Presenter->>Tx: new AddHourlyEmployee(...)
    Presenter->>Tx: Execute()
    Tx->>DB: AddEmployee(id, employee)

以測試驅動開發 Presenter#

AddEmployeeView 介面#

  • 定義 View 的抽象介面,讓 Presenter 不依賴具體的 UI 實作:
public interface AddEmployeeView
{
    int EmpId { get; }
    string EmployeeName { get; }
    string EmployeeAddress { get; }
    bool IsHourly { get; }
    bool IsSalaried { get; }
    bool IsCommissioned { get; }
    double HourlyRate { get; }
    double Salary { get; }
    double CommissionRate { get; }
    double BaseSalary { get; }
}

MockAddEmployeeView#

  • 測試時使用 Mock View,直接設定屬性值:
public class MockAddEmployeeView : AddEmployeeView
{
    public int EmpId { get; set; }
    public string EmployeeName { get; set; }
    public string EmployeeAddress { get; set; }
    public bool IsHourly { get; set; }
    public bool IsSalaried { get; set; }
    public bool IsCommissioned { get; set; }
    public double HourlyRate { get; set; }
    public double Salary { get; set; }
    public double CommissionRate { get; set; }
    public double BaseSalary { get; set; }
}

AddEmployeePresenter#

public class AddEmployeePresenter
{
    private AddEmployeeView view;
    private PayrollDatabase database;

    public AddEmployeePresenter(
        AddEmployeeView view, PayrollDatabase database)
    {
        this.view = view;
        this.database = database;
    }

    public void AddEmployee()
    {
        int empId = view.EmpId;
        string name = view.EmployeeName;
        string address = view.EmployeeAddress;

        if (view.IsHourly)
        {
            AddHourlyEmployee(empId, name, address);
        }
        else if (view.IsSalaried)
        {
            AddSalariedEmployee(empId, name, address);
        }
        else if (view.IsCommissioned)
        {
            AddCommissionedEmployee(empId, name, address);
        }
    }

    private void AddHourlyEmployee(int id, string name, string address)
    {
        AddHourlyEmployee t = new AddHourlyEmployee(
            id, name, address, view.HourlyRate, database);
        t.Execute();
    }
    // 其餘方法同理
}

測試 Presenter#

[Test]
public void AddHourlyEmployee()
{
    MockAddEmployeeView view = new MockAddEmployeeView();
    view.EmpId = 1;
    view.EmployeeName = "Andrew";
    view.EmployeeAddress = "123 Main St";
    view.IsHourly = true;
    view.HourlyRate = 25.0;

    InMemoryPayrollDatabase database = new InMemoryPayrollDatabase();
    AddEmployeePresenter presenter =
        new AddEmployeePresenter(view, database);

    presenter.AddEmployee();

    Employee e = database.GetEmployee(1);
    Assert.AreEqual("Andrew", e.Name);
    Assert.IsTrue(e.Classification is HourlyClassification);
}

技巧: MVP 的測試完全不需要啟動 UI 框架——所有的業務邏輯驗證都在 Presenter 層完成,使用 Mock View 提供輸入資料。這讓測試既快速又穩定。

建構實際的 UI#

AddEmployeeWindow#

  • AddEmployeeWindow 繼承 Form(Windows Forms)並實作 AddEmployeeView 介面
  • 它只負責:
    1. 將 UI 控制項的值暴露為 AddEmployeeView 的屬性
    2. 在按鈕點擊時呼叫 Presenter.AddEmployee()
    3. 根據選擇的薪資類型顯示/隱藏對應的輸入欄位
public class AddEmployeeWindow : Form, AddEmployeeView
{
    private AddEmployeePresenter presenter;

    public AddEmployeeWindow()
    {
        presenter = new AddEmployeePresenter(
            this, GpayrollDatabase.instance);
        // 初始化 UI 控制項...
    }

    public int EmpId
    {
        get { return int.Parse(empIdTextBox.Text); }
    }

    public string EmployeeName
    {
        get { return nameTextBox.Text; }
    }

    private void submitButton_Click(object sender, EventArgs e)
    {
        presenter.AddEmployee();
    }
    // ...
}

重點: AddEmployeeWindow 中幾乎沒有業務邏輯——所有的決策(如「選擇哪種 Transaction」)都在 Presenter 中完成。View 只是一個被動的資料容器與事件轉發器。

MVP 的優勢#

  • 可測試性:Presenter 的所有邏輯都可以透過 Mock View 測試,不需要 UI 框架
  • 可替換性:同一個 Presenter 可以搭配不同的 View 實作(Windows Forms、Web、Console)
  • 關注點分離:View 專注於呈現,Presenter 專注於協調,Model 專注於業務規則
  • 行為驅動:先寫 Presenter 的測試來定義行為,再建構 View 來滿足介面

補充: 作者在本章中選擇先寫測試、再寫 Presenter、最後才建構 UI——這是 TDD(測試驅動開發)在 UI 開發中的實踐。MVP 模式使得這種開發順序成為可能。

本章小結#

MVP 模式透過將 View 抽象為介面,讓 Presenter 可以在完全不依賴 UI 框架的情況下被開發與測試。View 是被動的——它只提供資料與接收事件,所有的業務決策都由 Presenter 做出。這種架構讓 UI 開發也能享受 TDD 的好處:先定義行為、再實作介面。