KuBuCo.Voodoo
1.5.0
dotnet add package KuBuCo.Voodoo --version 1.5.0
NuGet\Install-Package KuBuCo.Voodoo -Version 1.5.0
<PackageReference Include="KuBuCo.Voodoo" Version="1.5.0" />
<PackageVersion Include="KuBuCo.Voodoo" Version="1.5.0" />
<PackageReference Include="KuBuCo.Voodoo" />
paket add KuBuCo.Voodoo --version 1.5.0
#r "nuget: KuBuCo.Voodoo, 1.5.0"
#:package KuBuCo.Voodoo@1.5.0
#addin nuget:?package=KuBuCo.Voodoo&version=1.5.0
#tool nuget:?package=KuBuCo.Voodoo&version=1.5.0
Voodoo
Stable local HTTP responses for .NET tests.
Voodoo starts a small loopback server from test code. Register an HTTP method with an exact path or explicit fallback,
optional query parameters, request headers and exact JSON request body, status code, JSON response and response headers,
then call it through a normal HttpClient. No live
downstream service, Docker container or database is needed.
Benefits
- Keep the real HTTP path between an app and its downstream calls.
- Define repeatable responses in test code.
- Run concurrent tests on ports allocated from the OS.
- Start and stop the host inside the test scope.
- Use fluent registration with xUnit, NUnit, MSTest or another .NET test framework.
Use the package helpers for normal .NET tests. Use the standalone host for diagnostics or for a separate process
that cannot accept an HttpClient from the test.
Requirements
- The .NET 8 SDK on Windows, macOS or Linux.
- A .NET 8 test project.
- Permission to bind an IPv4 loopback port.
Voodoo needs no external service, container, persistent store or configuration file. Installation and use remain subject to the Voodoo License, including its non-commercial-use restrictions.
Give Voodoo a Spin in Two Minutes
Create an xUnit test project and add Voodoo from NuGet:
dotnet new xunit --framework net8.0 --output VoodooQuickStart
cd VoodooQuickStart
dotnet add package KuBuCo.Voodoo
Replace UnitTest1.cs with the xUnit sample, then run:
dotnet test
The test passes without another process to start. Voodoo selects a free loopback port, waits for its health route, serves the registered response, then releases the port on disposal.
Install in an Existing Project
Run this command from a .NET 8 test project:
dotnet add package KuBuCo.Voodoo
The package has no test-framework integration to configure.
Quick Start
Start Voodoo, register one response and call it:
using System.Net;
using Voodoo.Infrastructure;
await using var server = await Server.StartAsync();
var registration = await server
.Mock("get", "/products/13")
.ReturnsJson(200, new { id = 13, name = "Test Product" });
using var client = server.CreateClient();
using var response = await client.GetAsync(registration.Path);
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidOperationException("The mock did not return 200 OK.");
}
Each package release has a fixed mock limit embedded at pack time. Package consumers cannot change that limit
through ServerOptions, app configuration or environment variables. The limit applies across batches and concurrent
registrations. A rejected batch stores no partial result.
Test Framework Examples
Voodoo has no framework-specific integration. Start and dispose a server inside the test scope.
xUnit
using System.Net;
using Voodoo.Infrastructure;
using Xunit;
public sealed class CatalogTests
{
[Fact]
public async Task ReturnsTheConfiguredProduct()
{
await using var server = await Server.StartAsync();
var registration = await server
.Mock("get", "/products/13")
.ReturnsJson(200, new { id = 13, name = "Test Product" });
using var client = server.CreateClient();
using var response = await client.GetAsync(registration.Path);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
NUnit
using System.Net;
using NUnit.Framework;
using Voodoo.Infrastructure;
public sealed class CatalogTests
{
[Test]
public async Task ReturnsTheConfiguredProduct()
{
await using var server = await Server.StartAsync();
var registration = await server
.Mock("get", "/products/13")
.ReturnsJson(200, new { id = 13, name = "Test Product" });
using var client = server.CreateClient();
using var response = await client.GetAsync(registration.Path);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}
MSTest
using System.Net;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Voodoo.Infrastructure;
[TestClass]
public sealed class CatalogTests
{
[TestMethod]
public async Task ReturnsTheConfiguredProduct()
{
await using var server = await Server.StartAsync();
var registration = await server
.Mock("get", "/products/13")
.ReturnsJson(200, new { id = 13, name = "Test Product" });
using var client = server.CreateClient();
using var response = await client.GetAsync(registration.Path);
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
}
}
End-to-End HTTP Example
Pass the Voodoo client to the app component that calls a remote service. The call still crosses the real HTTP stack, but no remote service takes part.
using System.Net.Http.Json;
using Voodoo.Infrastructure;
using Xunit;
public sealed class CheckoutTests
{
[Fact]
public async Task CheckoutReadsTheRegisteredProduct()
{
await using var server = await Server.StartAsync();
await server
.Mock("get", "/products/13")
.ReturnsJson(200, new { id = 13, name = "Test Product" });
using var downstreamClient = server.CreateClient();
var catalog = new CatalogClient(downstreamClient);
var product = await catalog.GetProductAsync(13);
Assert.Equal("Test Product", product.Name);
}
private sealed class CatalogClient(HttpClient client)
{
public async Task<Product> GetProductAsync(int id) =>
await client.GetFromJsonAsync<Product>($"/products/{id}")
?? throw new InvalidOperationException("No product came back.");
}
private sealed record Product(int Id, string Name);
}
Replace CatalogClient with the app client, service or host that makes the real call. If it accepts a base URL rather
than an HttpClient, pass downstreamClient.BaseAddress before the end-to-end call.
Startup, Ports and Cleanup
Server.StartAsync(...) starts Kestrel on IPv4 loopback and waits up to ten seconds for the health route. It accepts
an optional configuration callback and cancellation token.
| Option | Default | Effect |
|---|---|---|
Port |
0 |
Lets the OS select an open ephemeral port. |
ConfigureServices |
null |
Gives advanced access to the host IServiceCollection before startup. |
Keep the default ephemeral port for concurrent tests. CreateClient() uses the selected base address, so no port
lookup is needed.
Set a fixed port when the app under test needs a known URL:
await using var server = await Server.StartAsync(
options => options.Port = 5127,
cancellationToken);
The port must be free before startup. Voodoo binds to loopback and is not exposed to other machines.
Each Server owns a separate in-process registration set. There is no public package Reset method. Dispose the
server and start a new one for clean state. await using calls DisposeAsync, stops Kestrel, releases the port and
discards all registrations. Dispose each HttpClient from CreateClient() as well.
For an advanced case that must reuse one host, DELETE /_voodoo/mocks clears all registrations. This is part of the
internal management surface, not the normal isolation pattern.
Fluent Registration Example
Add a custom response header:
using Voodoo.Infrastructure;
await using var server = await Server.StartAsync();
var registration = await server
.Mock("get", "/catalog/products/13")
.ReturnsJson(
200,
new { id = 13, name = "Test Product" },
new Dictionary<string, string> { ["x-source"] = "voodoo" });
using var client = server.CreateClient();
using var response = await client.GetAsync(registration.Path);
var content = await response.Content.ReadAsStringAsync();
Result:
registrationcontains the mock ID, method, path and status code.responsehas200 OKand thex-source: voodooheader.contentis{"id":13,"name":"Test Product"}.
Request Matching
Voodoo matches each runtime request by its HTTP method and relative URL path. Both values must match the same
registration. Method and path comparisons are case-insensitive. By default, URL search parameters do not participate,
so GET /products/13?include=details matches a get registration for /products/13, while POST /products/13 and
GET /products/13/history do not.
To distinguish calls that share a method and path, pass decoded query parameters to the overload:
var registration = await server
.Mock(
"get",
"/products/13",
new Dictionary<string, string>
{
["region"] = "eu",
["view"] = "full"
})
.ReturnsJson(200, new { id = 13, currency = "EUR" });
using var client = server.CreateClient();
using var response = await client.GetAsync(
$"{registration.Path}?region=eu&view=full&tracking=test");
Every configured query name and value must occur exactly once and match case-sensitively. Values are configured in
decoded form, so a value such as north & west matches north%20%26%20west in the URL. Extra request parameters are
allowed. An unconstrained registration remains the fallback for that exact method and path. When multiple registrations
match, Voodoo selects the one with the highest combined query, header and request-body constraint count. A configured
body contributes one constraint. Equal-specificity matches
retain registration/update order.
Query names and values are exact strings—characters such as * have no wildcard meaning. Request headers and bodies
participate only when their constraints are explicitly configured.
To match request headers, use the four-argument overload. Pass null when no query constraints are needed:
var registration = await server
.Mock(
"get",
"/products/13",
queryParameters: null,
requestHeaders: new Dictionary<string, string>
{
["x-tenant"] = "north"
})
.ReturnsJson(200, new { id = 13, tenant = "north" });
using var client = server.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, registration.Path);
request.Headers.Add("X-Tenant", "north");
using var response = await client.SendAsync(request);
Every configured header must occur exactly once. Header names match case-insensitively, while values match
case-sensitively. Extra request headers are allowed. null, omitted and empty request-header dictionaries are
unconstrained. Header values are exact strings—characters such as * have no wildcard meaning.
To distinguish requests by JSON body, add WithJsonBody(...) before ReturnsJson(...):
using System.Net.Http.Json;
var registration = await server
.Mock("post", "/orders")
.WithJsonBody(new { productId = 42, quantity = 2 })
.ReturnsJson(201, new { orderId = 123 });
using var client = server.CreateClient();
using var response = await client.PostAsJsonAsync(
registration.Path,
new { quantity = 2, productId = 42 });
JSON matching is semantic and exact: object property order and insignificant whitespace do not matter, equivalent
number forms such as 10e-3 and 0.01 match, property names and string values are case-sensitive, array order matters,
and extra or missing properties do not match. JSON null can be matched with WithJsonBody(null). An empty, malformed
or larger-than-1-MiB request body does not satisfy a body constraint, but an otherwise matching unconstrained
registration remains eligible. Content type is not implicitly constrained; add a Content-Type request-header
constraint when a test needs that distinction.
Paths use exact matching by default, preserving the behavior of every existing Mock(...) call. Opt into wildcard or
regular-expression matching with a typed route pattern:
var wildcard = await server
.Mock("get", MockedRoutePattern.Wildcard("/products/*"))
.ReturnsJson(200, new { match = "one product segment" });
var regularExpression = await server
.Mock("get", MockedRoutePattern.RegularExpression("/orders/[0-9]+"))
.ReturnsJson(200, new { match = "numeric order" });
All route modes compare the complete path case-insensitively and exclude the query string. In wildcard patterns, *
matches zero or more characters within one segment, ? matches one character within one segment, ** crosses path
separators, and \ escapes the next wildcard character. Regular expressions use the .NET non-backtracking engine,
are limited to 1024 characters, and are implicitly anchored to the complete path; unsupported constructs such as
backreferences are rejected during registration. A leading / is accepted by the fluent pattern helpers and removed
from the stored relative pattern, as it is for exact fluent routes.
When more than one complete registration matches, Voodoo orders route modes as exact, wildcard, regular expression,
then method fallback. Within one route mode it retains the existing combined query, header and body constraint
specificity, followed by registration/update order. An exact route containing * continues to treat it literally. A
trailing slash remains part of the path.
The runtime surface accepts the supported GET, POST, PUT and DELETE methods; another HTTP method returns
405 Method Not Allowed.
A URL without an explicit path is transmitted as the root path /. Register that exact request normally:
var root = await server
.Mock("get", "/")
.ReturnsJson(200, new { location = "root" });
For a deliberate default response when no complete route, query, header and body registration matches, configure a method-specific fallback:
var fallback = await server
.Fallback("get")
.ReturnsJson(404, new { message = "No configured GET response." });
Exact registrations, including an exact / registration, take precedence over wildcard and regular-expression
registrations, and every route registration takes precedence over a method fallback. A constrained route registration
that does not satisfy the incoming query, request headers or JSON body can fall through to a lower-precedence route or
the method fallback. Fallbacks do not accept a route match mode, query, request-header or body constraints and never
match another HTTP method. They are explicit because a broad fallback can otherwise hide an incorrect request path in
the system under test.
Advanced: Run as a Local Service
Package consumers should prefer Server.StartAsync, Server.Mock, Server.Fallback and Server.CreateClient. For
diagnostics, or for a process that cannot use the package helper, run a checked-out Voodoo host on fixed loopback.
From the source root:
dotnet run --project Voodoo/Voodoo.csproj --urls http://127.0.0.1:5000
Keep that terminal open. In a second terminal, check the health route:
curl -i http://127.0.0.1:5000/_voodoo/health
It returns 204 No Content. Register a response through the internal management API:
curl -i -X POST http://127.0.0.1:5000/_voodoo/mocks \
-H 'Content-Type: application/json' \
--data '{"mockedResponses":[{"mockedMethod":"get","mockedRoute":"products/13","mockedStatusCode":200,"mockedContentType":"application/json","mockedContent":{"id":13,"name":"Test Product"}}]}'
Registration returns 201 Created. Call the mock:
curl -i http://127.0.0.1:5000/products/13
The response is 200 OK with {"id":13,"name":"Test Product"}. Press Ctrl+C in the host terminal to stop Voodoo
and discard all registrations.
The standalone host has no authentication. Keep it on loopback; do not expose it as a production or shared network
service. These commands use a Bash-compatible shell. In Windows PowerShell, invoke curl.exe and enter the POST command
on one line.
Supported Scope
- .NET 8 automated test projects.
- Local Kestrel hosts bound to loopback.
- Mocked
get,post,putanddeleterequests. - JSON responses, HTTP status codes from
100through599, MIME formats and valid response headers. - In-process create, read, update, delete and runtime-match operations.
- Optional query-parameter constraints through fluent registration or
mockedQueryParametersin the internal management model. - Optional request-header constraints through fluent registration or
mockedRequestHeadersin the internal management model. - Optional exact semantic JSON-body constraints through
WithJsonBody(...)ormockedRequestBodyin the internal management model. - Optional exact, segment-aware wildcard and bounded regular-expression route matching through
MockedRoutePatternormockedRouteMatchModein the internal management model. - Exact root-path responses and explicit method-specific fallback responses for otherwise-unmatched paths.
- Fluent registration from test code, plus an internal HTTP management surface for advanced use.
Advanced/Internal HTTP Management API
This low-level host surface supports process-level integrations and diagnostics. Normal package consumers should use
Server.Mock(...) and ReturnsJson(...) instead. All management routes sit under /_voodoo; mock routes sit outside
that reserved space.
| Method | Route | Result |
|---|---|---|
GET |
/_voodoo/health |
Returns 204 No Content after the host starts. |
GET |
/_voodoo/mocks |
Lists all mocks. Add a mockedResponseID header to get one. |
POST |
/_voodoo/mocks |
Creates one or more mocks. |
PUT |
/_voodoo/mocks |
Updates mocks identified in the request. |
DELETE |
/_voodoo/mocks |
Deletes all mocks. Add a mockedResponseID header to delete one. |
Runtime matching uses the HTTP method, relative path and any optional mockedQueryParameters and
mockedRequestHeaders. An exact JSON body constraint uses
"mockedRequestBody":{"mode":"jsonExact","value":{...}}. Route matching defaults to exact; set
"mockedRouteMatchMode":"wildcard" or "mockedRouteMatchMode":"regularExpression" to opt into a pattern. Pattern
modes are immutable after creation, while the pattern itself can be updated. A root registration uses
"mockedRoute":"/". A fallback registration explicitly uses
"isFallback":true, omits mockedRoute, and cannot define query, request-header or body constraints. Omitting isFallback
never turns a missing route into a fallback and a fallback cannot define a route match mode. Omitting a constraint field on an update preserves its existing values;
sending {} clears that constraint dimension, including mockedRequestBody. Exact/fallback mode cannot be changed by an update.
DELETE /_voodoo/mocks without a mockedResponseID header clears all registrations but does not restart the host or
change its port.
Response Contracts
- Successful management reads and writes return
{ "mockedResponses": [...] }. - Validation failures return
{ "errors": [...] }. - A missing mock returns
{ "message": "The mocked response sought was not found." }. - A limit failure returns
{ "warning": "User usage limit exceeded." }. - An unexpected failure returns
{ "message": "An unexpected exception was thrown." }.
Troubleshooting
| Issue | Cause | Action |
|---|---|---|
| Startup reports that the address is in use. | The fixed port is occupied. | Remove the explicit Port, or select an open loopback port. |
| Startup is cancelled. | The token was cancelled before startup completed. | Use a token with enough time, or omit it when cancellation is not needed. |
| Startup exceeds 10 seconds. | Kestrel did not start on loopback. | Inspect the startup log and confirm that loopback binding is permitted. |
A mock call returns 404 Not Found. |
Its method, route pattern or configured query/header/body constraint differs from the request. | Check the values passed to Mock(...) or WithJsonBody(...); route patterns match the complete path, JSON body comparison is exact and semantic, query names/values and header values are case-sensitive, header names are case-insensitive, and repeated constrained values do not match. |
| Registration returns a usage warning. | The package mock limit has been reached. | Dispose the server and start a fresh instance, or split the case into smaller isolated tests. |
| Concurrent tests conflict on ports. | More than one test uses the same fixed port. | Keep Port = 0 unless the app requires a known URL. |
The client and server have separate lifetimes. Dispose both.
Limitations
- Registrations live in process and are lost when the server stops.
- Runtime matching considers the HTTP method, an exact, wildcard or regular-expression path, optional scalar query and request-header constraints, optional exact semantic JSON-body constraints, and explicit method fallbacks.
- Query constraints do not model repeated values for one name; a repeated constrained request parameter does not match.
- Request-header constraints do not model repeated values for one name; a repeated constrained header does not match.
- Mock routes must be relative or the exact root
/, cannot contain URL search parameters and cannot use the reserved/_voodoospace. - Regular-expression routes use the non-backtracking .NET engine. Constructs that require backtracking, including backreferences and lookarounds, are not supported.
- Voodoo has no persistence, authentication, metrics, partial-body matching, or hosted mock service.
- Use remains subject to the Voodoo License, including its commercial-use restrictions.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- FluentValidation (>= 12.1.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.