KuBuCo.Voodoo 1.5.0

dotnet add package KuBuCo.Voodoo --version 1.5.0
                    
NuGet\Install-Package KuBuCo.Voodoo -Version 1.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="KuBuCo.Voodoo" Version="1.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="KuBuCo.Voodoo" Version="1.5.0" />
                    
Directory.Packages.props
<PackageReference Include="KuBuCo.Voodoo" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add KuBuCo.Voodoo --version 1.5.0
                    
#r "nuget: KuBuCo.Voodoo, 1.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package KuBuCo.Voodoo@1.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=KuBuCo.Voodoo&version=1.5.0
                    
Install as a Cake Addin
#tool nuget:?package=KuBuCo.Voodoo&version=1.5.0
                    
Install as a Cake Tool

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:

  • registration contains the mock ID, method, path and status code.
  • response has 200 OK and the x-source: voodoo header.
  • content is {"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, put and delete requests.
  • JSON responses, HTTP status codes from 100 through 599, MIME formats and valid response headers.
  • In-process create, read, update, delete and runtime-match operations.
  • Optional query-parameter constraints through fluent registration or mockedQueryParameters in the internal management model.
  • Optional request-header constraints through fluent registration or mockedRequestHeaders in the internal management model.
  • Optional exact semantic JSON-body constraints through WithJsonBody(...) or mockedRequestBody in the internal management model.
  • Optional exact, segment-aware wildcard and bounded regular-expression route matching through MockedRoutePattern or mockedRouteMatchMode in 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 /_voodoo space.
  • 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.5.0 91 9/20/2026
1.4.0 95 9/19/2026
1.3.0 84 9/19/2026
1.2.0 118 8/23/2026
1.1.0 122 8/16/2026
1.0.4 112 8/16/2026
1.0.3 115 8/8/2026
1.0.2 110 8/8/2026
1.0.1 125 8/1/2026
1.0.0 121 7/18/2026