Skip to content

Latest commit

 

History

History
1230 lines (973 loc) · 35.6 KB

File metadata and controls

1230 lines (973 loc) · 35.6 KB

Verify.Http

Discussions Build status NuGet Status

Extends Verify to allow verification of Http bits.

See Milestones for release notes.

Sponsors

Entity Framework Extensions

Entity Framework Extensions is a major sponsor and is proud to contribute to the development this project.

Entity Framework Extensions

Developed using JetBrains IDEs

JetBrains logo.

NuGet

Initialize

Call VerifierSettings.InitializePlugins() in a [ModuleInitializer].

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Initialize() =>
        VerifierSettings.InitializePlugins();
}

snippet source | anchor

Or, if order of plugins is important, use VerifyHttp.Initialize() in a [ModuleInitializer].

ScrubHttpTextResponse

[Test]
public async Task ScrubHttpTextResponse()
{
    using var client = new HttpClient();

    using var result = await client.GetAsync("https://httpcan.org/html");

    await Verify(result)
        .ScrubHttpTextResponse(_ => _.Replace("Herman Melville - Moby-Dick", "New title"));
}

snippet source | anchor

Enable Recording

Enable at any point in a test using VerifyTests.Recording.Start().

Converters

Includes converters for the following

  • HttpMethod
  • Uri
  • HttpHeaders
  • HttpContent
  • HttpRequestMessage
  • HttpResponseMessage

For example:

[Test]
public async Task HttpResponse()
{
    using var client = new HttpClient();

    var result = await client.GetAsync("https://httpcan.org/json");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 200 OK,
  Headers: {
    Access-Control-Allow-Credentials: true,
    Alt-Svc: h3=":443",
    cf-cache-status: DYNAMIC,
    Connection: keep-alive,
    Date: DateTime_1,
    Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800},
    Server: cloudflare,
    Vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers
  },
  Content: {
    Headers: {
      Content-Length: 274,
      Content-Type: application/json
    },
    Value: {
      slideshow: {
        author: Yours Truly,
        date: date of publication,
        slides: [
          {
            title: Wake up to WonderWidgets!,
            type: all
          },
          {
            items: [
              Why <em>WonderWidgets</em> are great,
              Who <em>buys</em> WonderWidgets
            ],
            title: Overview,
            type: all
          }
        ],
        title: Sample Slide Show
      }
    }
  }
}

snippet source | anchor

Ignoring Headers

Headers are treated as properties, and hence can be ignored using IgnoreMember:

[Test]
public async Task IgnoreHeader()
{
    using var client = new HttpClient();

    using var result = await client.GetAsync("https://httpcan.org/get");

    await Verify(result)
        .IgnoreMembers(
            "Server",
            "Content-Length",
            "Access-Control-Allow-Credentials");
}

snippet source | anchor

HttpClient recording via Service

For code that does web calls via HttpClient, these calls can be recorded and verified.

Service that does http

Given a class that does some Http calls:

// Resolve a HttpClient. All http calls done at any
// resolved client will be added to `recording.Sends`
public class MyService(HttpClient client)
{
    public Task MethodThatDoesHttp() =>
        // Some code that does some http calls
        client.GetAsync("https://httpcan.org/status/200");
}

snippet source | anchor

Add to IHttpClientBuilder

Http recording can be added to a IHttpClientBuilder:

var collection = new ServiceCollection();
collection.AddScoped<MyService>();
var httpBuilder = collection.AddHttpClient<MyService>();

// Adds a AddHttpClient and adds a RecordingHandler using AddHttpMessageHandler
var recording = httpBuilder.AddRecording();

await using var provider = collection.BuildServiceProvider();

var myService = provider.GetRequiredService<MyService>();

await myService.MethodThatDoesHttp();

await Verify(recording.Sends)
    .IgnoreMember("Date");

snippet source | anchor

Add globally

Http can also be added globally IHttpClientBuilder:

var collection = new ServiceCollection();
collection.AddScoped<MyService>();

// Adds a AddHttpClient and adds a RecordingHandler using AddHttpMessageHandler
var (builder, recording) = collection.AddRecordingHttpClient();

await using var provider = collection.BuildServiceProvider();

var myService = provider.GetRequiredService<MyService>();

await myService.MethodThatDoesHttp();

await Verify(recording.Sends)
    .IgnoreMember("Date");

snippet source | anchor

Resulting verified file

[
  {
    RequestUri: https://httpcan.org/status/200,
    RequestMethod: GET,
    ResponseStatus: OK 200,
    ResponseHeaders: {
      Access-Control-Allow-Credentials: true,
      Alt-Svc: h3=":443",
      cf-cache-status: DYNAMIC,
      Connection: keep-alive,
      Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800},
      Server: cloudflare,
      Vary: Origin|Access-Control-Request-Method|Access-Control-Request-Headers
    },
    ResponseContent: {"status":200}
  }
]

snippet source | anchor

There a Pause/Resume semantics:

var collection = new ServiceCollection();
collection.AddScoped<MyService>();
var httpBuilder = collection.AddHttpClient<MyService>();

// Adds a AddHttpClient and adds a RecordingHandler using AddHttpMessageHandler
var recording = httpBuilder.AddRecording();

await using var provider = collection.BuildServiceProvider();

var myService = provider.GetRequiredService<MyService>();

// Recording is enabled by default. So Pause to stop recording
recording.Pause();
await myService.MethodThatDoesHttp();

// Resume recording
recording.Resume();
await myService.MethodThatDoesHttp();

await Verify(recording.Sends)
    .ScrubInlineDateTimes("R");

snippet source | anchor

If the AddRecordingHttpClient helper method does not meet requirements, the RecordingHandler can be explicitly added:

var collection = new ServiceCollection();

var builder = collection.AddHttpClient("name");

// Change to not recording at startup
var recording = new RecordingHandler(recording: false);

builder.AddHttpMessageHandler(() => recording);

await using var provider = collection.BuildServiceProvider();

var factory = provider.GetRequiredService<IHttpClientFactory>();

var client = factory.CreateClient("name");

await client.GetAsync("https://httpcan.org/html");

recording.Resume();
await client.GetAsync("https://httpcan.org/json");

await Verify(recording.Sends)
    .ScrubInlineDateTimes("R");

snippet source | anchor

Http Recording via listener

Http Recording allows, when a method is being tested, for any http requests made as part of that method call to be recorded and verified.

Usage

Call HttpRecording.StartRecording(); before the method being tested is called.

The perform the verification as usual:

[Test]
public async Task TestHttpRecording()
{
    Recording.Start();

    var sizeOfResponse = await MethodThatDoesHttpCalls();

    await Verify(
            new
            {
                sizeOfResponse
            })
        .IgnoreMembers("Expires", "Date")
        .ScrubLinesContaining("\"version\"");
}

static async Task<int> MethodThatDoesHttpCalls()
{
    using var client = new HttpClient();

    var jsonResult = await client.GetStringAsync("https://httpcan.org/json");
    var ymlResult = await client.GetStringAsync("https://httpcan.org/xml");
    return jsonResult.Length + ymlResult.Length;
}

snippet source | anchor

Resulting verified file

The requests/response pairs will be appended to the verified file.

{
  target: {
    sizeOfResponse: 792
  },
  httpCall: [
    {
      Status: Created,
      Request: {
        Uri: https://httpcan.org/json,
        Headers: {}
      },
      Response: {
        Status: 200 OK,
        Headers: {
          Access-Control-Allow-Credentials: true,
          Alt-Svc: h3=":443",
          cf-cache-status: DYNAMIC,
          Connection: keep-alive,
          Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800},
          Server: cloudflare,
          Vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers
        },
        ContentHeaders: {
          Content-Length: 274,
          Content-Type: application/json
        },
        ContentStringParsed: {
          slideshow: {
            author: Yours Truly,
            date: date of publication,
            slides: [
              {
                title: Wake up to WonderWidgets!,
                type: all
              },
              {
                items: [
                  Why <em>WonderWidgets</em> are great,
                  Who <em>buys</em> WonderWidgets
                ],
                title: Overview,
                type: all
              }
            ],
            title: Sample Slide Show
          }
        }
      }
    },
    {
      Status: Created,
      Request: {
        Uri: https://httpcan.org/xml,
        Headers: {}
      },
      Response: {
        Status: 200 OK,
        Headers: {
          Access-Control-Allow-Credentials: true,
          Alt-Svc: h3=":443",
          cf-cache-status: DYNAMIC,
          Connection: keep-alive,
          Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800},
          Server: cloudflare,
          Vary: Origin,Access-Control-Request-Method,Access-Control-Request-Headers
        },
        ContentHeaders: {
          Content-Length: 518,
          Content-Type: application/xml
        },
        ContentStringParsed: {
          ?xml: {
            @version: 1.0,
            @encoding: us-ascii
          }/*  A SAMPLE set of slides  */,
          slideshow: {
            @title: Sample Slide Show,
            @date: Date of publication,
            @author: Yours Truly,
            #comment: [],
            slide: [
              {
                @type: all,
                title: Wake up to WonderWidgets!
              },
              {
                @type: all,
                title: Overview,
                item: [
                  {
                    #text: [
                      Why ,
                       are great
                    ],
                    em: WonderWidgets
                  },
                  null,
                  {
                    #text: [
                      Who ,
                       WonderWidgets
                    ],
                    em: buys
                  }
                ]
              }
            ]
          }
        }
      }
    }
  ]
}

snippet source | anchor

Explicit Usage

The above usage results in the http calls being automatically added snapshot file. Calls can also be explicitly read and recorded using HttpRecording.FinishRecording(). This enables:

  • Filtering what http calls are included in the snapshot.
  • Only verifying a subset of information for each http call.
  • Performing additional asserts on http calls.

For example:

[Test]
public async Task TestHttpRecordingExplicit()
{
    Recording.Start();

    var responseSize = await MethodThatDoesHttpCalls();

    var httpCalls = Recording.Stop()
        .Select(_ => _.Data)
        .OfType<HttpCall>()
        .ToList();

    // Ensure all calls finished in under 5 seconds
    var threshold = TimeSpan.FromSeconds(5);
    foreach (var call in httpCalls)
    {
        IsTrue(call.Duration < threshold);
    }

    await Verify(
        new
        {
            responseSize,
            // Only use the Uri in the snapshot
            httpCalls = httpCalls.Select(_ => _.Request.Uri)
        });
}

snippet source | anchor

Resulting verified file

{
  responseSize: 792,
  httpCalls: [
    https://httpcan.org/json,
    https://httpcan.org/xml
  ]
}

snippet source | anchor

Mocking

MockHttpClient allows mocking of http responses and recording of http requests.

Default Response

The default behavior is to return a HttpResponseMessage with a status code of 200 OK.

[Test]
public async Task DefaultContent()
{
    using var client = new MockHttpClient();

    var result = await client.GetAsync("https://fake/get");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 200 OK
}

snippet source | anchor

Verifying Calls

Request-Response pairs can be verified using MockHttpClient.Calls

[Test]
public async Task ExplicitContent()
{
    using var client = new MockHttpClient(
        content: """{ "a": "b" }""",
        mediaType: "application/json");

    var result = await client.GetAsync("https://fake/get");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

[
  {
    Request: https://fake/get1,
    Response: 200 Ok
  },
  {
    Request: https://fake/get2,
    Response: 200 Ok
  }
]

snippet source | anchor

Explicit Content Response

Always return an explicit StringContent and media-type:

[Test]
public async Task ExplicitContent()
{
    using var client = new MockHttpClient(
        content: """{ "a": "b" }""",
        mediaType: "application/json");

    var result = await client.GetAsync("https://fake/get");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 200 OK,
  Content: {
    Headers: {
      Content-Length: 12,
      Content-Type: application/json; charset=utf-8
    },
    Value: {
      a: b
    }
  }
}

snippet source | anchor

Explicit HttpStatusCode Response

Always return an explicit HttpStatusCode:

[Test]
public async Task ExplicitStatusCode()
{
    using var client = new MockHttpClient(HttpStatusCode.Ambiguous);

    var result = await client.GetAsync("https://fake/get");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 300 Multiple Choices
}

snippet source | anchor

Explicit HttpResponseMessage

Alwars return an explicit HttpResponseMessage:

[Test]
public async Task ExplicitResponse()
{
    var response = new HttpResponseMessage(HttpStatusCode.OK)
    {
        Content = new StringContent("Hello")
    };
    using var client = new MockHttpClient(response);

    var result = await client.GetAsync("https://fake/get");

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 200 OK,
  Content: {
    Headers: {
      Content-Length: 5,
      Content-Type: text/plain; charset=utf-8
    },
    Value: Hello
  }
}

snippet source | anchor

HttpResponseMessage builder

Use custom code to create a HttpResponseMessage base on a HttpRequestMessage:

[Test]
public async Task ResponseBuilder()
{
    using var client = new MockHttpClient(request =>
    {
        var content = $"Hello to {request.RequestUri}";
        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(content),
        };
        return response;
    });

    using var result1 = await client.GetAsync("https://fake/get1");
    using var result2 = await client.GetAsync("https://fake/get2");

    await Verify(new
    {
        result1,
        result2
    });
}

snippet source | anchor

Resulting verified file

{
  result1: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Length: 26,
        Content-Type: text/plain; charset=utf-8
      },
      Value: Hello to https://fake/get1
    }
  },
  result2: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Length: 26,
        Content-Type: text/plain; charset=utf-8
      },
      Value: Hello to https://fake/get2
    }
  }
}

snippet source | anchor

Files as responses

It is often convenient to have files in a test project that can be returned as http responses.

Files can be included in the test directory:

<None Include="sample.*">
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

snippet source | anchor

Using files in a mock:

[Test]
public async Task ResponseFromFiles()
{
    using var client = new MockHttpClient(
        "sample.html",
        "sample.json",
        "sample.xml");

    using var content1 = await client.GetAsync("https://fake/get1");
    using var content2 = await client.GetAsync("https://fake/get2");
    using var content3 = await client.GetAsync("https://fake/get3");

    await Verify(new
        {
            content1,
            content2,
            content3
        })
        .IgnoreMember("Content-Length");
}

snippet source | anchor

Resulting verified file

{
  content1: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Type: text/html
      },
      Value:
<!DOCTYPE html>
<html>
<body>

<h1>My First Heading</h1>

<p>My first paragraph.</p>

</body>
</html>


    }
  },
  content2: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Type: application/json
      },
      Value: {
        name: John,
        age: 30,
        car: null
      }
    }
  },
  content3: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Type: application/xml
      },
      Value: {
        note: {
          to: Tove,
          from: Jani,
          heading: Reminder,
          body: Don't forget me this weekend!
        }
      }
    }
  }
}

snippet source | anchor

Enumeration of HttpResponseMessage

Use a sequence of HttpResponseMessage to return a sequence of requests:

[Test]
public async Task EnumerableResponses()
{
    using var client = new MockHttpClient(
        new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("Hello")
        },
        new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("World")
        });

    using var result1 = await client.GetAsync("https://fake/get1");
    using var result2 = await client.GetAsync("https://fake/get2");

    await Verify(new
    {
        result1,
        result2
    });
}

snippet source | anchor

Resulting verified file

{
  result1: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Length: 5,
        Content-Type: text/plain; charset=utf-8
      },
      Value: Hello
    }
  },
  result2: {
    Status: 200 OK,
    Content: {
      Headers: {
        Content-Length: 5,
        Content-Type: text/plain; charset=utf-8
      },
      Value: World
    }
  }
}

snippet source | anchor

Recording Mock Interactions

Interactions with MockHttpClient (in the form of Request and repsponse pairs) can optionally be included in Recording.

[Test]
public async Task RecordingMockInteractions()
{
    using var client = new MockHttpClient(recording: true);

    Recording.Start();
    await client.GetStringAsync("https://fake/getOne");
    await client.GetStringAsync("https://fake/getTwo");

    await Verify();
}

snippet source | anchor

Resulting verified file

{
  httpCall: [
    {
      Request: https://fake/getOne,
      Response: 200 Ok
    },
    {
      Request: https://fake/getTwo,
      Response: 200 Ok
    }
  ]
}

snippet source | anchor

Simulating ResponseHeadersRead

When using HttpCompletionOption.ResponseHeadersRead with HttpClient, the response content stream behaves differently than the default ResponseContentRead option. To properly test code that uses ResponseHeadersRead, use the SimulateNetworkStream=true setting in MockHttpClient or MockHttpHandler.

Why This Matters

With HttpCompletionOption.ResponseHeadersRead:

  • The stream is not seekable - CanSeek returns false
  • Content is not buffered - Data is read directly from the network
  • Single-read only - The stream cannot be reset or re-read
  • HttpClient timeout doesn't apply to content reading - Only applies until headers are received

Without SimulateNetworkStream (default)

using var client = new MockHttpClient("sample.html");
using var result = await client.GetAsync(
    "https://fake/get",
    HttpCompletionOption.ResponseHeadersRead);
await using var stream = await result.Content.ReadAsStreamAsync();

// Stream is seekable (MemoryStream-like behavior)
IsTrue(stream.CanSeek);
// Can reset
stream.Position = 0;

snippet source | anchor

With SimulateNetworkStream=true

using var client = new MockHttpClient("sample.html");
client.SimulateNetworkStream = true;
var result = await client.GetAsync(
    "https://fake/get",
    HttpCompletionOption.ResponseHeadersRead);
var stream = await result.Content.ReadAsStreamAsync();

// Stream is non-seekable (real network stream behavior)
IsFalse(stream.CanSeek);
// stream.Position throws NotSupportedException
// Cannot reset or re-read the stream

snippet source | anchor

Common Testing Scenarios

Progressive Reading

using var client = new MockHttpClient("sample.html");
client.SimulateNetworkStream = true;

var response = await client.GetAsync(
    "https://fake/large-file",
    HttpCompletionOption.ResponseHeadersRead);

// Read headers first without loading entire content
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;

// Then read content progressively
await using var stream = await response.Content.ReadAsStreamAsync();
var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
    // Process chunks as they arrive
    ProcessChunk(buffer.AsSpan(0, bytesRead));
}

snippet source | anchor

Read-Once Behavior

using var client = new MockHttpClient("sample.html");
client.SimulateNetworkStream = true;

using var response = await client.GetAsync(
    "https://fake/data",
    HttpCompletionOption.ResponseHeadersRead);

await using var stream = await response.Content.ReadAsStreamAsync();

// First read succeeds
using (var reader = new StreamReader(stream))
{
    var data1 = await reader.ReadToEndAsync();
}

// Second read returns empty (stream already consumed)
// Throws NotSupportedException
Assert.Throws<NotSupportedException>(() => stream.Position = 0);
// Returns empty
var data2 = await response.Content.ReadAsStringAsync();

snippet source | anchor

When to Use SimulateNetworkStream

Use SimulateNetworkStream=true when:

  • Testing code that explicitly uses HttpCompletionOption.ResponseHeadersRead
  • Testing streaming scenarios (large files, real-time data)
  • Ensuring code handles non-seekable streams correctly
  • Testing progressive download with progress reporting
  • Ensuring proper disposal/cleanup of streams

Use default behavior when:

  • Testing standard request/response scenarios
  • Using HttpCompletionOption.ResponseContentRead (the default)
  • Testing with GetStringAsync(), GetByteArrayAsync(), etc.

Verification Example

[Test]
public async Task VerifyWithSimulateNetworkStream()
{
    using var client = new MockHttpClient("sample.html");
    client.SimulateNetworkStream = true;

    var response = await client.GetAsync(
        "https://fake/api/data",
        HttpCompletionOption.ResponseHeadersRead);

    await Verify(response);
}

snippet source | anchor

This ensures tests accurately reflect production behavior when using ResponseHeadersRead.

Icon

Spider designed by marialuisa iborra from The Noun Project.