10
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2025 業務アプリ向け WinForms 初級「MVPパターン」解説(自動テスト含む)

Posted at

本記事では、C#のWinFormsアプリケーションを例に、MVPパターンにおける各クラスの役割と特徴を解説します。

MVP(Model-View-Presenter)パターンは、GUIアプリケーションの構造を明確にし、保守性とテスト容易性を高めるための設計パターンです。​

1.前提条件

  • Visual studio 2022 Version 17.13.6
  • .Net 9

なお、すべてのソースコードを公開しています。

このデモプログラムのDBアクセスは「あえて」簡易版です。
(DB構築なしで即実行できるようにしました。)

DBアクセスを含むフルスタックのサンプルについては、以下の記事をご覧ください。

2.MVP(Model + View + Presenter)パターンの概要

(1) MVPの概要

MVPイメージ図
image.png

MVPパターンは、以下の3つのコンポーネントで構成されます:​

コンポーネント 役割
Model アプリケーションのデータとビジネスロジックを管理
View ユーザーインターフェースを担当し、ユーザーの入力を受け取る(WinFormsだとForm)
Presenter ModelとViewの仲介役であり、ユーザーの操作に応じてModelを更新し、その結果をViewに反映させる

この構造により、各コンポーネントの責務が明確になり、コードの再利用性やテストのしやすさが向上します。

(2) なぜMVCではなくMVPなのか?

  • MVCは元々「Webアプリ(リクエスト・レスポンス)」に最適化された構造です
  • MVPは「UIイベント中心のアプリ(WinForms/WPFなど)」に向いています
  • WinFormsではViewに直接Button.Clickなどのイベントが来るため、それをPresenterへ委譲するMVPが自然なんです

(3) WinFormsとMVPの相性が良い理由

観点 MVP MVC
UIイベントとの対応 View → Presenterに直接イベント通知できる(eventで) コントローラーが直接イベントを監視しづらい
自動テスト PresenterはViewのインターフェースで疎結合→モック化しやすい ControllerがUIに依存しやすい
責務分離 ViewはUI描画だけ、Presenterがロジック全て持つ ControllerとViewの責務が曖昧になりやすい
WinFormsとの親和性 Formにイベント+バインドを集約しやすい ViewとControllerの役割分担が混ざりやすい

3.画面イメージから「View」と「リポジトリ」インターフェースの定義

リポジトリとビューは、それぞれインターフェースを通じて「何を提供できるか」を外部に明示します。
これにより、内部の実装に依存することなく、外部から必要な機能だけを利用できるようになります。

(1) 画面イメージ

image.png

image.png

(2) UI操作とそれに対応する処理の責任範囲

UI上の操作 プレゼンターの責任 リポジトリの責任
画面起動 GetAll() を呼び出して View に一覧を渡す 利用者一覧を DB(またはメモリ)から取得
追加ボタンクリック View の入力欄を初期化し編集モードに切り替える なし
グリッドの選択 選択データを View に渡し、編集モードに切り替える なし
保存クリック 新規か更新かで Add / Edit を分岐し実行 データの Insert または Update を実行
削除クリック Delete を実行し View に結果を通知する データの Delete を実行
キャンセルクリック View の入力状態をクリアし一覧表示に戻す なし

🎯 Presenter は「UI操作」と「データ操作」の橋渡し役です。
View や Repository に直接仕事をさせず、「どの操作で、何が起きるか」をプレゼンターがコントロールしています。

(3) UI上の操作からVIEWのインターフェースを定義する

public interface IView利用者
{
    // 画面起動(一覧表示用のデータを受け取る)
    void Set利用者ListBindingSource(BindingSource 利用者list);

    // 追加ボタンクリック
    event EventHandler AddNewEvent;   
    // グリッドの選択(修正開始要求)
    event EventHandler EditEvent;     
    // 保存ボタンクリック
    event EventHandler SaveEvent;     
    // 削除ボタンクリック
    event EventHandler DeleteEvent;   
    // キャンセルボタンクリック
    event EventHandler CancelEvent;   
}

(4) 必要なDBアクセスからリポジトリのインターフェースを定義する

public interface I利用者Repository
{
    // DBから利用者の一覧をSelectする
    IEnumerable<利用者Model> GetAll();

    // 入力した利用者をDBへInsert
    bool Add(利用者Model user);
    // 入力した利用者をDBへUpdate
    bool Edit(利用者Model user);
    // 選択した利用者をDBからDeleteする
    bool Delete(利用者Model user);
}

4.各コンポーネントの役割と実装

(1) 🔷 Model の役割

データの構造や、データ操作(追加・修正・削除・検索)を提供します。ビジネスロジックも含めます。

極力データアノテーションを指定して、ビジネスルールをデータの構造で表現します。
これによりビジネスルールに合致しないデータの受入れを構造的に排除できます。

public class 利用者Model
{
    [Key]
    public int ID { get; set; }

    [Required(ErrorMessage = "利用者名は必須です。")]
    [StringLength(20, MinimumLength = 2, ErrorMessage = "利用者名は2文字以上+20文字以内で入力してください。")]
    public string 利用者名 { get; set; } = string.Empty;

    [StringLength(200, ErrorMessage = "住所は200文字以内で入力してください。")]
    public string? 住所 { get; set; }

    [Required(ErrorMessage = "誕生日は必須です。")]
    [DataType(DataType.Date, ErrorMessage = "誕生日は日付を入力してください。")]
    public DateTime 誕生日 { get; set; }

    [Required]
    public int Version { get; set; }
}

通常リポジトリーはDBアクセスを担当します。
今回のデモでは下記のように、DBの代わりにBindingList<利用者Model>を利用してます。

public class 利用者Repository : I利用者Repository
{
    private BindingList<利用者Model> _users = new();

    public 利用者Repository()
    {
        // 初期データ
        _users.Add(new 利用者Model { ID = 1, 利用者名 = "山田太郎", 住所 = "東京都", 誕生日 = new DateTime(1990, 1, 1), Version = 1 });
        _users.Add(new 利用者Model { ID = 2, 利用者名 = "鈴木花子", 住所 = "大阪府", 誕生日 = new DateTime(1995, 5, 5), Version = 1 });
        _users.Add(new 利用者Model { ID = 3, 利用者名 = "佐藤次郎", 住所 = "北海道", 誕生日 = new DateTime(1988, 8, 8), Version = 1 });
    }

    public bool Edit(利用者Model user)
    {
        var index = _users.FindIndex(u => u.ID == user.ID && u.Version == user.Version);
        if (index < 0) return false;
        user.Version++;
        _users[index] = user;
        return true;
    }

(2) 🔷 View の役割

WinFormsでは一般にFormのこと。
ただしMVPではFormの中にビジネスロジックを直接書かず、プレゼンターへ通知する。

public interface IView利用者
{
    event EventHandler AddNewEvent;   // 「追加を要求されたときの振る舞い」がある
    event EventHandler EditEvent;
    event EventHandler DeleteEvent;
    event EventHandler SaveEvent;
    event EventHandler CancelEvent;
}

この IView利用者 を実装した 利用者View クラスは、以下のようにイベント発行を行います:

this.Button追加.Click += delegate
{
    // Button追加.Click時にInterfaceで定義した「追加を要求されたときの振る舞い」を起動する
    AddNewEvent?.Invoke(this, EventArgs.Empty);
    Panel操作.Visible = true;
    this.Button削除.Visible = false;
};

(3) 🔷 Presenter の役割

View からのイベント通知を受け取り、Model を操作し、結果を View に反映させます。

public class 利用者Presenter
{
    public 利用者Presenter(IView利用者 view, I利用者Repository repository)
    {
        // 「追加を要求されたときの振る舞い」とは「AddNew利用者」である
        _view.AddNewEvent += AddNew利用者;
        _view.EditEvent += LoadSelected利用者ToEdit;
        _view.DeleteEvent += Delete利用者;
        _view.SaveEvent += Save利用者;
        _view.CancelEvent += Cancel利用者;

        _view.Set利用者ListBindingSource(_bindingSource利用者);

        LoadAll利用者();
        _view.Show();
    }

Presenter は Model の戻り値を元に View hメッセージを返したり、状態を反映させます:

// ここで初めて、「追加を要求されたときの振る舞い」が実装される
private void AddNew利用者(object? sender, EventArgs e)
{
    // 追加時の初期値
    _view.IsEdit = false;

    CleanViewFields();
}

(4) 🔷 Model / View / Presenter の呼び出し方

[STAThread]
static void Main()
{
    IView利用者 view = new 利用者View();
    I利用者Repository repo = new 利用者Repository();
    
    _ = new 利用者Presenter(view, repo);

    Application.Run((Form)view);
}

IView利用者 view = new 利用者View();

→ これは「変数はインターフェース型で宣言し、実体は具体クラスで生成する」という設計の例です。
つまり、「実装にではなく抽象に依存する(Depend on abstractions)」という原則に従った書き方です。

(5) 🔷 プロジェクト全体のクラスダイアグラム

image.png

5.Presenterの自動単体テスト(Moq使用)

MVPパターンの最大のメリットは、PresenterがViewやModelから疎結合であるため、単体テストが非常に容易になることです。

(1) テストプロジェクトの追加と実行イメージ

image.png

(2) テストクラスの定義

以下のように、ViewとRepositoryをMockし、Presenterの動作のみを検証できます。

[TestMethod]
public void LoadAll利用者_初期表示で全件取得される()
{
    var mockView = new Mock<IView利用者>();
    var mockRepo = new Mock<I利用者Repository>();

    // --------------------------------------------------------------------
    // 単体テストの基本的な考え方:
    //     実際のDBの値に依存しないように、モックを使ってテストする。
    //     ( ここでモックが返す値を定義している )
    // --------------------------------------------------------------------
    var 利用者一覧 = new List<利用者Model>
    {
        new 利用者Model { ID = 1, 利用者名 = "山田太郎", Version = 1 },
        new 利用者Model { ID = 2, 利用者名 = "鈴木花子", Version = 1 }
    };

    mockRepo.Setup(r => r.GetAll()).Returns(利用者一覧);

    BindingSource? capturedBindingSource = null;
    mockView.Setup(v => v.Set利用者ListBindingSource(It.IsAny<BindingSource>()))
            .Callback<BindingSource>(bs => capturedBindingSource = bs);

    mockView.Setup(v => v.Show());

    // Act
    var presenter = new 利用者Presenter(mockView.Object, mockRepo.Object);

    // Assert
    Assert.IsNotNull(capturedBindingSource);
    var bs = capturedBindingSource;

    // 件数の確認
    Assert.AreEqual(2, bs.Count);
    // 1件目の利用者名の確認
    Assert.AreEqual("山田太郎", ((利用者Model)bs[0]!).利用者名);
}

このように、Presenterだけをテストすることで、UIなしでもアプリのロジックを検証できます。
他にも「登録成功」「削除成功」「排他ロック失敗」など、様々なケースをテストできます。

6.MVPのメリットまとめ

  • Viewの疎結合化 → テストや画面差し替えが容易
  • Presenterのテスト容易性 → Moqを使ったPresenter単体テストが実現可能
  • Modelの独立性 → ビジネスロジックをViewと切り離して管理

7.おわりに

MVPパターンは、WinFormsのようなデスクトップアプリケーションでも非常に有効なアーキテクチャです。
この記事が少しでもお役に立てれば嬉しく思います。

10
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?