| name | unit-test-skimdown |
| description | SkimDown for Windows の Application / Domain 単体テストを追加・改修・レビューする時に使う。トリガー語の例 - "テストを追加", "MainPageViewModel のテスト", "TestHelpers", "Stub / Fake / Recording / InMemory", "async void のテスト", "Sleep を使いたい", "テスト用に IFoo を差し替えたい", "MarkdownScanner のテスト"。テスト戦略の根拠は ADR-0003 を参照。 |
Unit Test for SkimDown for Windows
どんな時に使うか
- 新しいテストを
SkimDownForWindows.Tests に追加する時
- 新しい
I<X> 抽象を足したのでテスト用ダブルを書く時
MainPageViewModel 等の async void ハンドラーをテストする時
- 既存テストを直す時に「これは仕様なのか現挙動の golden test なのか」を区別する時
- PR レビューで「モックライブラリ入れたい」と提案が出た時
判断の正当性 (なぜこのテスト戦略になっているか) は ADR-0003 を参照。skill はそこで決まったことを実装時に思い出すための圧縮表。
テストプロジェクトの前提
| 項目 | 内容 |
|---|
| プロジェクト | src/SkimDownForWindows.Tests/SkimDownForWindows.Tests.csproj |
| TFM | net10.0 (プラットフォーム中立、Windows TFM ではない) |
| テストフレームワーク | MSTest v4 |
| 並列実行 | MethodLevel Parallelize (MSTestSettings.cs で設定) |
| 参照プロジェクト | SkimDownForWindows.Domain / SkimDownForWindows.Application のみ |
| 参照しない プロジェクト | SkimDownForWindows.Infrastructure (Windows TFM のため) |
| Implicit usings | Microsoft.VisualStudio.TestTools.UnitTesting を <Using> で取り込み済み |
Tests から Infrastructure を ProjectReference してはいけない。IFileSystem が必要なテストは TestHelpers/RealFileSystem を使う。
テストダブルの基本ポリシー
- モックライブラリ (Moq / NSubstitute / FakeItEasy) は採用しない
- 各
I<X> 抽象に対する 手書き in-memory 実装 を src/SkimDownForWindows.Tests/TestHelpers/ に置く
- 1 ファイル = 1 ダブル、
internal sealed
- 内部名前空間 =
SkimDownForWindows.Tests.TestHelpers
理由は ADR-0003 § 1 参照 (リファクタの検出力、AOT 親和性、再利用効率)。
命名規約 (Prefix)
意図に応じて prefix を使い分ける。
| Prefix | 用途 | 内部状態 | 例 |
|---|
Stub<X> | 入力に対して固定値を返すだけ | なし or 設定値のみ | StubMarkdownFileReader, StubSystemThemeProvider |
Fake<X> | 内部状態を持ち、テストから状態を進めたり外部イベントを発火したりできる | あり | FakeFolderWatcher (RaiseTreeMayHaveChanged 等) |
Recording<X> | 副作用を持つ I/F 呼び出しを記録するだけ | 呼び出し履歴のみ | RecordingShellService, RecordingClipboardService |
InMemory<X> | 永続化や読み書きを伴う I/F のメモリ実装 | データ + 呼び出し履歴 + waiter | InMemorySettingsRepository |
Real<X> | 実体に近い実装をテスト用にラップしたもの (Infrastructure 不参照の代替) | 実 OS リソース | RealFileSystem |
既存ダブル一覧
場所: src/SkimDownForWindows.Tests/TestHelpers/
| ダブル | 対応する抽象 | 主要機能 |
|---|
InMemorySettingsRepository | ISettingsRepository | Current で AppSettings 保持、SaveAsync / FlushSync 回数記録、WaitForSaveCountAsync(int, TimeSpan?) で非同期完了待ち |
FakeFolderWatcher | IFolderWatcher | Watch / Stop / Dispose 回数記録、LastWatchedPath、RaiseTreeMayHaveChanged() / RaiseFileContentChanged(path) でテスト側からイベント発火 |
StubMarkdownFileReader | IMarkdownFileReader | SetContent(path, content) で応答を仕込み、未登録パスは DefaultContent を返す。ReadCalls で履歴取得 |
StubSystemThemeProvider | ISystemThemeProvider | System プロパティで OS テーマの戻り値を切り替え |
RecordingClipboardService | IClipboardService | Writes / LastWrite で呼び出し履歴を保持 |
RecordingShellService | IShellService | RevealedPaths / LastRevealedPath で呼び出し履歴を保持 |
RealFileSystem | IFileSystem | 本物の System.IO.* を使う。例外は吸収して false / 空列挙を返す (LocalFileSystem と同じ quiet behavior) |
新規ダブルを追加する 4 ステップ
Step 1: 命名 prefix を選ぶ
- 固定値を返すだけ →
Stub<X>
- イベント発火させたい →
Fake<X>
- 呼び出し履歴だけ取りたい →
Recording<X>
- データの読み書きを伴う →
InMemory<X>
- 本物の OS 機能を使う必要がある →
Real<X> (最後の手段)
Step 2: ファイルを TestHelpers/ 配下に作成
using SkimDownForWindows.Application.Abstractions;
namespace SkimDownForWindows.Tests.TestHelpers;
internal sealed class InMemoryXxx : IXxx
{
private readonly object _gate = new();
...
}
internal sealed
- スレッドセーフ性を
lock で確保 (MethodLevel Parallelize と衝突しないように)
- 副作用観測 API (
Writes, WaitForXxxAsync) を提供
Step 3: 副作用観測 API を作る (必要なら)
- 単純な呼び出し履歴 →
List<T> Calls プロパティ
async 副作用の完了待ち → WaitForXxxCountAsync パターン (次節参照)
- 直近値だけ欲しい →
LastXxx プロパティ
Step 4: テストで利用
[TestInitialize]
public void Setup()
{
_xxx = new InMemoryXxx();
...
}
async void / fire-and-forget の完了同期
MainPageViewModel には次のような fire-and-forget ハンドラーがある:
async void OnTreeMayHaveChanged(...) (IFolderWatcher.TreeMayHaveChanged 購読)
_ = SelectAndLoadAsync(...) (UI コマンド経由)
- これらが内部で
ISettingsRepository.SaveAsync() を呼ぶ
テスト側はこれを await できないので 観測点側に完了通知を仕込む。
ルール
- ❌
Thread.Sleep / Task.Delay ベースのポーリングは禁止 (flaky テストの温床)
- ✅ ダブル側に
WaitForXxxCountAsync(int expected, TimeSpan? timeout = null) を実装
InMemorySettingsRepository.WaitForSaveCountAsync の仕組み
public Task SaveAsync()
{
List<TaskCompletionSource<int>> toFire;
int count;
lock (_gate)
{
_saveAsyncCalls++;
count = _saveAsyncCalls;
toFire = new List<TaskCompletionSource<int>>(_waiters);
_waiters.Clear();
}
foreach (var w in toFire) w.TrySetResult(count);
return Task.CompletedTask;
}
public async Task WaitForSaveCountAsync(int expectedCount, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (true)
{
TaskCompletionSource<int> tcs;
lock (_gate)
{
if (_saveAsyncCalls >= expectedCount) return;
tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
_waiters.Add(tcs);
}
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
throw new TimeoutException($"SaveAsync was called {SaveAsyncCalls} times; expected at least {expectedCount}.");
var completed = await Task.WhenAny(tcs.Task, Task.Delay(remaining)).ConfigureAwait(false);
if (completed != tcs.Task)
throw new TimeoutException($"SaveAsync was called {SaveAsyncCalls} times; expected at least {expectedCount}.");
}
}
テスト側での使い方
[TestMethod]
public async Task OpenFolderAsync_PersistsRecentFolder()
{
var vm = CreateViewModel();
await vm.OpenFolderAsync(_root);
await _settings.WaitForSaveCountAsync(1);
Assert.AreEqual(_root, _settings.Current.LastFolderPath);
}
タイムアウト超過時の例外メッセージには実際の呼び出し回数を含める (TimeoutException の Message がテストレポートで読みやすくなる)。
一時ディレクトリのライフサイクル
実ファイルシステムを使うテスト (MarkdownScanner 等) は固有の一時ディレクトリを使う。
[TestClass]
public sealed class MyTests
{
private string _root = null!;
[TestInitialize]
public void Setup()
{
_root = Path.Combine(Path.GetTempPath(), "skim-myname-" + Path.GetRandomFileName());
Directory.CreateDirectory(_root);
}
[TestCleanup]
public void Teardown()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private void Touch(string relativePath, string content = "x")
{
var full = Path.Combine(_root, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content);
}
}
ポイント:
- ディレクトリ名 =
"skim-<area>-" + Path.GetRandomFileName() で衝突回避
[TestInitialize] 作成、[TestCleanup] 削除
- 削除は best-effort (
try { ... } catch { })。Windows のロックで失敗してもテスト結果に影響させない
MethodLevel Parallelize と衝突しない (テストインスタンスごとに別ディレクトリ)
純粋サービスは実体を使う (ハイブリッド方針)
MainPageViewModel のテストで Markdown 解析パイプラインを通すとき、純粋サービスはダブル化せず実体を new する。
private MainPageViewModel CreateViewModel()
{
var scanner = new MarkdownScanner(_fs);
var treeBuilder = new MarkdownTreeBuilder();
var picker = new InitialSelectionPicker();
var linkResolver = new LinkResolver();
return new MainPageViewModel(
_settings, _watcher, _reader, _fs,
_shell, _clipboard, _theme,
scanner, treeBuilder, picker, linkResolver);
}
理由:
- これらは副作用ゼロ or
IFileSystem のみに依存する純粋ロジック
- ダブル化すると「Markdown ファイル → ツリー → 初期選択」の統合挙動の回帰検出力が落ちる
- I/O 抽象だけダブル化すれば十分制御可能
仕様要件 vs 現挙動 (golden test) の命名
- 仕様要件 (SPEC で要求される動作):
- 名前:
Action_State_ExpectedOutcome (例: OpenFolderAsync_WithEmptyFolder_SetsHasFolderTrue)
- 現挙動の固定化 (バグ寄りだが現状そう動く / 意図的だが仕様には書かれていない):
- 名前に
_CurrentBehavior / _Documents...Behavior を含める
- コメントで「仕様で要求されているわけではない」旨を明記
- 仕様変更を伴う修正は別 PR + 必要なら新 ADR
[TestMethod]
public void Scan_HiddenDotFolder_IsExcluded_CurrentBehavior()
{
...
}
テスト対象外 (現フェーズ)
- Infrastructure 層の I/O ラッパー (
LocalFileSystem, JsonSettingsRepository 等)
- Tests が
net10.0 で Infrastructure (net10.0-windows*) を参照しないため
- 必要になったら別 TFM の
SkimDownForWindows.Infrastructure.Tests プロジェクトを新設
- Presentation 層 (
MainWindow, MainPage, MarkdownPreview)
- WinUI 3 が UI スレッド前提のため
net10.0 テストでは扱えない
- Domain 層 の純粋 enum / record
- 値そのもののテストは過剰。JSON ラウンドトリップ等で間接的に保持を確認
やってはいけないこと (Anti-patterns)
❌ モックライブラリの導入
<PackageReference Include="Moq" Version="..." />
理由は ADR-0003 § 検討した代替案 A 参照。導入したくなった場合は ADR を新規作成して議論する。
❌ Thread.Sleep / Task.Delay で非同期を待つ
await Task.Delay(500);
Assert.AreEqual(1, _settings.SaveAsyncCalls);
await _settings.WaitForSaveCountAsync(1);
❌ Infrastructure を ProjectReference
<ProjectReference Include="..\SkimDownForWindows.Infrastructure\..." />
var fs = new RealFileSystem();
❌ 共有可変状態を持つテストクラス
private static InMemorySettingsRepository _sharedSettings = new();
private InMemorySettingsRepository _settings = null!;
[TestInitialize] public void Setup() => _settings = new InMemorySettingsRepository();
❌ 純粋サービスをダブル化
public interface IMarkdownScanner { ... }
internal sealed class StubMarkdownScanner : IMarkdownScanner { ... }
var scanner = new MarkdownScanner(_fs);
❌ ダブルを TestHelpers/ 外に置く
- どのテストファイルが何を差し替えているかが追えなくなる
- 命名規約も崩れる
チェックリスト (PR 時セルフレビュー)
ビルド・テストの実行
# 単体テスト (net10.0)
dotnet test src\SkimDownForWindows.Tests
# 上流 samples を使う統合テスト (オプトイン)
$env:SKIM_SAMPLES_PATH = "C:\path\to\SkimDown\samples"
dotnet test src\SkimDownForWindows.Tests
参考リンク