Skip to content

Latest commit

 

History

History
1136 lines (880 loc) · 32.8 KB

readme.md

File metadata and controls

1136 lines (880 loc) · 32.8 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 MockHttpClient("sample.moby.html");

    using var result = await client.GetAsync("https://fake/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 MockHttpClient("sample.json");

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

    await Verify(result);
}

snippet source | anchor

Resulting verified file

{
  Status: 200 OK,
  Content: {
    Headers: {
      Content-Length: 48,
      Content-Type: application/json
    },
    Value: {
      name: John,
      age: 30,
      car: null
    }
  }
}

snippet source | anchor

Ignoring Headers

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

[Test]
public async Task IgnoreHeader()
{
    var response = new HttpResponseMessage(HttpStatusCode.OK)
    {
        Content = new StringContent("{\"status\":200}", Encoding.UTF8, "application/json")
    };
    response.Headers.Add("Server", "test-server");
    response.Headers.Add("Access-Control-Allow-Credentials", "true");
    response.Headers.Add("Vary", "Origin");
    using var client = new MockHttpClient(response);

    using var result = await client.GetAsync("https://fake/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://fake/status/200");
}

snippet source | anchor

Add to IHttpClientBuilder

Http recording can be added to a IHttpClientBuilder:

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:

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://fake/status/200,
    RequestMethod: GET,
    ResponseStatus: OK 200,
    ResponseContent: {"status":200}
  }
]

snippet source | anchor

There a Pause/Resume semantics:

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 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://fake/html");

recording.Resume();
await client.GetAsync("https://fake/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($"{TestServer.Root}/json");
    var ymlResult = await client.GetStringAsync($"{TestServer.Root}/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: 170
  },
  httpCall: [
    {
      Request: {
        Uri: http://test-server/json,
        Headers: {}
      },
      Response: {
        Status: 200 OK,
        Headers: {
          Connection: close
        },
        ContentHeaders: {
          Content-Length: 48,
          Content-Type: application/json
        },
        ContentStringParsed: {
          name: John,
          age: 30,
          car: null
        }
      }
    },
    {
      Request: {
        Uri: http://test-server/xml,
        Headers: {}
      },
      Response: {
        Status: 200 OK,
        Headers: {
          Connection: close
        },
        ContentHeaders: {
          Content-Length: 128,
          Content-Type: application/xml
        },
        ContentStringParsed: {
          note: {
            to: Tove,
            from: Jani,
            heading: Reminder,
            body: Don't forget me this weekend!
          }
        }
      }
    }
  ]
}

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)
    {
        await Assert.That(call.Duration < threshold).IsTrue();
    }

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

snippet source | anchor

Resulting verified file

{
  responseSize: 170,
  httpCalls: [
    http://test-server/json,
    http://test-server/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)
await Assert.That(stream.CanSeek).IsTrue();
// 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)
await Assert.That(stream.CanSeek).IsFalse();
// 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
await Assert.That(() => stream.Position = 0).ThrowsExactly<NotSupportedException>();
// 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.