第47章:Blazorの“テストしやすい分離”を作る(UIとロジックを分ける)🧩
この章はひとことで言うと、**「Blazorの画面(Razorコンポーネント)を“薄く”して、ロジックを“テストできる場所”に引っ越す」**練習だよ〜😊💡 Blazor Web App テンプレートは 1つのプロジェクトからSSR/クライアント側の両方を扱える形になっていて便利だけど、油断すると UIにロジックが混ざりやすいのが罠⚠️ (※Blazor Web App テンプレの説明は Microsoft Docs にもあるよ) (Microsoft Learn)
この章のゴール🎯✨

- ✅ UI(.razor)は「表示+イベント配線」だけにできる
- ✅ 重要なルール(計算・検証)を クラスライブラリへ移して xUnit でテストできる
- ✅ 仕様変更が来ても、UIを壊しにくくできる(安心感〜🥹🛡️)
今日つくるミニ題材🎀📦
「推し活グッズ管理」の超ミニ版として、
- グッズを追加(名前/カテゴリ/単価/数量)
- 合計金額・合計点数を表示
を作るよ〜😊 ポイントは、合計計算や入力チェックをUIに書かないこと🧠✨
まず結論:Blazorで“分離できてる”状態って?👀✅
✅ 良い感じ(おすすめ)🌼
.razor:表示、ボタン押下などのイベント、画面の状態(入力欄の文字列とか)Core(クラスライブラリ):入力チェック、計算、ルール(=テストしたいもの)Tests:CoreをxUnitでガンガン検証🧪✨
❌ ありがち地獄(つらい)😵💫
-
@code { ... }の中にifが増える- 計算式が増える
- 例外処理が増える
- 画面更新とルールが絡まり始める → テストしにくい&変更が怖い💥
最新の前提の“確認だけ”してから進むよ🔍✨
- .NET 10 の最新は 10.0.2(2026-01-13) (Microsoft)
- Visual Studio 2026 のリリースノート上でも **2026-01-13 の更新(18.2.0)**が出てるよ (Microsoft Learn)
- C# 14 は .NET 10 SDK / Visual Studio 2026 で試せるよ、って Microsoft Docs にも明記されてるよ (Microsoft Learn)
(ここは“確認”だけね😊 この章の主役は分離!)
ハンズオン:UIを薄くして、ロジックをテストへ引っ越す🏃♀️💨🧪
1) ソリューション構成をこうする📁✨
最低限これでOK!
OshiGoods.Blazor(Blazor Web App)OshiGoods.Core(クラスライブラリ:ルール置き場)OshiGoods.Core.Tests(xUnit:テスト)
コツ:テストしたいものは Core に置く。UIは“薄く”。
2) まずCoreに「ドメイン(グッズ)」を作る🎁
GoodsItem(入力チェックはここへ!)🧷
namespace OshiGoods.Core;
public sealed record GoodsItem
{
public string Name { get; }
public string Category { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }
private GoodsItem(string name, string category, decimal unitPrice, int quantity)
{
Name = name;
Category = category;
UnitPrice = unitPrice;
Quantity = quantity;
}
public static GoodsItem Create(string name, string category, decimal unitPrice, int quantity)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required.", nameof(name));
if (string.IsNullOrWhiteSpace(category))
throw new ArgumentException("Category is required.", nameof(category));
if (unitPrice < 0)
throw new ArgumentOutOfRangeException(nameof(unitPrice), "UnitPrice must be >= 0.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be > 0.");
return new GoodsItem(name.Trim(), category.Trim(), unitPrice, quantity);
}
public decimal LineTotal => UnitPrice * Quantity;
}
🌟 ここが大事! 入力チェック(名前必須、単価マイナス禁止、数量0禁止)を UIじゃなくCoreに入れたよね😊✅ → だから テストで守れる!
3) 次にCoreへ「集計ロジック」を作る📊✨
namespace OshiGoods.Core;
public sealed record GoodsSummary(int TotalQuantity, decimal TotalPrice);
public static class GoodsSummaryCalculator
{
public static GoodsSummary Calculate(IEnumerable<GoodsItem> items)
{
if (items is null) throw new ArgumentNullException(nameof(items));
int totalQty = 0;
decimal totalPrice = 0m;
foreach (var item in items)
{
totalQty += item.Quantity;
totalPrice += item.LineTotal;
}
return new GoodsSummary(totalQty, totalPrice);
}
}
4) ここでTDD!テストを先に書くよ🧪🚦
OshiGoods.Core.Tests にテストを書く(xUnit)✨
using OshiGoods.Core;
using Xunit;
public class GoodsItemTests
{
[Fact]
public void Create_ValidInput_CreatesItem()
{
var item = GoodsItem.Create("アクスタ", "グッズ", 1200m, 2);
Assert.Equal("アクスタ", item.Name);
Assert.Equal("グッズ", item.Category);
Assert.Equal(1200m, item.UnitPrice);
Assert.Equal(2, item.Quantity);
Assert.Equal(2400m, item.LineTotal);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Create_EmptyName_Throws(string name)
{
Assert.Throws<ArgumentException>(() => GoodsItem.Create(name, "グッズ", 100m, 1));
}
[Fact]
public void Create_NegativePrice_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => GoodsItem.Create("アクスタ", "グッズ", -1m, 1));
}
[Fact]
public void Create_ZeroQuantity_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => GoodsItem.Create("アクスタ", "グッズ", 100m, 0));
}
}
using OshiGoods.Core;
using Xunit;
public class GoodsSummaryCalculatorTests
{
[Fact]
public void Calculate_Empty_ReturnsZero()
{
var summary = GoodsSummaryCalculator.Calculate(Array.Empty<GoodsItem>());
Assert.Equal(0, summary.TotalQuantity);
Assert.Equal(0m, summary.TotalPrice);
}
[Fact]
public void Calculate_MultipleItems_SumsCorrectly()
{
var items = new[]
{
GoodsItem.Create("アクスタ", "グッズ", 1200m, 2), // 2400
GoodsItem.Create("缶バッジ", "グッズ", 500m, 3), // 1500
};
var summary = GoodsSummaryCalculator.Calculate(items);
Assert.Equal(5, summary.TotalQuantity);
Assert.Equal(3900m, summary.TotalPrice);
}
}
🎉 これで、UIなしで仕様が守れるようになった! (しかも速い⚡️)
5) 最後にBlazor UIを“薄く”作る🎨🪶
OshiGoods.Blazor のページ例(Pages/Goods.razor)
※UIは「入力→Coreで生成→一覧に足す→Coreで集計」だけ!
@page "/goods"
@using OshiGoods.Core
<h3>推し活グッズ管理(ミニ)🎀📦</h3>
<div style="display:grid; gap:8px; max-width:520px;">
<input placeholder="名前(例:アクスタ)" @bind="name" />
<input placeholder="カテゴリ(例:グッズ)" @bind="category" />
<input placeholder="単価(例:1200)" @bind="unitPriceText" />
<input placeholder="数量(例:2)" @bind="quantityText" />
<button @onclick="Add">追加する➕✨</button>
@if (!string.IsNullOrWhiteSpace(error))
{
<div style="color:red;">⚠️ @error</div>
}
</div>
<hr />
<h4>一覧🧾</h4>
<ul>
@foreach (var item in items)
{
<li>@item.Name(@item.Category): @item.UnitPrice 円 × @item.Quantity 個 = @item.LineTotal 円</li>
}
</ul>
@{
var summary = GoodsSummaryCalculator.Calculate(items);
}
<h4>集計📊</h4>
<p>合計点数:@summary.TotalQuantity 個</p>
<p>合計金額:@summary.TotalPrice 円</p>
@code {
private string name = "";
private string category = "";
private string unitPriceText = "";
private string quantityText = "";
private string error = "";
private readonly List<GoodsItem> items = new();
private void Add()
{
error = "";
try
{
if (!decimal.TryParse(unitPriceText, out var unitPrice))
throw new ArgumentException("単価は数字で入れてね🥺");
if (!int.TryParse(quantityText, out var qty))
throw new ArgumentException("数量は整数で入れてね🥺");
// ✅ ここが主役:入力チェックはUIじゃなくCoreに寄せる
var item = GoodsItem.Create(name, category, unitPrice, qty);
items.Add(item);
// 入力欄クリア(UI都合)
name = category = unitPriceText = quantityText = "";
}
catch (Exception ex)
{
error = ex.Message;
}
}
}
ね、UIが薄いでしょ?😊✨
- ルール:
GoodsItem.Create/GoodsSummaryCalculator(Core) - UI:入力して押して表示するだけ🎨
この章の“分離ルール”まとめ(超だいじ)🧠✅
✅ UI(.razor)に残していいもの🙆♀️
- 入力欄の文字列
- ボタン押下やイベント
- 表示の切り替え(エラーメッセージ出す等)
- 画面の状態(一覧を保持、選択中のIDなど)
✅ Coreへ追い出すもの🏃♀️💨
- 入力の妥当性(必須、範囲、形式)
- 計算(合計、割引、上限)
- 条件分岐のルール(カテゴリ別、在庫の扱い等)
- 仕様として守りたいもの全部🧪✨
AI(Copilot/Codex)使い方:この章の勝ちパターン🤖🏆
AIは便利だけど、UIにロジックをベタ貼りしがち😇 だからお願いの仕方を固定しよ〜!
- 「この仕様を Core(UIなし) で表現するとしたら、どんなクラス?」🤖✨
- 「xUnitで 境界値 を含むテストケースを列挙して🧪」
- 「このCreateメソッド、例外の種類はこれでいい?改善案ある?」🧯
- 「UI側は薄くしたい。
@codeから追い出す候補を指摘して👀」
最後の採用条件はいつもこれ👇 ✅ テストが通る + ✅ 意図どおり + ✅ UIが薄い
よくある落とし穴(先に潰す)🕳️💥
- ❌
@codeに計算式が増えていく(その瞬間、Coreへ引っ越し!) - ❌ 入力チェックをUIにだけ書く(テスト不能になりがち)
- ❌ 例外を握りつぶして無言(最低でもメッセージは出そ)
- ❌ 「とりあえず動いた!」で止める(テストが仕様だよ〜🧪)
ミニ課題(10〜20分)⏱️🎀
- ✅ 「カテゴリが空ならエラー」を
GoodsItem.Createに追加してテストを書く🧪 - ✅ 合計金額が 10,000円超えたら “買いすぎ警告” をUIに出す(※警告条件はCoreに置いてもOK)⚠️
- ✅
GoodsSummaryに「平均単価」を追加してテストを書く📊
もし次へ進めるなら、**第48章(bUnitでコンポーネントテスト)**に入る前に、 この章の状態(UI薄い&Coreがテストされてる)を作れてるとめちゃ強いよ〜😊🧪✨