28 November
Fake data substitution for tests in C# with Bogus
Programming
min. read
Unit tests are always the most critical part of the application, as they are the one step (from hopefully many) that will prevent system failure when deploying in production, but sometimes designing a happy path for Customer data is tiresome especially when it’s hard to predict what real users data will look like.
In the previous article we discussed Verify as a way to ensure quick iteration of the tests without writing a lot of code, today we will fill this data with Bogus.
Bogus the data substitution framework with fake data.
In this article, we had the following Customer record and today we will try to generate a lot of test examples from a small code sample, and test it with the Verify.
public record Customer(
string Name,
string Address,
string City,
string State,
string ZipCode,
string Country,
string Phone,
string Email,
string CreditCardNumber,
string CreditCardExpiration,
string CreditCardCVV,
string BillingAddress
);
Let’s write a simple test…
[Test]
public void Customer_WithAllPropertiesSet_Add_IsValid()
{
// Arrange
var customer = new Customer(
"John Doe",
"123 Main St",
"Anytown",
"CA",
"12345",
"USA",
"555-555-5555",
"[email protected]",
"1234567890123456",
"01/2025",
"123",
"123 Main St"
);
// Act
var result = Database.AddToDatabase(customer);
// Assert
Verifier.Verify(result).ToTask().Wait();
}
The issue with that test is that it tests only for one single John Doe, not anyone else. We don’t know if it will cover it any other example of our data. For example longer names, non-US postal codes, or emails in other domains. So we will generate the data with Bogus!
To start we need to create an instance of the Faker object from the Bogus library. And we can add rules to this object. After we added all rules, we can generate the object.
var bogus = new Bogus.Faker<Customer>();
bogus.CustomInstantiator(faker => new Customer(
faker.Person.FullName,
faker.Address.StreetAddress(),
faker.Address.City(),
faker.Address.State(),
faker.Address.ZipCode(),
faker.Address.Country(),
faker.Phone.PhoneNumber(),
faker.Person.Email,
faker.Finance.CreditCardNumber(),
faker.Date.Future().Month + "/" + faker.Date.Future(5).Year,
faker.Finance.CreditCardCvv(),
faker.Address.StreetAddress()
));
var customer = bogus.Generate();
Splendid!
Integration with Verify
As you would probably see when running the above code yourself. Your will notice that you get different results each time. To prevent that we need to assign a static seed to the Bogus randomizer. To do that you can use the following.
Bogus.Randomizer.Seed = new Random(8675309);
Unfortunately, you will very quickly run into issues, when trying to run more than one test at once, the seed will remain, but each test will use the same static shared Randomizer and result in different results. This will be especially visible when running more than one test in parallel or test out of order.
To prevent that we need to make sure to provide a new instance of the Randomizer for each Faker instance. This can be done via UseSeed
method on Faker class.
var bogus = new Bogus.Faker<Customer>();
bogus.UseSeed(123);
Creates a seed locally scoped within this Faker{T} ignoring the globally scoped Randomizer.Seed. If this method is never called the global Randomizer.Seed is used.From Bogus source documentation
This should make sure that our Verify will work as intended for our tests, but what if we want to have more than one set of data for our tests?
Random seed for each test
To achieve that and keep the predictability of our tests we can do 2 things. Add a for loop into our test, but this would be counter-productive in the long run.
- We would not be able to test only for one case
- Verify overwrites the result file if executed more than once.
- Re-runs of single test are not possible
To combat that we will use a random seed from the test name string.
In NUnit we can TestContext.CurrentContext.Test.FullName; get the full name of the test and if we have parameters of that test they will be part of the string.
[TestCase(1)]
public void Customer_WithAllPropertiesSet_Add_IsValid(int index)
{
TestContext.CurrentContext.Test.FullName;
// TestProject1.Tests.Customer_WithAllPropertiesSet_Add_IsValid(1)
So the straightforward thing would be to cast this string to get them GetHashCode and call it a day! But this will trigger a very hidden C# feature, that is GetHashCode will result in a different hash for each run because it uses a different initializer for each run.
To prevent that we need to implement our custom GetHashMethod
From stack overflow: https://stackoverflow.com/a/36845864/2531209
public static class StringExtensionMethods
{
public static int GetStableHashCode(this string str)
{
unchecked
{
int hash1 = 5381;
int hash2 = hash1;
for(int i = 0; i < str.Length && str[i] != '\0'; i += 2)
{
hash1 = ((hash1 << 5) + hash1) ^ str[i];
if (i == str.Length - 1 || str[i+1] == '\0')
break;
hash2 = ((hash2 << 5) + hash2) ^ str[i+1];
}
return hash1 + (hash2*1566083941);
}
}
}
Full code sample
public record Customer(
string Name,
string Address,
string City,
string State,
string ZipCode,
string Country,
string Phone,
string Email,
string CreditCardNumber,
string CreditCardExpiration,
string CreditCardCVV,
string BillingAddress
);
public static class StringExtensionMethods
{
public static int GetStableHashCode(this string str)
{
unchecked
{
int hash1 = 5381;
int hash2 = hash1;
for(int i = 0; i < str.Length && str[i] != '\0'; i += 2)
{
hash1 = ((hash1 << 5) + hash1) ^ str[i];
if (i == str.Length - 1 || str[i+1] == '\0')
break;
hash2 = ((hash2 << 5) + hash2) ^ str[i+1];
}
return hash1 + (hash2*1566083941);
}
}
}
public class Tests
{
private static IEnumerable<object> CaseSource()
{
return Enumerable.Range(0, 100).Cast<object>();
}
[Test]
[TestCaseSource(nameof(CaseSource))]
public void Customer_WithAllPropertiesSet_Add_IsValid(int index)
{
var testCaseHashCode = TestContext.CurrentContext.Test.FullName.GetStableHashCode();
var bogus = new Bogus.Faker<Customer>();
bogus.UseSeed(testCaseHashCode);
bogus.CustomInstantiator(faker => new Customer(
faker.Person.FullName,
faker.Address.StreetAddress(),
faker.Address.City(),
faker.Address.State(),
faker.Address.ZipCode(),
faker.Address.Country(),
faker.Phone.PhoneNumber(),
faker.Person.Email,
faker.Finance.CreditCardNumber(),
faker.Date.Future().Month + "/" + faker.Date.Future(5).Year,
faker.Finance.CreditCardCvv(),
faker.Address.StreetAddress()
));
var customer = bogus.Generate();
Console.WriteLine(customer.ToString());
// Act and assert
}
}
Support for an international audience
By default, Bogus will generate random data with a single language in mind, but we can easily change that, by passing the locale name into Faker!
At the moment of writing Bogus supports around 50 locales! And we can easily test for all of them.
// Assuming we passed index as in the previous example
var locale = Database.GetAllLocales().Skip(index % Database.GetAllLocales().Count()).First();
var bogus = new Bogus.Faker<Customer>(locale);
Locale Code | Language | Locale Code | Language |
---|---|---|---|
af_ZA | Afrikaans | fr_CH | French (Switzerland) |
ar | Arabic | ge | Georgian |
az | Azerbaijani | hr | Hrvatski |
cz | Czech | id_ID | Indonesia |
de | German | it | Italian |
de_AT | German (Austria) | ja | Japanese |
de_CH | German (Switzerland) | ko | Korean |
el | Greek | lv | Latvian |
en | English | nb_NO | Norwegian |
en_AU | English (Australia) | ne | Nepalese |
en_AU_ocker | English (Australia Ocker) | nl | Dutch |
en_BORK | English (Bork) | nl_BE | Dutch (Belgium) |
en_CA | English (Canada) | pl | Polish |
en_GB | English (Great Britain) | pt_BR | Portuguese (Brazil) |
en_IE | English (Ireland) | pt_PT | Portuguese (Portugal) |
en_IND | English (India) | ro | Romanian |
en_NG | Nigeria (English) | ru | Russian |
en_US | English (United States) | sk | Slovakian |
en_ZA | English (South Africa) | sv | Swedish |
es | Spanish | tr | Turkish |
es_MX | Spanish (Mexico) | uk | Ukrainian |
fa | Farsi | vi | Vietnamese |
fi | Finnish | zh_CN | Chinese |
fr | French | zh_TW | Chinese (Taiwan) |
fr_CA | French (Canada) | zu_ZA | Zulu (South Africa) |
Let's talk
I agree that my data in this form will be sent to [email protected] and will be read by human beings. We will answer you as soon as possible. If you sent this form by mistake or want to remove your data, you can let us know by sending an email to [email protected]. We will never send you any spam or share your data with third parties.
I agree that my data in this form will be sent to [email protected] and will be read by human beings. We will answer you as soon as possible. If you sent this form by mistake or want to remove your data, you can let us know by sending an email to [email protected]. We will never send you any spam or share your data with third parties.