mcpField
S
snapshot-testing

snapshot-testing

github.com/wshaddixテスト・品質8671 回インストール35 週間前に更新
リポジトリ

snapshot-testing について

このスキルは、.NETのテストプロジェクトでVerifyを使うスナップショットテストを説明します。出力の保存、承認済みベースラインのレビュー、動的データへの対処を、オブジェクトやHTTPレスポンス、メール、API表面を例に案内します。

主な機能

  1. 01出力を保存し承認済みスナップショットと比較
  2. 02動的な値をスクラブし変動メンバーを除外
  3. 03オブジェクト、HTTPレスポンス、レンダリング済みメールを検証
  4. 04スナップショットで公開APIの変更を承認
  5. 05スナップショットとパラメーター化テストを整理
  6. 06Verify CLIでレビュー済み変更を受け入れ

活用シーン

  1. 01シリアライズ出力の意図しない変更を検出
  2. 02HTMLメールやレポートの描画変更をレビュー
  3. 03公開APIの変更から破壊的差分を把握
  4. 04HTTPレスポンスの本文とヘッダーを比較

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill snapshot-testing

インストール後、Claude Code で/snapshot-testingを入力して読み込まれたか確認します。

詳細

Verify によるスナップショットテスト

このスキルを使う場面

次のような場合に、スナップショットテストを使用します。

  • レンダリングされた出力(HTML メール、レポート、生成されたコード)を検証する場合
  • 破壊的変更の検出に向けて、公開 API の範囲を承認する場合
  • HTTP レスポンスの本文とヘッダーをテストする場合
  • シリアル化の出力を検証する場合
  • 複雑なオブジェクトに意図しない変更がないか確認する場合

スナップショットテストとは?

スナップショットテストでは、出力を取得し、人間が承認したベースラインと比較します。

  1. 初回実行:テストで実際の出力を含む .received. ファイルを生成します
  2. 人によるレビュー:開発者が内容を承認し、.verified. ファイルを作成します
  3. 以降の実行:テストで出力を .verified. ファイルと比較します
  4. 変更の検出:テストが失敗し、差分ツールにレビュー用の差分が表示されます

これにより、意図しない変更を検出しつつ、明示的な承認によって意図的な変更を許可できます。


セットアップ

パッケージ
<PackageReference Include="Verify.Xunit" Version="20.*" />
<PackageReference Include="Verify.Http" Version="6.*" />
モジュール初期化子

Verify では、各テストアセンブリにつき 1 回の初期化が必要です。

// ModuleInitializer.cs
using System.Runtime.CompilerServices;

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init()
    {
        // Use source-file-relative paths for verified files
        VerifyBase.UseProjectRelativeDirectory("Snapshots");

        // Scrub common non-deterministic types globally
        VerifierSettings.ScrubMembersWithType<DateTime>();
        VerifierSettings.ScrubMembersWithType<DateTimeOffset>();
        VerifierSettings.ScrubMembersWithType<Guid>();

        // In CI, fail instead of launching diff tool
        if (Environment.GetEnvironmentVariable("CI") is not null)
        {
            DiffRunner.Disabled = true;
        }
    }
}
ソース管理

.gitignore に追加します。

# Verify received files (test failures)
*.received.*

.gitattributes に追加します。

*.verified.txt text eol=lf
*.verified.xml text eol=lf
*.verified.json text eol=lf
*.verified.html text eol=lf

基本的な使い方

オブジェクトの検証
[UsesVerify]
public class OrderSerializationTests
{
    [Fact]
    public Task Serialize_CompletedOrder_MatchesSnapshot()
    {
        var order = new Order
        {
            Id = 1,
            CustomerId = "cust-123",
            Status = OrderStatus.Completed,
            Items =
            [
                new OrderItem("SKU-001", Quantity: 2, UnitPrice: 29.99m),
                new OrderItem("SKU-002", Quantity: 1, UnitPrice: 49.99m)
            ],
            Total = 109.97m
        };

        return Verify(order);
    }
}

OrderSerializationTests.Serialize_CompletedOrder_MatchesSnapshot.verified.txt が作成されます。

文字列とストリームの検証
[Fact]
public Task RenderInvoice_MatchesExpectedHtml()
{
    var html = invoiceRenderer.Render(order);
    return Verify(html, extension: "html");
}

[Fact]
public Task ExportReport_MatchesExpectedXml()
{
    var stream = reportExporter.Export(report);
    return Verify(stream, extension: "xml");
}

スクラブとフィルタリング

日付、GUID、自動採番された ID などの非決定的な値は、テストの実行ごとに変わります。スクラブによって、それらを安定したプレースホルダーに置き換えます。

組み込みのスクラバー
[Fact]
public Task CreateOrder_ScrubsNonDeterministicValues()
{
    var order = new Order
    {
        Id = Guid.NewGuid(),          // Scrubbed to Guid_1
        CreatedAt = DateTime.UtcNow,  // Scrubbed to DateTime_1
        TrackingNumber = Guid.NewGuid().ToString() // Scrubbed to Guid_2
    };

    return Verify(order);
}

次のような安定した出力が生成されます。

{
  Id: Guid_1,
  CreatedAt: DateTime_1,
  TrackingNumber: Guid_2
}
カスタムスクラバー
[Fact]
public Task AuditLog_ScrubsTimestampsAndMachineNames()
{
    var log = auditService.GetRecentEntries();

    return Verify(log)
        .ScrubLinesWithReplace(line =>
            Regex.Replace(line, @"Machine:\s+\w+", "Machine: Scrubbed"))
        .ScrubLinesContaining("CorrelationId:");
}
メンバーの無視
[Fact]
public Task OrderSnapshot_IgnoresVolatileFields()
{
    var order = orderService.CreateOrder(request);

    return Verify(order)
        .IgnoreMember("CreatedAt")
        .IgnoreMember("UpdatedAt")
        .IgnoreMember("ETag");
}
インライン値のスクラブ
[Fact]
public Task ApiResponse_ScrubsTokens()
{
    var response = authService.GenerateTokenResponse(user);

    return Verify(response)
        .ScrubLinesWithReplace(line =>
            Regex.Replace(line, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer {scrubbed}"));
}

HTTP レスポンスの検証

HTTP レスポンス全体
[UsesVerify]
public class OrdersApiSnapshotTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersApiSnapshotTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetOrders_ResponseMatchesSnapshot()
    {
        var response = await _client.GetAsync("/api/orders");
        await Verify(response);
    }
}
レスポンスの特定部分
[Fact]
public async Task CreateOrder_VerifyResponseBody()
{
    var response = await _client.PostAsJsonAsync("/api/orders", request);
    var body = await response.Content.ReadFromJsonAsync<OrderDto>();

    await Verify(body)
        .IgnoreMember("Id")
        .IgnoreMember("CreatedAt");
}

レンダリングされたメールの検証

レンダリングされた HTML 出力を検証して、メールテンプレートをスナップショットテストします。

[UsesVerify]
public class EmailTemplateTests
{
    private readonly EmailRenderer _renderer = new();

    [Fact]
    public Task OrderConfirmation_MatchesSnapshot()
    {
        var model = new OrderConfirmationModel
        {
            CustomerName = "Alice Johnson",
            OrderNumber = "ORD-001",
            Items =
            [
                new("Widget A", Quantity: 2, Price: 29.99m),
                new("Widget B", Quantity: 1, Price: 49.99m)
            ],
            Total = 109.97m
        };

        var html = _renderer.RenderOrderConfirmation(model);
        return Verify(html, extension: "html");
    }

    [Fact]
    public Task PasswordReset_MatchesSnapshot()
    {
        var model = new PasswordResetModel
        {
            UserName = "alice",
            ResetLink = "https://example.com/reset?token=test-token"
        };

        var html = _renderer.RenderPasswordReset(model);

        return Verify(html, extension: "html")
            .ScrubLinesWithReplace(line =>
                Regex.Replace(line, @"token=[^""&]+", "token={scrubbed}"));
    }
}

メールテストでのメリット:

  • CSS やレイアウトのリグレッションを検出します
  • テンプレート変数の不具合を検出します
  • 差分ツールで視覚的にレビューできます
  • バージョン管理でメールの変更を追跡できます

API の範囲の承認

公開 API への意図しない破壊的変更を防ぎます。

[Fact]
public Task ApprovePublicApi()
{
    var assembly = typeof(MyLibrary.PublicClass).Assembly;

    var publicApi = assembly.GetExportedTypes()
        .OrderBy(t => t.FullName)
        .Select(t => new
        {
            Type = t.FullName,
            Members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
                .Where(m => m.DeclaringType == t)
                .OrderBy(m => m.Name)
                .Select(m => m.ToString())
        });

    return Verify(publicApi);
}

または、専用の ApiApprover パッケージを使用します。

<PackageReference Include="PublicApiGenerator" />
<PackageReference Include="Verify.Xunit" />
[Fact]
public Task ApproveApi()
{
    var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
    return Verify(api);
}

API の範囲全体を含む .verified.txt が作成されます。変更にはすべて明示的な承認が必要です。


カスタムコンバーター

検証時に特定の型をどのようにシリアル化するかを制御します。

public class MoneyConverter : WriteOnlyJsonConverter<Money>
{
    public override void Write(VerifyJsonWriter writer, Money value)
    {
        writer.WriteStartObject();
        writer.WriteMember(value, value.Amount, "Amount");
        writer.WriteMember(value, value.Currency.Code, "Currency");
        writer.WriteEndObject();
    }
}

public class AddressConverter : WriteOnlyJsonConverter<Address>
{
    public override void Write(VerifyJsonWriter writer, Address value)
    {
        // Single-line summary for compact snapshots
        writer.WriteValue($"{value.Street}, {value.City}, {value.State} {value.Zip}");
    }
}

モジュール初期化子に登録します。

[ModuleInitializer]
public static void Init()
{
    VerifierSettings.AddExtraSettings(settings =>
    {
        settings.Converters.Add(new MoneyConverter());
        settings.Converters.Add(new AddressConverter());
    });
}

スナップショットファイルの構成

専用ディレクトリ

検証済みファイルを専用のディレクトリに移動します。

[ModuleInitializer]
public static void Init()
{
    Verifier.DerivePathInfo(
        (sourceFile, projectDirectory, type, method) =>
            new PathInfo(
                directory: Path.Combine(projectDirectory, "Snapshots"),
                typeName: type.Name,
                methodName: method.Name));
}
パラメーター化テスト

[Theory] テストでは、UseParameters() を使用します。

[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("ja-JP")]
public Task FormatCurrency_ByLocale_MatchesSnapshot(string locale)
{
    var formatted = currencyFormatter.Format(1234.56m, locale);
    return Verify(formatted).UseParameters(locale);
}

次のように別々のファイルが作成されます。

FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=en-US.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=de-DE.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=ja-JP.verified.txt

ワークフロー:変更の承認

差分ツールとの連携
[ModuleInitializer]
public static void Init()
{
    // Verify auto-detects installed diff tools
    // Override if needed:
    DiffTools.UseOrder(DiffTool.VisualStudioCode, DiffTool.Rider);
}
CLI での承認
# Install the Verify CLI tool (one-time)
dotnet tool install -g verify.tool

# Accept all received files
verify accept

# Accept for a specific test project
verify accept --project tests/MyApp.Tests
CI の動作
env:
  DiffEngine_Disabled: true

CI/CD との連携

GitHub Actions
- name: Run tests
  run: dotnet test
  env:
    CI: true

- name: Upload snapshots on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: snapshots
    path: |
      **/*.received.*
      **/*.verified.*

スナップショットテストを使う場面

シナリオスナップショットテストを使うか理由
レンダリングされた HTML/メールはい見た目のリグレッションを検出します
API の範囲はい意図しない破壊的変更を防ぎます
シリアル化の出力はいワイヤー形式を検証します
複雑なオブジェクトグラフはい手動でアサーションを書くより簡単です
単純な値の確認いいえ通常のアサーションを使います
ビジネスロジックいいえ明示的なアサーションを使います
パフォーマンステストいいえベンチマークを使います

主な原則

  • 単純な値ではなく、複雑な出力をスナップショットテストします。 期待値を単一の Assert.Equal で表せる場合は、そちらを優先します。
  • 非決定的な値はすべてスクラブします。 日付、GUID、タイムスタンプをスクラブする必要があります。
  • .verified.txt ファイルをソース管理にコミットします。 .received.txt ファイルは追加しないでください。
  • スナップショットの差分を慎重にレビューします。 レビューせずに承認すると、リグレッションを知らないうちに承認してしまう可能性があります。
  • ドメインで読みやすい表現にはカスタムコンバーターを使います。 既定のシリアル化結果は冗長になることがあります。
  • スナップショットの対象を絞ります。 変動しやすいフィールドを除外するには、IgnoreMember を使います。

ベストプラクティス

すること
// Use descriptive test names - they become file names
[Fact]
public Task UserRegistration_WithValidData_ReturnsConfirmation()

// Scrub dynamic values consistently
VerifierSettings.ScrubMembersWithType<Guid>();

// Use extension parameter for non-text content
await Verify(html, extension: "html");

// Keep verified files in source control
git add *.verified.*
しないこと
// Don't verify random/dynamic data without scrubbing
var order = new Order { Id = Guid.NewGuid() };  // Fails every run!
await Verify(order);

// Don't commit .received files
git add *.received.*  // Wrong!

// Don't use for simple assertions
await Verify(result.Count);  // Just use Assert.Equal(5, result.Count)

エージェントでの注意点

  1. テストクラスに [UsesVerify] を付け忘れないでください。 付けないと、実行時に Verify() の呼び出しが失敗します。
  2. .received.txt ファイルをコミットしないでください。 .gitignore に *.received.* を追加してください。
  3. パラメーター化されたテストで UseParameters() を省略しないでください。 すべての組み合わせが同じファイルに書き込まれます。
  4. 契約の一部である値をスクラブしないでください。 真に非決定的な値のみスクラブしてください。
  5. 急速に変化している API にスナップショットテストを使用しないでください。 API が安定するまで待ってください。
  6. 異なるテストフレームワーク間で Verify パッケージのバージョンをハードコードしないでください。 バージョン範囲(20.*)を使用してください。

参考資料

コメント