The Bogus and AutoFixture Guide: Faker<T> Rules, Turkish Locale, Seeds, Strict Mode, AutoData and Customizations
In Bogus the seed fixes the random number sequence, not the data: a single RuleFor added in the middle of a Faker changes every value after it, so you assert that generated values follow the rules rather than the values themselves. RuleFor, CustomInstantiator, RuleSet, StrictMode, GenerateLazy, a real search bug found with the Turkish locale; a DateOnly customization, the Build pitfall, AutoData and AutoNSubstitute in AutoFixture.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3, Bogus 35.6.5, AutoFixture 4.18.1, AutoFixture.Xunit3 4.19.0 and AutoFixture.AutoNSubstitute 4.18.1.
Predict First
Post 10 introduced Bogus and AutoFixture with a section each: one produced realistic data, the other anonymous data, and for both we talked about why the seed matters. This post looks at their APIs in detail. The examples come from the Catalog module of Bookstore, the online bookstore used as the shared example across this site's series: Book, Isbn13, which validates an ISBN, and BookSearch, which searches by author. We wrote a Faker for the catalog tests:
public static Faker<Book> Create(int seed, string locale = "en") =>
new Faker<Book>(locale)
.UseSeed(seed)
.UseDateTimeReference(Reference)
.StrictMode(true)
.RuleFor(b => b.Isbn, f => NewIsbn(f))
.RuleFor(b => b.Title, f => f.Lorem.Sentence(f.Random.Int(1, 4)).TrimEnd('.'))
.RuleFor(b => b.Author, f => f.Name.FullName())
.RuleFor(b => b.Pages, f => f.Random.Int(80, 900))
.RuleFor(b => b.Price, (f, b) => Math.Round(5m + b.Pages * 0.04m + f.Random.Decimal(0m, 10m), 2))
.RuleFor(b => b.PublishedOn, f => f.Date.PastDateOnly(40))
.RuleFor(b => b.Language, _ => locale);
And, since the seed is fixed, a test that relies on the values of the first book:
[Fact]
public void First_book_of_the_seeded_catalog()
{
Book first = BookFaker.Create(seed: 2026).Generate();
Assert.Equal("9782999068636", first.Isbn);
Assert.Equal(173, first.Pages);
Assert.Equal(17.14m, first.Price);
}
The test is green. Then an optional Subtitle property was added to Book, and a rule was written into the Faker right below Title:
.RuleFor(b => b.Subtitle, f => f.Random.Bool(0.3f) ? f.Lorem.Sentence(3).TrimEnd('.') : null)
The seed is still 2026. Does the test pass?
Answer: The Seed Is Fixed, the Order Is Not
It fails:
failed Bookstore.Catalog.Tests.SeededCatalogTests.First_book_of_the_seeded_catalog (29ms)
Assert.Equal() Failure: Values differ
Expected: 173
Actual: 508
The ISBN line passed, the page count failed. UseSeed(2026) fixes the Faker's random number generator; each rule draws numbers from that generator in turn. The new rule draws two more numbers after Title, so every rule after it (Author, Pages, Price, PublishedOn) started reading from a different position in the sequence. The earlier rules (Isbn, Title) stayed the same.
Bogus's README makes two recommendations: add new rules last, and assert a property of the generated value rather than the value itself. We applied the first; with the rule moved to the bottom, this test went green again. But the root of the problem is in the test: its name says "first book", yet what it actually checks is Bogus's internal order. The same test would also break on a new major version of Bogus (according to the README, updates to locale data can change deterministic output). What we wrote instead:
[Fact]
public void Same_seed_gives_the_same_catalog()
{
List<Book> first = BookFaker.Create(seed: 2026).Generate(100);
List<Book> second = BookFaker.Create(seed: 2026).Generate(100);
Assert.Equal(first, second);
}
[Fact]
public void Catalog_of_1000_books_has_valid_and_unique_isbns()
{
List<Book> catalog = BookFaker.Create(seed: 2026).Generate(1_000);
Assert.All(catalog, b => Assert.True(Isbn13.IsValid(b.Isbn), $"{b.Isbn} is not a valid ISBN-13"));
Assert.Distinct(catalog.Select(b => b.Isbn));
}
[Fact]
public void Generated_values_stay_inside_the_rules()
{
List<Book> catalog = BookFaker.Create(seed: 7).Generate(1_000);
Assert.All(catalog, b =>
{
Assert.InRange(b.Pages, 80, 900);
Assert.InRange(b.Price, 5m + b.Pages * 0.04m, 15m + b.Pages * 0.04m);
Assert.InRange(b.PublishedOn, new DateOnly(1986, 11, 1), new DateOnly(2026, 11, 1));
});
}
The seed still matters: it lets you reproduce a red run with the same data. But what the test checks is that the generated data follows the rules, not a particular value.
Faker<T>: RuleFor, Dependent Rules and CustomInstantiator
RuleFor(b => b.Property, f => ...) defines how a property is filled; f is the Faker object that opens Bogus's data sets (Name, Address, Internet, Lorem, Date, Random…). The two-parameter form (f, b) => ... also gives you the object filled so far: the Price rule depends on the page count, so it is written after Pages. If you write a dependent rule before the rule it depends on, b.Pages is still 0 and nothing fails; it just produces wrong data.
Bogus has no ready-made method for a valid ISBN-13 (Commerce.Ean13() produces a 13-digit EAN and does not guarantee the 978/979 prefix). We take the check digit from Isbn13 in Catalog; the test data uses production's validation rule:
public static string NewIsbn(Faker f)
{
string first12 = f.PickRandom("978", "979") + f.Random.ReplaceNumbers("#########");
return first12 + Isbn13.CheckDigit(first12);
}
Two details about dates. Date.PastDateOnly(40) produces .NET's DateOnly type directly. The seed does not fix dates, because "past" is computed relative to today; unless UseDateTimeReference(new DateTime(2026, 11, 1, ...)) fixes the reference date, the same seed produces different dates tomorrow.
Because Book is a record with init properties, RuleFor works directly. For positional records that can only be built through their constructor, there is CustomInstantiator:
Faker<CustomerAccount> customers = new Faker<CustomerAccount>("tr")
.UseSeed(2026)
.CustomInstantiator(f => new CustomerAccount(
f.Internet.ExampleEmail(),
IsInstitutional: f.Random.Bool(0.2f),
CompletedOrders: f.Random.Int(0, 40)));
The first three records:
CustomerAccount { Email = Atsiz_Cetin7@example.org, IsInstitutional = False, CompletedOrders = 35 }
CustomerAccount { Email = Bagaturipi40@example.com, IsInstitutional = False, CompletedOrders = 5 }
CustomerAccount { Email = Bayri_Arslanoglu@example.org, IsInstitutional = True, CompletedOrders = 36 }
Internet.ExampleEmail() uses only the example.com, example.org and example.net domains. Internet.Email(), by contrast, produces the domains of real free e-mail services; in my run, gmail and hotmail addresses came out. Test data ends up in logs, failure messages and screenshots; you do not want to see an address that might really exist there. The test pins this down as a rule: every one of the 200 accounts has an e-mail on an example domain.
RuleSet, StrictMode, GenerateLazy and FinishWith
RuleSet defines a named group of rules; according to the names passed to Generate, it overrides the default rules:
.RuleSet("classic", rules => rules
.RuleFor(b => b.PublishedOn, f => f.Date.PastDateOnly(150, new DateOnly(1950, 1, 1))));
[Fact]
public void Classic_rule_set_moves_publication_before_1950()
{
List<Book> classics = BookFaker.Create(seed: 2026).Generate(50, "default,classic");
Assert.All(classics, b => Assert.True(b.PublishedOn < new DateOnly(1950, 1, 1)));
}
The name "default" has to be listed as well. With only "classic", the rules outside the RuleSet did not run at all: the books came out with an empty string for ISBN, title and author and a price of 0, and strict mode did not catch it.
StrictMode(true) stops Generate when a property is left without a rule. Had the Subtitle property from Predict First been added without a rule, the test would have failed like this (here with a Faker deliberately left incomplete to show the missing rules):
Bogus.ValidationException : Validation was called to ensure all properties / fields have rules.
There are missing rules for Faker<T> 'Book'.
=========== Missing Rules ===========
Subtitle
Price
Pages
PublishedOn
Language
Without strict mode, the new property silently stays null or 0, and the tests that rely on it run on an empty value instead of real data. Keeping it on in shared Fakers forces the Faker to be updated when the domain changes.
Generate(n) returns a List<T>; GenerateLazy(n) produces objects only as they are enumerated. FinishWith is called once per object after all rules have run; here it counts the generated objects:
[Fact]
public void Lazy_generation_creates_only_what_is_enumerated()
{
int created = 0;
Faker<Book> books = BookFaker.Create(seed: 2026).FinishWith((_, _) => created++);
List<Book> firstFive = [.. books.GenerateLazy(1_000).Take(5)];
Assert.Equal(5, firstFive.Count);
Assert.Equal(5, created);
}
Five books were generated instead of 1,000. When you run a performance experiment with large data or feed a stream, GenerateLazy saves memory.
The Turkish Locale: a Real Bug Found by Data
new Faker<Book>("tr") takes names, addresses, cities and words from the Turkish data set. Two books generated with the same seed:
Book { Isbn = 9782999068636, Title = Dışarı sıradanlıktan illo öyle, Subtitle = Qui açılmadan ipsam, Author = Aydoğdu Korol, Price = 17.14, Pages = 173, PublishedOn = 7/3/2018, Language = tr }
Book { Isbn = 9786801711079, Title = Odit, Subtitle = , Author = Atberilgen Alpuğan, Price = 21.26, Pages = 325, PublishedOn = 2/13/2021, Language = tr }
ISBN, price and page count are the same as in the English catalog: the same seed produces the same numbers, and the locale only changes the text data. The titles mix Latin and Turkish words; according to the README, a data set missing from a locale is filled in from English.
Using this data with BookSearch turned up a bug. The first version searched with Contains(term, StringComparison.OrdinalIgnoreCase). The test searches for every author of 1,000 Turkish books in lowercase, the way a user would type it:
[Fact]
public void Every_turkish_author_is_found_by_a_lowercase_search()
{
List<Book> catalog = BookFaker.Create(seed: 2026, locale: "tr").Generate(1_000);
BookSearch search = new(catalog);
Assert.All(catalog.Select(b => b.Author).Distinct(), author =>
Assert.NotEmpty(search.ByAuthor(author.ToLower(Turkish))));
}
Assert.All() Failure: 38 out of 998 items in the collection did not pass.
[42]: Item: "İlgegü Düşenkalkar"
Error: Assert.NotEmpty() Failure: Collection was empty
[55]: Item: "İdil Özdenak"
Error: Assert.NotEmpty() Failure: Collection was empty
[136]: Item: "İkeme Sarıoğlu"
Error: Assert.NotEmpty() Failure: Collection was empty
Every one of the 38 authors has an İ or an I in their name. Ordinal comparison does not know Turkish casing rules: "idil" does not match "İdil", and "ışıl" does not match "Işıl". Simply switching to the tr-TR culture is not the answer either; then a search for "isaac" does not find "Isaac Asimov", because in Turkish the uppercase of i is İ. The fix is to tie the comparison rule to the book's language:
// Case rules depend on the book's language: "İdil" matches "idil" only under Turkish rules
public IReadOnlyList<Book> ByAuthor(string term) =>
[
.. books
.Where(b => CultureInfo.GetCultureInfo(b.Language).CompareInfo
.IndexOf(b.Author, term, CompareOptions.IgnoreCase) >= 0)
.OrderBy(b => b.Title),
];
A second test checks that English authors are still found in a mixed catalog. With a handful of hand-written names like "Ada Lovelace", this bug would never have shown up; that is exactly the value of realistic data.
AutoFixture: Create, Build and Customizations
The stable version of AutoFixture is still 4.18.1 (November 2023). The first time we asked it for a Book, it failed:
AutoFixture.ObjectCreationExceptionWithPath : AutoFixture was unable to create an instance from System.DateOnly because creation unexpectedly failed with exception. Please refer to the inner exception to investigate the root cause of the failure.
Request path:
Bookstore.Catalog.Book
System.DateOnly PublishedOn
System.DateOnly
Inner exception messages:
System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation.
System.ArgumentOutOfRangeException: Year, Month, and Day parameters describe an un-representable DateTime.
4.18.1 does not know DateOnly; it tries to create it like any other struct by passing a random year, month and day to the constructor. The "Request path" part of the message shows which property it got stuck on. When I tried 5.0.0-rc.1 (July 31, 2026) in a separate project, DateOnly was created with no extra settings; but we did not put an RC package into the snapshot. The fix is a customization:
// AutoFixture 4.18.1 cannot create DateOnly by itself: it calls the constructor with random year/month/day
public sealed class DateOnlyCustomization : ICustomization
{
public void Customize(IFixture fixture) =>
fixture.Customize<DateOnly>(c => c.FromFactory((DateTime d) => DateOnly.FromDateTime(d)));
}
// Anonymous books still need a valid ISBN and a real culture name
public sealed class BookCustomization : ICustomization
{
public void Customize(IFixture fixture) =>
fixture.Customize<Book>(c => c
.With(b => b.Isbn, (int n) => ToIsbn(n))
.With(b => b.Language, "en"));
private static string ToIsbn(int n)
{
string first12 = "978" + (n % 1_000_000_000).ToString("D9");
return first12 + Isbn13.CheckDigit(first12);
}
}
public sealed class BookstoreCustomization() : CompositeCustomization(new DateOnlyCustomization(), new BookCustomization());
ICustomization is the way to keep a rule in one place; CompositeCustomization combines several. FromFactory((DateTime d) => ...) tells AutoFixture "create a DateTime first, then convert it". A book produced by the customized fixture:
Book { Isbn = 9780000001078, Title = Title5091d1e5-1556-45d1-ae5e-ef397ff1bbfa, Subtitle = Subtitleea847e9a-0a49-4a55-8404-11a5c3e16821, Author = Authoreef4d46f-cb24-4e35-a864-e4fa72b14dbd, Price = 81, Pages = 17, PublishedOn = 5/13/2028, Language = en }
Published in 2028, 17 pages: anonymous data does not try to be realistic. Where a value matters, the test supplies it itself:
[Fact]
public void Build_sets_only_what_the_test_cares_about()
{
Book dune = fixture.Build<Book>()
.With(b => b.Title, "Dune")
.With(b => b.Author, "Frank Herbert")
.With(b => b.Language, "en")
.Without(b => b.Subtitle)
.Create();
BookSearch search = new([dune, .. fixture.CreateMany<Book>(20)]);
Assert.Equal([dune], search.ByAuthor("herbert"));
}
The .With(b => b.Language, "en") line was missing on the first attempt, and the test failed:
Xunit.MicrosoftTestingPlatform.XunitException: System.Globalization.CultureNotFoundException : Culture is not supported. (Parameter 'name')
language42fb2f2e-42ac-40ae-bd3d-b156f3f30dce is an invalid culture identifier.
Build<T>() does not use the Customize<T> settings made for that type; it starts a setup from scratch. DateOnlyCustomization kept working (it belongs to the DateOnly type), but the ISBN and language rules of BookCustomization were dropped. When you use Build, do not rely on the type's customization; supply the values you need with With.
[AutoData], [InlineAutoData] and AutoNSubstitute
The xUnit v3 integration is a separate package: AutoFixture.Xunit3. The stable 4.19.0 (April 2025) depends on xunit.v3.extensibility.core 1.0.0 or later and worked with xUnit 4.0.3 without problems; 5.0.0-rc.1 is compiled for .NET 8 and .NET 10. All of these packages are licensed under MIT. To avoid repeating the customizations in every test, derive your own attribute:
public sealed class BookstoreAutoDataAttribute() : AutoDataAttribute(CreateFixture)
{
internal static IFixture CreateFixture() =>
new Fixture().Customize(new BookstoreCustomization()).Customize(new AutoNSubstituteCustomization());
}
public sealed class BookstoreInlineAutoDataAttribute(params object[] values)
: InlineAutoDataAttribute(BookstoreAutoDataAttribute.CreateFixture, values);
[BookstoreAutoData] fills all of the test method's parameters from the fixture; [BookstoreInlineAutoData(20)] fills the first parameters with the given values and the rest from the fixture. AutoNSubstituteCustomization puts an NSubstitute fake wherever an interface or an abstract class is requested; [Frozen] gives the same instance to both the test and the code under test. InstitutionalCheckout (post 17), which has six dependencies, is created with a single parameter:
[Theory]
[BookstoreInlineAutoData(20)]
[BookstoreInlineAutoData(150)]
public async Task Institutional_order_is_saved_with_every_copy(
int copies,
[Frozen] IStockService stock,
[Frozen] IOrderRepository orders,
InstitutionalCheckout sut)
{
stock.TryReserve(default!, default, out _).ReturnsForAnyArgs(true);
(CustomerAccount customer, IReadOnlyList<OrderLine> lines) =
new InstitutionalOrderBuilder().WithCopies(copies).AcrossTitles(3).Build();
await sut.PlaceAsync(customer, lines, TestContext.Current.CancellationToken);
await orders.Received(1).SaveAsync(
Arg.Is<Order>(o => o.Lines.Sum(l => l.Quantity) == copies), Arg.Any<CancellationToken>());
}
One surprise: ILogger<InstitutionalCheckout> is an NSubstitute fake, but the abstract TimeProvider is not. Asking the fixture for a TimeProvider returned SystemTimeProvider, that is, the real clock; the year in the order number changes with the year the test runs in. This test does not check the number, so it does not matter here; a test that checks the number should hand in the FakeTimeProvider from post 11 with fixture.Inject(...). Hiding the dependencies a test does not care about is AutoNSubstitute's benefit; it is also its risk: the signature does not tell you what each dependency was filled with. Writing the [Frozen] parameters out explicitly is the test saying "these are the ones that matter".
The version warning is the same as in post 10: AutoFixture.AutoNSubstitute 4.18.1 limits NSubstitute to < 6.0.0, the project uses 6.2.0, and restore gives an NU1608 warning. In 5.0.0-rc.1 the range is [5.0.0, 7.0.0). We stayed on the stable version and accepted the warning on purpose, but only on the NSubstitute reference, not project-wide:
<!-- AutoNSubstitute 4.18.1 declares NSubstitute < 6.0.0; 6.2.0 works here, NU1608 accepted on purpose -->
<PackageReference Include="NSubstitute" NoWarn="NU1608" />
Putting NoWarn on the AutoNSubstitute reference did not work; the warning is attached to the package whose version falls outside the range (NSubstitute).
Together with a Test Data Builder
The Test Data Builder from post 10 carries the meaningful defaults, while Bogus fills the fields the test does not care about with realistic values. For an institutional order (at least 20 copies):
// Meaningful defaults come from the builder, realistic filler (ISBN, price) from Bogus
public sealed class InstitutionalOrderBuilder
{
private CustomerAccount customer = new("library@example.org", IsInstitutional: true, CompletedOrders: 3);
private int copies = InstitutionalCheckout.MinimumQuantity;
private int titles = 2;
public InstitutionalOrderBuilder WithCopies(int total)
{
copies = total;
return this;
}
public InstitutionalOrderBuilder AcrossTitles(int count)
{
titles = count;
return this;
}
public InstitutionalOrderBuilder ForIndividualCustomer()
{
customer = customer with { IsInstitutional = false };
return this;
}
public (CustomerAccount Customer, IReadOnlyList<OrderLine> Lines) Build()
{
List<Book> books = BookFaker.Create(seed: 20).Generate(titles);
OrderLine[] lines =
[
.. books.Select((b, i) => new OrderLine(b.Isbn, copies / titles + (i < copies % titles ? 1 : 0), b.Price)),
];
return (customer, lines);
}
}
The default order is 10 copies each of two books; right at the minimum. The test that checks the limit states only the value it cares about:
[Theory, BookstoreAutoData]
public async Task Nineteen_copies_are_rejected(InstitutionalCheckout sut)
{
(CustomerAccount customer, IReadOnlyList<OrderLine> lines) = new InstitutionalOrderBuilder().WithCopies(19).Build();
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PlaceAsync(customer, lines, TestContext.Current.CancellationToken));
Assert.Equal("Institutional orders need at least 20 copies; got 19.", ex.Message);
}
The division of labor among the three tools: the builder carries what the test is about, Bogus the realism of the data, AutoFixture the dependencies the test does not care about. The Catalog test project has 15 tests; all of them, including the 1,000-book ones, finish in under a second.
In Production
Do not use the global seed. Randomizer.Seed = new Random(...) is a static source shared by all Fakers. With tests running in parallel in xUnit 4.0 (post 05), which test draws which number from the sequence depends on the execution order; fixing the seed does not fix the data. Use UseSeed on every Faker.
When upgrading Bogus across a major version, review the tests that expect literal values. The README treats a change in deterministic output as a breaking change and usually points it out in the release notes; staying within the same major version keeps the output stable.
Do not use Bogus data in a production seed. If 1,000 fake customers generated for a demo environment mix with real users, they cannot be told apart; and if Email() produces real domains, e-mails may even go out to those addresses. Keep Bogus in the test project and in a separate demo data tool, out of Bookstore.Api's references.
Common Pitfalls
Random data without a seed. The Predict First of post 10 showed it: 6 red runs out of 30. If you cannot reproduce a red run, you cannot fix the test.
Asserting a generated value literally. The test from Predict First; even with a fixed seed, it breaks when the rule order or the Bogus version changes.
Not knowing that Build<T>() skips customizations. The CultureNotFoundException above; the failure shows up somewhere else, with an unrelated message.
Hiding the test's intent with AutoData. When every parameter comes from the fixture, the test does not tell you which value determines the result; as the TimeProvider example shows, you cannot even see whether a dependency is real or fake. Supply the value that affects the result explicitly, with InlineAutoData, a builder or inside the test.
A Small Experiment
★☆☆ Add a "bestseller" RuleSet to BookFaker: price between 30 and 60, publication date within the last two years. Test it with Generate(20, "default,bestseller"); what happens when you forget "default"?
★★☆ Write Every_turkish_author_is_found_by_a_lowercase_search for book titles as well (BookSearch.ByTitle). The Turkish locale's title data is half Latin; is this test as valuable as the one for authors?
★★★ Upgrade the Catalog test project to AutoFixture 5.0.0-rc.1 (AutoFixture, AutoFixture.Xunit3, AutoFixture.AutoNSubstitute). Is DateOnlyCustomization still needed, did NU1608 go away, and which test changed behavior?
In the next post we move to another test framework: NUnit 5. For someone who knows xUnit: the constraint model, TestCase/TestCaseSource, SetUp/OneTimeSetUp, parallelism and the breaking changes of 5.0, released on September 27, 2026; by upgrading an old NUnit 4 project to 5.0.