C# Test Data: JSON Fixtures for xUnit Theories and Integration Tests
C# test data tends to be written three ways, often in one solution. Unit
tests build objects with new CreateOrderRequest("Test User", ...),
some theories use [InlineData] rows of constants, and a library such as
AutoFixture or Bogus fills in the rest at run time. The integration tests
then post a JSON string typed straight into the test class. Each copy
describes the same order, and each drifts on its own schedule.
This post replaces them with one committed JSON fixture, generated once
from a JsonFabrica template with a fixed seed. System.Text.Json loads it
into C# records, an xUnit [Theory] runs every row through the
validator, and a WebApplicationFactory<Program> test posts the same
rows to the API. Planted invalid rows carry their expected error and
HTTP status, and dotnet test makes no network calls.
JsonFabrica's part happens outside the test run. It is a JSON-over-HTTP
API. There is no NuGet package, no .NET SDK and no xUnit extension: a
short C# script you own sends the template and a seed with HttpClient,
and writes the response into the test project.
Where C# test data usually comes from
A typical first theory looks like this:
[Theory]
[InlineData("Ada Lovelace", "[email protected]", 3, true)]
[InlineData("", "[email protected]", 3, false)]
[InlineData("Ada Lovelace", "not-an-email", 3, false)]
public void Validate(string name, string email, int qty, bool valid)
{
var order = new CreateOrderRequest(name, email, "Norway", qty, 10m);
Assert.Equal(valid, OrderValidator.Validate(order) is null);
}
It is clear, but it runs into limits quickly:
- Attributes only hold constants. C# attribute arguments must be
compile-time constants of a few types: primitives such as
intanddouble,string,Type, enums and arrays of them. A record, aDateTimeor adecimalliteral can't go in[InlineData], so the test rebuilds the object from loose arguments. bool validhides the reason. The test passes if the empty-name row is rejected for its quantity instead. Checking which rule fired needs another column per row, and the attribute list gets unreadable.- The boundaries are missing. Quantity 99 is allowed and 100 is not. Those are the rows that catch an off-by-one, and they are rarely written until the bug is filed.
Generating values in-process with Bogus or AutoFixture fixes volume, and there's a section on when that's the better choice further down. But the data then exists only in memory during a run, where nobody can review it and nothing outside .NET can read it.
.NET integration test fixtures as a committed file
The approach here splits generation from testing:
- Generate offline. A script sends a template and a fixed seed to
the JsonFabrica API and writes
Fixtures/orders.json. You run it when the template changes, review the diff and commit the file. - Read in tests. The tests deserialize the committed file. The test run needs no API key and no network, and every machine and CI agent reads the same bytes.
The layout used in the rest of the post:
scripts/
regen-fixtures.cs # calls the API; run by hand, never by tests
src/Orders.Api/
Orders.Api.csproj
Program.cs # minimal API: POST /api/orders
Orders.cs # request record and validator
tests/Orders.Tests/
Orders.Tests.csproj
TestData.cs # loads the fixture, exposes theory data
OrderValidatorTests.cs # unit tests
OrdersApiTests.cs # WebApplicationFactory tests
Fixtures/
orders.tmpl # the template (committed)
orders.json # generated fixture (committed)
The code under test validates an order with four rules: the customer name must be non-empty, the email must be a bare address, the quantity must be between 1 and 99, and the unit price must be positive.
using System.Net.Mail;
namespace Orders.Api;
public sealed record CreateOrderRequest(
string CustomerName,
string Email,
string Country,
int Quantity,
decimal UnitPrice);
public static class OrderErrors
{
public const string MissingName = "missing_name";
public const string InvalidEmail = "invalid_email";
public const string QuantityOutOfRange = "quantity_out_of_range";
public const string InvalidPrice = "invalid_price";
public static readonly IReadOnlyList<string> All =
[MissingName, InvalidEmail, QuantityOutOfRange, InvalidPrice];
}
public static class OrderValidator
{
// Returns the first rule the order breaks, or null if it is valid.
public static string? Validate(CreateOrderRequest order)
{
if (string.IsNullOrWhiteSpace(order.CustomerName))
return OrderErrors.MissingName;
if (!IsBareAddress(order.Email))
return OrderErrors.InvalidEmail;
if (order.Quantity is < 1 or > 99)
return OrderErrors.QuantityOutOfRange;
if (order.UnitPrice <= 0)
return OrderErrors.InvalidPrice;
return null;
}
private static bool IsBareAddress(string? email) =>
MailAddress.TryCreate(email, out var parsed)
&& parsed.Address == email;
}
Program.cs maps the endpoint and returns the error code as JSON:
using Orders.Api;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/api/orders", (CreateOrderRequest order) =>
OrderValidator.Validate(order) is { } error
? Results.BadRequest(new { error })
: Results.Created($"/api/orders/{Guid.NewGuid()}", order));
app.Run();
// Makes Program visible to WebApplicationFactory<Program> in the tests.
public partial class Program { }
A template with planted invalid orders
One template produces the whole fixture. Each row has a stable ID, a
short case label, the input that becomes a CreateOrderRequest, and
expect, the status and error code the row should produce:
{
"rows": [
<for(i, 1, getParam('rows', 12))><if(getVar('i') > 1)>,<endIf>
{
"id": "row-<appendBefore('0', getVar('i'), 2)>",
"case": <if(getVar('i') == 1)>"missing-name"
<elseIf(getVar('i') == 2)>"invalid-email"
<elseIf(getVar('i') == 3)>"quantity-0"
<elseIf(getVar('i') == 4)>"quantity-100"
<elseIf(getVar('i') == 5)>"quantity-99"
<elseIf(getVar('i') == 6)>"zero-price"
<else>"valid"<endIf>,
"input": {
"customerName": <if(getVar('i') == 1)>""
<else>"<getRandomFullName()>"<endIf>,
"email": <if(getVar('i') == 2)>
"<toLowerCase(getRandomName())>.example.com"
<else>"<getRandomEmail('example.com')>"<endIf>,
"country": "<getRandomCountry()>",
"quantity": <if(getVar('i') == 3)>0
<elseIf(getVar('i') == 4)>100
<elseIf(getVar('i') == 5)>99
<else><getRandomNumber(1, 20)><endIf>,
"unitPrice": <if(getVar('i') == 6)>0
<else><getRandomNumber(1, 250, 2)><endIf>
},
"expect": {
"status": <if(getVar('i') <= 4 || getVar('i') == 6)>400
<else>201<endIf>,
"error": <if(getVar('i') == 1)>"missing_name"
<elseIf(getVar('i') == 2)>"invalid_email"
<elseIf(getVar('i') <= 4)>"quantity_out_of_range"
<elseIf(getVar('i') == 6)>"invalid_price"
<else>null<endIf>
}
}<end_for>
]
}
How it's put together:
- The row count is a parameter.
getParamreads it once, as the loop bound, with a default of 12. - IDs come from the loop index.
appendBeforepadsito two digits, so the rows are alwaysrow-01torow-12. Stable IDs give stable test names. - The planted rows sit at fixed indexes. Row 1 has an empty name.
Row 2's email has no
@: a first name fromgetRandomName, lowercased withtoLowerCase, then.example.com. Rows 3 and 4 sit just outside the quantity range (0 and 100), row 5 sits just inside it (99, which must be accepted), and row 6 has a price of 0. Rows 7 and up are ordinary orders withgetRandomEmailaddresses and agetRandomNumberprice with two decimals. - Each planted row breaks one rule. The zero-price row still has a real name, a real email and a valid quantity, so the validator can only reject it for one reason.
- Every row carries its outcome.
expect.statusis what the API should return, andexpect.erroris the validator's code, ornullfor a valid order.
No variables are needed: each value is chosen inline by index.
Template expressions support comparisons (==, !=, <, <=, >,
>=) and &&/|| but no arithmetic, which is why the planted rows
are picked by index. for bounds are inclusive, the loop variable is
read with getVar('i'), and the guard <if(getVar('i') > 1)>,<endIf>
puts a comma before every row except the first. The
control flow docs cover for and
if/elseIf/else.
Rendered with seed 20261027 and the default 12 rows, the fixture
starts like this (trimmed to the first six rows):
{
"seed": 20261027,
"rows": [
{
"id": "row-01",
"case": "missing-name",
"input": {
"customerName": "",
"email": "[email protected]",
"country": "Brazil",
"quantity": 7,
"unitPrice": 188.36
},
"expect": { "status": 400, "error": "missing_name" }
},
{
"id": "row-02",
"case": "invalid-email",
"input": {
"customerName": "Rosa Shelridge",
"email": "bob.example.com",
"country": "Switzerland",
"quantity": 6,
"unitPrice": 243.96
},
"expect": { "status": 400, "error": "invalid_email" }
},
{
"id": "row-03",
"case": "quantity-0",
"input": {
"customerName": "Kylie Wendridge",
"email": "[email protected]",
"country": "Sweden",
"quantity": 0,
"unitPrice": 43.21
},
"expect": { "status": 400, "error": "quantity_out_of_range" }
},
{
"id": "row-04",
"case": "quantity-100",
"input": {
"customerName": "Muriel Chopin",
"email": "[email protected]",
"country": "Japan",
"quantity": 100,
"unitPrice": 152.39
},
"expect": { "status": 400, "error": "quantity_out_of_range" }
},
{
"id": "row-05",
"case": "quantity-99",
"input": {
"customerName": "Diane Chen",
"email": "[email protected]",
"country": "Mexico",
"quantity": 99,
"unitPrice": 70.81
},
"expect": { "status": 201, "error": null }
},
{
"id": "row-06",
"case": "zero-price",
"input": {
"customerName": "Leilani Dalman",
"email": "[email protected]",
"country": "Switzerland",
"quantity": 13,
"unitPrice": 0
},
"expect": { "status": 400, "error": "invalid_price" }
}
]
}
(The expect objects are folded onto one line here to save space; the
script writes them indented like the rest of the file.)
Regenerating the fixture with a C# script
The regen script is the only code that talks to JsonFabrica. It reads
the template, sends it to
POST /v1/templates/generate, and
writes the result. That endpoint renders a raw template body without
storing it, so the template stays a file in your repo next to the data
it produces.
On .NET 10 or later, a single .cs file can be run directly with
dotnet run, with no project file. The first line turns off Native AOT,
which file-based apps enable by default and which disables the
reflection-based System.Text.Json serialization that PostAsJsonAsync
needs (without it, the call throws):
#:property PublishAot=false
// scripts/regen-fixtures.cs
// Regenerates tests/Orders.Tests/Fixtures/orders.json from orders.tmpl
// by calling the JsonFabrica HTTP API. Run from the repo root (.NET 10+):
// dotnet run scripts/regen-fixtures.cs
// Review the diff and commit the result. dotnet test never runs this.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
var seedArg = Environment.GetEnvironmentVariable("SEED") ?? "20261027";
var rows = int.Parse(Environment.GetEnvironmentVariable("ROWS") ?? "12");
var apiKey = Environment.GetEnvironmentVariable("JSONFABRICA_API_KEY")
?? throw new InvalidOperationException("JSONFABRICA_API_KEY not set");
var api = Environment.GetEnvironmentVariable("JSONFABRICA_URL")
?? "https://api.jsonfabrica.com";
var dir = Path.Combine("tests", "Orders.Tests", "Fixtures");
var request = new JsonObject
{
["body"] = File.ReadAllText(Path.Combine(dir, "orders.tmpl")),
["params"] = new JsonObject { ["rows"] = rows },
};
if (seedArg != "random") // SEED=random lets the API pick one
request["seed"] = long.Parse(seedArg);
using var http = new HttpClient { BaseAddress = new Uri(api) };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
using var response =
await http.PostAsJsonAsync("/v1/templates/generate", request);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine($"HTTP {(int)response.StatusCode}: {text}");
return 1;
}
var result = JsonNode.Parse(text)!;
var seed = result["meta"]!["seed"]!.GetValue<long>();
if (seedArg != "random" && seed != long.Parse(seedArg))
{
// Refuse to write a file nobody can regenerate.
Console.Error.WriteLine($"API used seed {seed}, not {seedArg}");
return 1;
}
var fixture = new JsonObject
{
["seed"] = seed,
["rows"] = result["data"]!["rows"]!.DeepClone(),
};
var format = new JsonSerializerOptions
{
WriteIndented = true,
// Keep apostrophes, accents and '+' readable in diffs.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
var path = Path.Combine(dir, "orders.json");
File.WriteAllText(path, fixture.ToJsonString(format) + "\n");
Console.WriteLine($"wrote {path} (seed {seed}, {rows} rows)");
return 0;
The details that matter:
- The response is
{ data, meta }.datais the generated document, andmeta.seedis the seed the API used: the one you sent, or the one it picked if you sent none. API-picked seeds can be larger thanint.MaxValue, so the script reads them aslong. A 4xx or 5xx prints the error body and exits non-zero without touching the fixture. - The seed is written into the file. The top-level
seedkey means the fixture records how to rebuild itself.meta.generatedAtis left out on purpose, because it changes on every call and would make every regeneration show a diff. - Same template, seed and params give the same file. Running the
script twice produces no diff. Raising
ROWSappends rows and leaves the existing ones untouched, because the loop bound doesn't consume randomness. Editing the template shifts the random draws, so commit the regenerated file in the same change as the template. - The relaxed encoder keeps the file readable. It stops
',+and accented letters becoming\uXXXX, which is fine on disk but not for JSON you embed in HTML.
Keep the script outside every project directory. SDK-style projects
compile every .cs file under their folder, so a script inside
tests/Orders.Tests/ would be built into the test assembly. On .NET 8
or 9, put the same code in the Program.cs of a small console project
(dotnet new console -o tools/RegenFixtures) and run it from the repo
root with dotnet run --project tools/RegenFixtures. If you'd rather
use curl and jq, the shell script from the
Go version of this pattern works with only the
paths changed.
Loading the JSON with System.Text.Json
The test project needs the fixture next to the test assembly. In
Orders.Tests.csproj:
<ItemGroup>
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Use Update, not Include: SDK-style projects already include every
file under the project folder as a None item, and Update changes
the metadata of those existing items instead of adding duplicates. The
template stays out of the output because only *.json is copied. The
project also references Orders.Api, an xUnit package (xunit for v2,
xunit.v3 for v3) with its runner, and
Microsoft.AspNetCore.Mvc.Testing for the integration tests.
The fixture maps onto three records. The loader parses the file once and exposes it to xUnit:
using System.Text.Json;
using System.Text.Json.Serialization;
using Orders.Api;
using Xunit;
namespace Orders.Tests;
public sealed record OrderFixture(long Seed, IReadOnlyList<OrderCase> Rows);
public sealed record OrderCase(
string Id, string Case, CreateOrderRequest Input, Expectation Expect)
{
// xUnit uses this in the test display name.
public override string ToString() => $"{Id} {Case}";
}
public sealed record Expectation(int Status, string? Error);
public static class TestData
{
private static readonly JsonSerializerOptions Options =
new(JsonSerializerDefaults.Web)
{
// A field the records don't know about fails the load.
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
private static readonly Lazy<OrderFixture> LazyOrders =
new(() => Load<OrderFixture>("orders.json"));
public static OrderFixture Orders => LazyOrders.Value;
public static TheoryData<OrderCase> OrderCases
{
get
{
var data = new TheoryData<OrderCase>();
foreach (var row in Orders.Rows)
data.Add(row);
return data;
}
}
private static T Load<T>(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(stream, Options)
?? throw new InvalidDataException($"{path} contains null");
}
}
What each setting does:
JsonSerializerDefaults.Webturns on camelCase names and case-insensitive matching, socustomerNamein the file binds toCustomerNamein the record. It's the same set of defaults ASP.NET Core uses, which means the test reads the fixture the way the API reads a request body.UnmappedMemberHandling.Disallow(.NET 8 and later) makes deserialization throw if the file has a property no record declares. System.Text.Json ignores unknown properties by default, so without it a field renamed in the template would silently turn intonullon every row.AppContext.BaseDirectoryis the folder the test assembly runs from, which is whereCopyToOutputDirectoryput the file. It doesn't depend on the directory you starteddotnet testfrom.Lazy<T>parses the file once per test run, and it is thread-safe, which matters because xUnit runs test classes in parallel by default.
xUnit test data from a JSON file with MemberData
With the loader in place, the validator theory is a few lines:
using Orders.Api;
using Xunit;
using Xunit.Abstractions; // v2 only; v3 has ITestOutputHelper in Xunit
namespace Orders.Tests;
public class OrderValidatorTests(ITestOutputHelper output)
{
[Theory]
[MemberData(nameof(TestData.OrderCases), MemberType = typeof(TestData))]
public void Validate_returns_expected_error(OrderCase row)
{
output.WriteLine(
$"orders.json: seed {TestData.Orders.Seed}, " +
$"{TestData.Orders.Rows.Count} rows");
Assert.Equal(row.Expect.Error, OrderValidator.Validate(row.Input));
}
[Fact]
public void Fixture_plants_every_error_code()
{
var planted = TestData.Orders.Rows
.Select(row => row.Expect.Error)
.OfType<string>()
.ToHashSet();
Assert.Empty(OrderErrors.All.Except(planted));
Assert.Contains(TestData.Orders.Rows, row => row.Expect.Error is null);
}
}
What each piece is doing:
[MemberData]points at the loader.MemberTypeis needed because the data lives onTestData, not on the test class. The member must be public and static.TheoryData<T>is the typed form of the olderIEnumerable<object[]>:Addonly accepts anOrderCase, and the xUnit analyzers warn if the type doesn't match the test method's parameter.- Rows must fail for the right reason. The theory compares the exact error code. A test that only checked "is invalid" would pass if the quantity check fired on the empty-name row.
- The seed is logged.
ITestOutputHelperoutput is attached to the test result, anddotnet test's console logger prints it under "Standard Output Messages" for a failing test. Each failure says which fixture and seed it ran against. Fixture_plants_every_error_codeguards the planted rows. If a template edit drops one, this test names the error code that has lost its coverage, and it also checks that at least one valid row is left.
Readable test display names for data rows
xUnit names each row after the method and its arguments. For a type
that overrides ToString(), the argument formatter uses that, so the
override on OrderCase gives names like these (shown without the
Orders.Tests.OrderValidatorTests. prefix xUnit puts in front):
Validate_returns_expected_error(row: row-01 missing-name)
Validate_returns_expected_error(row: row-04 quantity-100)
Validate_returns_expected_error(row: row-05 quantity-99)
Without the override you would get the compiler-generated record
ToString(), which prints every nested property and buries the case
label. The ID and label also stay put when the fixture is regenerated.
Why Test Explorer shows one test instead of twelve
When you run these tests, every row runs and is reported with the name above. In Visual Studio's Test Explorer, though, the theory may appear as one entry rather than twelve, and you can't run a single row from there.
The reason is discovery. xUnit lists each row as its own test case
before the run only if it can serialize the row's arguments: primitives,
strings, enums and a few other built-in types, or types implementing
IXunitSerializable. OrderCase is none of those, so xUnit falls back
to one test case for the whole theory and enumerates the rows when it
runs.
If you want one entry per row, pass a string key instead of the record and look the row up inside the test:
public static TheoryData<string> OrderCaseKeys
{
get
{
var data = new TheoryData<string>();
foreach (var row in Orders.Rows)
data.Add(row.ToString());
return data;
}
}
public static OrderCase FindCase(string key) =>
Orders.Rows.Single(row => row.ToString() == key);
Each row's test then takes a string key argument and starts with
var row = TestData.FindCase(key);. It shows up in Test Explorer as
Validate_returns_expected_error(key: "row-05 quantity-99"), and you
can run it alone.
NUnit: the same fixture through TestCaseSource
NUnit's equivalent of [MemberData] is [TestCaseSource]. The source
must be static, and TestCaseData.SetName sets the display name:
using NUnit.Framework;
using Orders.Api;
namespace Orders.Tests;
public class OrderValidatorTests
{
private static IEnumerable<TestCaseData> OrderCases() =>
TestData.Orders.Rows.Select(row =>
new TestCaseData(row).SetName($"Validate {row}"));
[TestCaseSource(nameof(OrderCases))]
public void Validate_returns_expected_error(OrderCase row) =>
Assert.That(OrderValidator.Validate(row.Input),
Is.EqualTo(row.Expect.Error));
}
The loader and records are unchanged apart from dropping the
TheoryData property and its using Xunit;. NUnit lists each
TestCaseData as its own test, so the Test Explorer issue above
doesn't arise.
WebApplicationFactory integration tests from the same rows
The unit theory proves the validator. The integration test proves the
whole request path: routing, JSON binding of the request body and the
status code. WebApplicationFactory<Program>, from the
Microsoft.AspNetCore.Mvc.Testing package, starts the real app on an
in-memory test server, and the theory posts every fixture row to it:
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace Orders.Tests;
internal sealed record ErrorBody(string Error);
public class OrdersApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Theory]
[MemberData(nameof(TestData.OrderCases), MemberType = typeof(TestData))]
public async Task Post_returns_expected_status(OrderCase row)
{
using var client = factory.CreateClient();
using var response =
await client.PostAsJsonAsync("/api/orders", row.Input);
Assert.Equal(row.Expect.Status, (int)response.StatusCode);
if (row.Expect.Error is { } expected)
{
var body = await response.Content.ReadFromJsonAsync<ErrorBody>();
Assert.Equal(expected, body?.Error);
}
}
}
Points to know:
Programmust be public. With top-level statements, the compiler generatesProgramas an internal class, so the test project can't use it as a type argument and fails to compile with "'Program' is inaccessible due to its protection level". Thepublic partial class Program { }line at the end ofProgram.csfixes that;InternalsVisibleTofor the test assembly works too. According to the ASP.NET Core 10 release notes, .NET 10 generates the public declaration itself, so on 10 the manual line is redundant (the ASP0027 analyzer flags it) but harmless.IClassFixtureshares one server per test class. Starting the host for every row would be slow; with the class fixture, all twelve rows hit the same in-memory server, andCreateClient()is cheap.- The same row, two layers. Row 5 (quantity 99) must be accepted by
the validator and come back as
201 Created. If someone adds an endpoint filter or middleware that rejects it before the validator runs, the integration test fails on that row while the unit test still passes, which is exactly the gap this test exists to cover. - Typed input, real JSON.
PostAsJsonAsyncserializes the record with the same web defaults the API reads with.
Reproducing a failure from the logged seed
With a committed fixture, most failures reproduce trivially: CI read
the same orders.json you have locally, so rerunning the test class
reproduces the failure.
dotnet test tests/Orders.Tests \
--filter "FullyQualifiedName~OrderValidatorTests"
That's VSTest filter syntax; a v3 project running on Microsoft Testing
Platform uses options such as --filter-class instead.
The seed matters when you go looking for data you didn't plan for. A scheduled exploratory job can regenerate the fixture with a seed the API picks and many more rows, then run the suite against it. That job needs the API key, so keep it separate from the checks that gate merges:
SEED=random ROWS=500 dotnet run scripts/regen-fixtures.cs
dotnet test tests/Orders.Tests
The seed shows up twice in that job's log: in the script's
wrote ... (seed N, 500 rows) line, and in the output of every failing
validator row. When the job fails, check out the same commit, so the template
matches, and rebuild the exact file:
SEED=3172840951 ROWS=500 dotnet run scripts/regen-fixtures.cs
dotnet test tests/Orders.Tests
(The seed is a placeholder; use the one from your own log.) Same template, same seed, same parameters: the file is byte-for-byte what the job tested, so the same rows fail with the same names. Once you understand the bug, turn the failing row into a planted row in the template, regenerate with the default seed and commit both files. Reproducible test data from seeds covers how seeded generation works in more depth.
Bogus, AutoFixture, or a committed fixture?
A generated fixture is extra machinery: a template, a script, an API key for whoever regenerates, and a JSON file to review. For many tests, one of the in-process libraries is simpler.
AutoFixture, when the values don't matter. AutoFixture's job is to
supply "anonymous" values the test doesn't care about, so a test can
construct a CreateOrderRequest without listing every argument:
var order = new Fixture().Create<CreateOrderRequest>();
Strings come out as the parameter name plus a GUID, and numbers are arbitrary. That's deliberate, and it suits a unit test that only cares about one property. It isn't meant to produce realistic or repeatable data, and it has no way to express "row 5 must be accepted".
Bogus, when you want realistic values in code. Bogus generates plausible names, emails and amounts in-process, and it is repeatable when seeded:
var orders = new Faker<CreateOrderRequest>()
.UseSeed(20261027)
.CustomInstantiator(f => new CreateOrderRequest(
f.Name.FullName(),
f.Internet.Email(),
f.Address.Country(),
f.Random.Int(1, 20),
f.Finance.Amount(1, 250)))
.Generate(12);
UseSeed seeds one faker; avoid the global Randomizer.Seed, which is
static state shared by tests running in parallel. Seeded output can also
change when you upgrade Bogus. For one object, or a handful of rows used
by one test class, Bogus with UseSeed is less work than a fixture file.
A committed fixture, when the data has to outlive the test run. The file-based approach wins when:
- Several test types share the rows. Here the unit theory and the integration test read one file; a Playwright suite or a load test can read it too.
- More than one language reads them. A JSON file is just as easy to load in pytest, Jest or Vitest or a Spring Boot service as in xUnit.
- Reviewers should see the data. Planted edge cases and their expected status and error sit next to each other in a pull request diff, instead of being implied by generator code.
- A CI run has to be reproduced exactly. The committed file doesn't change when a library is upgraded, and a logged seed rebuilds any exploratory variant.
FAQ
How do I load test data from a JSON file in xUnit?
Copy the file to the test output folder with CopyToOutputDirectory in
the csproj, read it from Path.Combine(AppContext.BaseDirectory, ...),
and deserialize it with System.Text.Json into C# records. Expose the
rows through a TheoryData<T> property and point a [Theory] at it
with [MemberData(nameof(...), MemberType = typeof(...))]. Load the
file once, for example behind a Lazy<T> wrapper, so every test class
shares the same parsed data.
Why does my xUnit theory show up as only one test in Test Explorer?
xUnit can only list each data row as its own test case at discovery
time if the row's arguments are serializable: primitive types, strings,
enums and a few others, or types that implement IXunitSerializable.
When a row contains a custom record or class, xUnit falls back to a
single test case for the whole theory and runs the rows inside it.
Every row still runs and is reported, but you can't run one row on its
own. Passing a string key instead of the object brings back one test
per row.
How do I copy a JSON file to the output directory in a .NET test project?
Add a None item to the test project's csproj with
Update="Fixtures\*.json" and CopyToOutputDirectory="PreserveNewest":
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />.
Use Update rather than Include in SDK-style projects, because the
files are already part of the default None items. At run time, build
the path from AppContext.BaseDirectory rather than relying on the
current directory.
Why does WebApplicationFactory<Program> say Program is inaccessible?
With top-level statements, the compiler generates the Program class
as internal, so a separate test project can't use it as a type
argument. Add public partial class Program { } at the end of
Program.cs, or expose internals to the test project with
InternalsVisibleTo. According to the ASP.NET Core 10 release notes,
.NET 10 generates the public declaration for you; on .NET 8 and 9 you
add it yourself.
Should I use Bogus or AutoFixture for C# test data?
They solve different problems. AutoFixture fills in values a test doesn't care about, so a test can construct an object without spelling out every argument. Bogus generates realistic values in-process and can be made repeatable with a seed. Both are a good fit for unit tests that build data in code. A committed JSON fixture fits better when several test types or languages share the data, when you want planted edge cases and expected results reviewed in a pull request, or when you need to reproduce a CI run exactly.
Is there a JsonFabrica NuGet package or .NET SDK?
No. JsonFabrica is a JSON-over-HTTP API, and there is no NuGet package,
.NET SDK or xUnit extension. You call the API from a script you write,
such as a small C# program using HttpClient or a curl command, and
commit the JSON it returns. The test project gets no new dependency,
and dotnet test makes no network calls.
A seeded template, a short regen script and one committed file give
your xUnit theories and WebApplicationFactory tests the same rows,
with planted boundary cases and data you can rebuild from a seed.
Seeded generation from a raw template is one request to the JsonFabrica
API; the templates API reference
documents its parameters and the { data, meta } response.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.