An active customer places a valid order. Before invoking the ordering operation, we must put that customer in the database, select products, build the request, calculate the expected outcome, and prepare to observe the messages. Each of those decisions belongs to the arrangement.
About the examples: Meridian Ordering is a private training reference project. The inline listings illustrate the techniques; the full C# and Python repositories are not currently public.
The complete order walkthrough shows them together. Here we will examine why the arrangement uses random values, how it keeps those values inside the intended scenario, and how its helpers work.
The purpose remains confidence to go to production. The team first establishes the business requirements, acceptance criteria, functional requirements, and non-functional requirements, then verifies that the scenarios and expectations correctly cover them. The arrangement must create the conditions those scenarios claim to test.
Decide what must be deliberate and what may vary
For the successful order, these conditions are deliberate:
- The customer exists and is Active.
- Two different catalog products are ordered at quantities three and two.
- The catalog prices keep the order at or below the review threshold.
- The expected amounts follow the formula: quantity times catalog price.
The customer identifier, order reference, and customer email can vary. Giving each run its own identities lets it create, observe, and remove its own records. That matters when developers share infrastructure, tests run in parallel, or several pull-request jobs overlap. A fixed customer could be made inactive or removed by another test while this test was using it.
Random generation serves several purposes:
| Use | What varies | What stays deliberate |
|---|---|---|
| Record ownership | Customer identifiers, email identities, order references | This run owns these records and cleanup |
| Valid input variation | Names, addresses, eligible catalog choices | Required shape and the scenario’s conditions |
| Invalid-value reporting | An unknown field name or opt-in value | The value violates a specific rule and must appear in the refusal |
| Closed vocabulary | A selected enum member or defined choice | The value belongs to the system’s allowed set |
Random values provide sampled variation and practical protection against collisions. They do not enumerate every input or replace named threshold and failure scenarios.
Separate reusable randomness from domain shapes
Meridian uses two layers. RandomDataGenerator produces letters, digits, numbers, and samples. DataGenerators combines those primitives into this system’s values.
Here is the complete string-building primitive:
/// Arrange: generate characters from the chosen alphabet.
private static string CreateFromAlphabet(string alphabet, int length)
{
var randomCharacters = new StringBuilder(length);
for (int characterIndex = 0; characterIndex < length; characterIndex++)
{
randomCharacters.Append(alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]);
}
return randomCharacters.ToString();
}
For every character, it draws an index into the alphabet. The caller chooses the alphabet and length. The generator supplies methods for lowercase letters, alphanumeric strings, digit strings, and proper-case words.
The domain layer gives those draws meaning:
/// Arrange: reserve the customer and order identities.
public static ExpectedOrderPlacementIdentifiers CreateExpectedOrderPlacementIdentifiers()
{
return new ExpectedOrderPlacementIdentifiers(
CustomerId: CreateRandomNonexistentCustomerId(),
OrderReference: CreateRandomOrderReference());
}
/// Arrange: produce an order-reference-shaped value.
public static string CreateRandomOrderReference()
{
return $"REF-{RandomDataGenerator.GetRandomAlphaNumericString(12).ToUpperInvariant()}";
}
/// Arrange: create the customer email identity used by capture assertions.
public static string CreateRandomEmailAddress()
{
return $"{RandomDataGenerator.GetRandomLowercaseAlphaString(8)}" +
$".{RandomDataGenerator.GetRandomLowercaseAlphaString(10)}" +
$"@{RandomDataGenerator.GetRandomLowercaseAlphaString(8)}.example";
}
This email generation gives the test a fresh address to record on its customer and compare with the outbound request. Meridian captures that request without sending it. A real-delivery test needs a routable address or alias in a domain whose inbox the test environment can read. Keep the controlled delivery address separate from the customer’s original address; the email article explains both observations.
The primitive generator also shows exact-decimal amounts drawn as scaled integers, dates drawn as whole seconds within a range, booleans, and enum choices. Its scaled amounts and date offsets must fit the integer ranges used by those implementations.
The domain generator supplies the system-specific lengths and formats. A length scenario can request an exact length by passing the same minimum and maximum. Unknown opt-in choices are eight random lowercase letters, whose length rules out the recognized Yes and No values. The refusal must name the value supplied. That comparison would expose a refusal that substitutes a fixed sample value.
Select catalog data without changing the scenario
The placement request contains SKU and quantity. It contains no price. Production reads the product record from the database at placement time and uses that price. The test reads the catalog during Arrange to establish its expected amounts independently of the returned result.
Here is the complete constrained selection method:
/// Arrange: select distinct eligible products from the database.
public static List<CatalogProduct> GetRandomCatalogProducts(
int productCount,
decimal? maximumUnitPrice = null,
string? connectionString = null)
{
// Sampled WITHOUT replacement, so a multi-line arrangement can never accidentally
// repeat a product (BR-3). Returned in SKU order because the order store returns
// recorded lines ORDER BY Sku - SKU-ordered expectations then match positionally.
//
// maximumUnitPrice makes a scenario's implicit constraint explicit: a test
// whose outcome depends on its derived total (e.g. staying at or under the BR-6
// threshold so the order is Placed) caps the price so ANY draw satisfies it.
List<CatalogProduct> catalogProducts = GetCatalogProducts(connectionString);
if (maximumUnitPrice is not null)
{
catalogProducts = catalogProducts
.Where(catalogProduct => catalogProduct.UnitPrice <= maximumUnitPrice.Value)
.ToList();
}
Assert.True(
catalogProducts.Count >= productCount,
$"Expected at least {productCount} products in the seeded catalog" +
$"{(maximumUnitPrice is null ? string.Empty : $" with unit price at most {maximumUnitPrice}")} " +
$"but found {catalogProducts.Count} - run the setup tool or reseed the catalog");
return RandomDataGenerator.GetRandomItems(catalogProducts, productCount)
.OrderBy(catalogProduct => catalogProduct.Sku, StringComparer.Ordinal)
.ToList();
}
Three details matter:
- Filtering happens before sampling. Every eligible product has a price small enough for the intended Placed scenario.
- Sampling happens without replacement. The arrangement cannot accidentally select one product twice.
- Selected records are returned in SKU order, matching the order used when reading stored lines for positional comparison.
For quantities three and two, the maximum eligible unit price is the review threshold divided by five. Even if both products cost that maximum, their combined line totals remain at the threshold. Every permitted draw preserves this condition.
Here is the complete sample implementation:
/// Arrange: shuffle a copy and take the requested distinct items.
public static List<T> GetRandomItems<T>(IReadOnlyList<T> items, int count)
{
var shuffledItems = new List<T>(items);
for (int itemIndex = shuffledItems.Count - 1; itemIndex > 0; itemIndex--)
{
int swapIndex = RandomNumberGenerator.GetInt32(itemIndex + 1);
(shuffledItems[itemIndex], shuffledItems[swapIndex]) = (shuffledItems[swapIndex], shuffledItems[itemIndex]);
}
return shuffledItems.GetRange(0, count);
}
It copies the input, shuffles it, and takes the requested number of items. The caller must request a valid count; the catalog helper checks that enough eligible products exist. A small eligible set may force the same pair on successive runs. Random selection does not promise different products every time.
Guard scenarios that depend on an exact value
The exact-threshold scenario needs a total of $10,000.00. It uses four units of the Calibration Rig, reads the current price, and checks the arithmetic before Act:
/// Arrange: confirm that the catalog supports the named threshold scenario.
var catalogProduct = DataManagerForTest.GetCatalogProduct(DataGenerators.CalibrationRigSku);
int atThresholdQuantity = 4;
decimal expectedOrderTotal = catalogProduct.UnitPrice * atThresholdQuantity;
Assert.True(
expectedOrderTotal == AcceptanceTestConstants.HeldForReviewThresholdTotal,
$"AC-09 arrange guard: {atThresholdQuantity} x {catalogProduct.Sku} totals {expectedOrderTotal}, not exactly " +
$"{AcceptanceTestConstants.HeldForReviewThresholdTotal} - the seeded catalog no longer supports this boundary scenario");
These are selected statements from the complete threshold test. The guard uses Assert.True during Arrange to check the scenario’s premise. Outcome comparisons belong after Act. If a seed change moves the total away from the threshold, the guard explains why this run cannot demonstrate the named rule.
Use factories for small models and builders for wide requests
A customer record needs an identifier, status, and email. A narrow factory expresses that arrangement directly. A registration request has many fields; repeating defaults in every test would obscure its distinguishing condition.
Meridian’s builders register defaults with For, set scenario values with Set, and construct requests with Build. Here is the order request builder:
/// Arrange: register a default line; owned identifiers remain explicit.
public static ModelBuilder<OrderPlacementRequest> OrderPlacementRequestBuilder { get; } =
new ModelBuilder<OrderPlacementRequest>()
.For(request => request.OrderPlacementLineItems, () =>
[new OrderPlacementLineItem(sku: DataManagerForTest.GetRandomCatalogProducts(1)[0].Sku, quantity: 1)]);
OrderReference and CustomerId deliberately have no defaults. Each test reserves its identifiers before starting and supplies them explicitly. A forgotten identifier then fails during arrangement before records can be created under an identity the test has not reserved for cleanup.
The line default helps only when it preserves the intended scenario. An ordinary accepted-order test explicitly sets price-constrained products. A rollback test sets a product read from its private database. The scenario owns those conditions.
The request builders show registration defaults and a derived minimal request. Each default is a delegate evaluated at Build time, so two builds draw fresh values. The minimal builder replaces nine optional defaults with null while leaving the full builder intact. CompanyName remains part of the required registration shape.
Here are the complete Set and value-resolution methods:
/// Arrange: return a builder with this scenario value.
public ModelBuilder<TModel> Set<TValue>(Expression<Func<TModel, TValue>> propertyExpression, TValue value)
{
var scenarioValuesBySetProperty =
new Dictionary<string, object?>(_scenarioValuesBySetProperty, StringComparer.OrdinalIgnoreCase)
{
[PropertyNameOf(propertyExpression)] = value,
};
return new ModelBuilder<TModel>(scenarioValuesBySetProperty, _defaultDelegatesByForProperty, _constructorParameterInfos);
}
/// Arrange: resolve explicit values first, then fresh defaults.
private object? ResolveValue(ParameterInfo constructorParameterInfo)
{
// Constructor parameters are matched to properties by name, case-insensitively
// (orderReference <-> OrderReference), the same convention the models follow.
string propertyName = constructorParameterInfo.Name!;
if (_scenarioValuesBySetProperty.TryGetValue(propertyName, out object? scenarioValue))
{
return scenarioValue;
}
if (_defaultDelegatesByForProperty.TryGetValue(propertyName, out Func<object?>? defaultValueDelegate))
{
return defaultValueDelegate();
}
throw new ModelBuilderPropertyNotSetException(
$"The builder was given no value for {typeof(TModel).Name}.{propertyName} " +
$"(type {constructorParameterInfo.ParameterType.Name}). " +
"Set the property in the test's Arrange, or register a For default where the builder is created.");
}
Set copies the scenario-value dictionary and returns a new builder. For similarly copies the default-delegate dictionary. Existing instances are treated as immutable. Tests can share a registered builder while retaining their own overrides. An explicit null supplied with Set takes precedence over a default. An unresolved constructor parameter throws an exception naming the missing property.
The ModelBuilder includes For, Build, constructor selection, and property-expression handling. It constructs these models through their public constructors and matches parameter names to property names without case sensitivity. That convention fits these models; other constructor designs need their own consideration.
Reserve cleanup identities before creating records
The containing test class reserves the identifiers in a field. Teardown uses those identifiers even if arrangement fails or an assertion stops the method:
/// Arrange: establish cleanup ownership before creating records.
private readonly ExpectedOrderPlacementIdentifiers _expectedOrderPlacementIdentifiers =
DataGenerators.CreateExpectedOrderPlacementIdentifiers();
The complete test class shows initialization and teardown. The order reference identifies the broker message too, but broker consumption also needs isolation. An identifier cannot recover a message another consumer has already removed from a shared queue.
Make failures possible to investigate
These generators use cryptographic draws and expose no replay seed. Re-running a failed test generates new identities and may select new eligible data. For diagnosis, retain the request, selected catalog values, expectations, and observed results when they matter to the failure. Meridian’s comparison reports include many expected and actual values; they do not provide a complete replay record for every failure, particularly one interrupting arrangement or gathering.
A discovered input-sensitive bug needs a maintained scenario or constrained regression case. Preserve the condition that caused it while continuing to use per-run identities for ownership of unrelated records.
The PWI Test Structure and Organization chapter and Test Naming Conventions chapter provide the surrounding discipline. A reader should be able to find Arrange, Act, and Assert and understand the condition and outcome from the scenario name. For resubmission, the deliberate second Act stays visible because the repeated request is the behavior being verified.
