Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@

# Verify
*.verified.txt text eol=lf working-tree-encoding=UTF-8
*.verified.txt text eol=lf working-tree-encoding=UTF-8

# Mock HTTP bodies (Content-MD5 is computed from file bytes)
src/Devlead.Testing.MockHttp.Tests/Resources/** text eol=lf
src/Devlead.Testing.MockHttp.Tool.Tests/Fixtures/** text eol=lf
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ public class MyServiceTests
This approach ensures that your service logic is tested in isolation, without making actual network requests, by simulating HTTP interactions using the mock HTTP client. The `Constants` type helps manage the configuration of these interactions, providing a centralized way to define and access resource settings.


## Local mock HTTP tool

`Devlead.Testing.MockHttp.Tool` is a .NET global tool that hosts the same `Routes.json` contract on Kestrel instead of replacing `HttpClient`. Install and serve a resources directory:

```bash
dotnet tool install -g Devlead.Testing.MockHttp.Tool
dotnet mockhttp serve ./Resources --port 5080
```

By default the tool matches method, path, and query so a client can use `http://127.0.0.1:5080` as its base address. Pass `--match-host` to also require the request `Host` to match the host in `AbsoluteUri`. The console shows live route and activity tables; `http://127.0.0.1:5080/inspect` has request/response header details. See the [tool README](src/Devlead.Testing.MockHttp.Tool/README.md) for details.

## Example project

A real world example can be found in the [Blobify](https://github.com/devlead/Blobify) project.
4 changes: 4 additions & 0 deletions build/records.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ DirectoryPath OutputPath
{
private const string IntegrationTest = "integrationtest",
Output = "output";
public FilePath ToolProjectPath => ProjectRoot.CombineWithFilePath("Devlead.Testing.MockHttp.Tool/Devlead.Testing.MockHttp.Tool.csproj");

public FilePath[] PackableProjectPaths => [ProjectPath, ToolProjectPath];

public DirectoryPath NuGetOutputPath { get; } = OutputPath.Combine("nuget");
public DirectoryPath BinaryOutputPath { get; } = OutputPath.Combine("bin");
public DirectoryPath IntegrationTestPath { get; } = OutputPath.Combine(IntegrationTest);
Expand Down
8 changes: 4 additions & 4 deletions cake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
.WithProperty("Company", "devlead")
.WithProperty("PackageLicenseExpression", "MIT")
.WithProperty("PackageTags", "testing;http")
.WithProperty("PackageDescription", ".NET Library for mocking HTTP client requests")
.WithProperty("RepositoryUrl", "https://github.com/devlead/Devlead.Testing.MockHttp.git")
.WithProperty("ContinuousIntegrationBuild", GitHubActions.IsRunningOnGitHubActions ? "true" : "false")
.WithProperty("EmbedUntrackedSources", "true"),
Expand Down Expand Up @@ -127,9 +126,10 @@
)
)
.Then("Pack")
.Does<BuildData>(
static (context, data) => context.DotNetPack(
data.ProjectPath.FullPath,
.DoesForEach<BuildData, FilePath>(
static (data, _) => data.PackableProjectPaths,
static (data, project, context) => context.DotNetPack(
project.FullPath,
new DotNetPackSettings {
NoBuild = true,
NoRestore = true,
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"rollForward": "latestFeature",
"version": "11.0.100-rc.1.26425.128"
"version": "11.0.100-rc.2.26504.105"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<NoWarn>NU1008</NoWarn>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Verify.Http" />
<PackageReference Include="Verify.NUnit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Devlead.Testing.MockHttp.Tool\Devlead.Testing.MockHttp.Tool.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Devlead.Testing.MockHttp.Tool.Hosting" />
<Using Include="Devlead.Testing.MockHttp.Tool.Inspect.Models" />
<Using Include="Devlead.Testing.MockHttp.Tool.Routing" />
<Using Include="NUnit.Framework" />
</ItemGroup>

<ItemGroup>
<Content Include="..\Devlead.Testing.MockHttp.Tests\Resources\**\*" Link="Resources\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, World!
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[
{
"Request": {
"Methods": [
{
"Method": "GET"
}
],
"AbsoluteUri": "https://example.com/headers.txt"
},
"Responses": [
{
"RequestHeaders": {
"X-Test": [
"yes"
]
},
"ContentResource": "Example.Index.txt",
"ContentType": "text/plain",
"StatusCode": 200
}
]
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
version: {Scrubbed},
listenUrl: http://localhost,
inspectUrl: http://localhost/inspect,
resourcesDirectory: {Scrubbed},
matchHost: false,
counts: {
routes: 5,
enabled: 3,
requests: 1,
unauthorized: 0,
notFound: 0
},
routes: [
{
method: GET,
absoluteUri: https://example.com/login/secret.json,
host: example.com,
pathAndQuery: /login/secret.json,
enabled: true,
hits: 0
},
{
method: GET,
absoluteUri: https://example.com/index.txt,
host: example.com,
pathAndQuery: /index.txt,
enabled: true,
hits: 1
},
{
method: PUT,
absoluteUri: https://example.com/new.txt,
host: example.com,
pathAndQuery: /new.txt,
enabled: true,
hits: 0
},
{
method: GET,
absoluteUri: https://example.com/new.txt,
host: example.com,
pathAndQuery: /new.txt,
enabled: false,
hits: 0
},
{
method: DELETE,
absoluteUri: https://example.com/new.txt,
host: example.com,
pathAndQuery: /new.txt,
enabled: false,
hits: 0
}
],
activity: [
{
id: {Scrubbed},
utc: {Scrubbed},
method: GET,
pathAndQuery: /index.txt,
host: localhost,
status: 200,
matchedAbsoluteUri: https://example.com/index.txt,
enableDisable: false,
requestHeaders: {
X-Test: [
inspect
],
Host: [
localhost
]
},
responseHeaders: {
Content-Type: [
text/plain
],
Content-MD5: [
ZajifYh5KDgxtmS9i38K1A==
]
},
contentType: text/plain,
contentLength: 13,
bodyPreview: Hello, World!
}
]
}
92 changes: 92 additions & 0 deletions src/Devlead.Testing.MockHttp.Tool.Tests/InspectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace Devlead.Testing.MockHttp.Tool.Tests;

public class InspectTests
{
[Test]
public async Task InspectPageReturnsHtml()
{
await using var host = await MockHttpTestHost.StartAsync();

var response = await host.Client.GetAsync("/inspect");

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo("text/html"));
var html = await response.Content.ReadAsStringAsync();
Assert.That(html, Does.Contain("MockHttp inspect"));
Assert.That(html, Does.Contain("Request headers"));
Assert.That(html, Does.Contain("Response headers"));
}

[Test]
public async Task InspectApiAfterGetIncludesHitAndHeaders()
{
await using var host = await MockHttpTestHost.StartAsync();
host.Client.DefaultRequestHeaders.Add("X-Test", "inspect");

await host.Client.GetAsync("/index.txt");
var json = await host.Client.GetStringAsync("/inspect/api");

await VerifyJson(json)
.ScrubMember("version")
.ScrubMember("resourcesDirectory")
.ScrubMember("id")
.ScrubMember("utc");
}

[Test]
public async Task DisabledRouteGetDoesNotIncrementHits()
{
await using var host = await MockHttpTestHost.StartAsync();

var response = await host.Client.GetAsync("/new.txt");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));

var snapshot = await host.Client.GetFromJsonAsync<JsonElement>("/inspect/api");
var route = snapshot.GetProperty("routes").EnumerateArray().Single(item =>
item.GetProperty("method").GetString() == "GET"
&& item.GetProperty("pathAndQuery").GetString() == "/new.txt");
Assert.That(route.GetProperty("hits").GetInt64(), Is.EqualTo(0));
}

[Test]
public async Task ToggleDisableThenGetIsNotFound()
{
await using var host = await MockHttpTestHost.StartAsync();

var toggle = await host.Client.PostAsJsonAsync(
"/inspect/api/routes/toggle",
new { method = "GET", absoluteUri = "https://example.com/index.txt", enabled = false });
Assert.That(toggle.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));

var response = await host.Client.GetAsync("/index.txt");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}

[Test]
public async Task ClearActivityRemovesHits()
{
await using var host = await MockHttpTestHost.StartAsync();
await host.Client.GetAsync("/index.txt");

var clear = await host.Client.DeleteAsync("/inspect/api/activity");
Assert.That(clear.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));

var snapshot = await host.Client.GetFromJsonAsync<JsonElement>("/inspect/api");
Assert.That(snapshot.GetProperty("activity").GetArrayLength(), Is.EqualTo(0));
Assert.That(snapshot.GetProperty("counts").GetProperty("requests").GetInt32(), Is.EqualTo(0));
}

[Test]
public async Task InspectIsNotMockedAsNotFound()
{
await using var host = await MockHttpTestHost.StartAsync();

var response = await host.Client.GetAsync("/inspect");

Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound));
}
}
45 changes: 45 additions & 0 deletions src/Devlead.Testing.MockHttp.Tool.Tests/MockHttpTestHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace Devlead.Testing.MockHttp.Tool.Tests;

internal sealed class MockHttpTestHost : IAsyncDisposable
{
private MockHttpTestHost(WebApplication app, HttpClient client)
{
App = app;
Client = client;
}

public WebApplication App { get; }

public HttpClient Client { get; }

public static async Task<MockHttpTestHost> StartAsync(string? resourcesDirectory = null, bool matchHost = false)
{
var table = RouteTable.Load(resourcesDirectory ?? TestPaths.LibraryResources, matchHost);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddSingleton(table);
builder.Services.AddSingleton(new MockHttpListenUrl("http://localhost"));
var app = builder.Build();
MockHttpWebHost.Map(app);
await app.StartAsync();
return new MockHttpTestHost(app, app.GetTestClient());
}

public async ValueTask DisposeAsync()
{
Client.Dispose();
await App.DisposeAsync();
}
}

internal static class TestPaths
{
public static string LibraryResources { get; } = Path.Combine(AppContext.BaseDirectory, "Resources");

public static string HeaderMismatch { get; } = Path.Combine(AppContext.BaseDirectory, "Fixtures", "HeaderMismatch");
}
Loading
Loading