Test-data defaults
Defaults live in modules: small classes, ideally one per feature area, next to the tests that use them.
public sealed class InvoiceDataDefaults : IProtoDataDefaultsModule
{
public void Configure(ProtoDataConfiguration data)
{
data.Values.Use<InvoiceId>(context => new InvoiceId(context.NextGuid()));
data.For<Invoice>()
.Default(x => x.Currency, "EUR")
.Default(x => x.Customer, context => $"customer-{context.TestId}");
}
}
builder.AddData(data => data.AddDefaults<InvoiceDataDefaults>());
You can configure inline in AddData(data => ...). The trace then reports the source as "Host configuration", while a module's values are attributed to the module's full type name.
The configuration surface
| Member | Does |
|---|---|
data.Values | a ProtoDataValueConfiguration for providers that apply to every member of a type |
data.For<T>() | a ProtoDataTypeConfiguration<T> for one object type |
data.RedactValueType<TValue>() | redacts every resolved value of the type in ProtoTrace |
data.AddValueResolver(resolver) | adds an IProtoDataValueResolver after exact member and type providers |
data.AddDefaults<TModule>() | adds one module (where TModule : IProtoDataDefaultsModule, new()) |
data.AddDefaultsFromAssembly(assembly) | discovers modules in an assembly and adds them in full-name order |
For<T>().Default(member, value) | a constant member default |
For<T>().Default(member, provider) | a member default computed from a ProtoDataValueContext |
For<T>().ConstructUsing(factory) | builds the type through a domain factory instead of reflection |
For<T>().Redact(member) | hides one member's value in ProtoTrace while keeping its provenance |
Values.Use<T>(provider) | supplies a value for every member of T |
AddDefaultsFromAssembly loads public, concrete, non-generic modules with a public parameterless constructor. It runs them ordered by type full name. Registering the same member, type or factory twice throws ProtoDataException naming both sources; there is no last-one-wins, so two modules cannot silently fight over a value. Resolvers are the exception: they may repeat and all run in order.
Where a value comes from
For each member, the first of these that applies wins:
With(...)in the test.- A member default:
data.For<T>().Default(x => x.Member, …), resolved by walking the target type and its base types. - A type provider:
data.Values.Use<TValue>(…), matched on the exact type only. - A custom resolver:
IProtoDataValueResolver, in registration order; a throwing resolver is wrapped inProtoDataException. - The constructor parameter's default value, on the constructor route only. The declaration's default is the value the author asked for, so a generation never replaces it, including
nullfor a nullable parameter. - A safe built-in, for a member with no constructor default:
nullfor a nullable member or a nullable-annotated reference, a generated string forstring, a generatedGuid, and an empty array or list for array,IEnumerable<T>,IReadOnlyCollection<T>,IReadOnlyList<T>,ICollection<T>,IList<T>andList<T>.
If none apply, ProtoDataException names the member. Numbers, enums, booleans, dates and your own value objects are deliberately not on the built-in list: provide them explicitly or through a default.
A member resolving shows its source in Explain() and the trace. The shape of the output, with illustrative values:
var plan = Proto.Context.Data().For<Invoice>()
.With(x => x.Total, 125m)
.Explain();
// Total = 125 [Explicit]
// Currency = EUR [MemberDefault]
// Id = 3fa85f64-5717-4562-b3fc-2c963f66afa6 [TypeProvider]
// Each entry also names its source: the module type for defaults, "Host configuration" for inline setup.
Constant or provider, member or type? Use the provider overload when the value must be unique per test, a member default when it belongs to one object type, a type provider when every member of a type shares it, and a resolver when the convention spans many types.
Member defaults
data.For<InviteMemberRequest>()
.Default(request => request.Email,
context => $"member-{context.TestId}-{context.ObjectSequence:D4}@example.test");
Use the provider overload whenever the value must be unique: a constant email address breaks the moment two tests run in parallel.
Type providers
A type provider supplies a value for every member of a type, on any object:
data.Values.Use<Money>(_ => new Money(10m, "EUR"));
data.Values.Use<TenantId>(context => new TenantId(context.NextGuid()));
The value context
Both kinds of provider, and every member default, receive a ProtoDataValueContext:
| Member | Is |
|---|---|
TestId | the running test's id |
ObjectSequence | the object's position among objects built in this test |
TargetType, ValueType, MemberName | what is being resolved |
Services | the test's service provider |
NextGuid() | a deterministic GUID |
NextString() | a deterministic string like Invoice.Reference-0001-00 |
Ref<T>(identity) | a value provisioned earlier in this test, from the identity map |
NextGuid() is the first 16 bytes of a SHA-256 over "{TestId}|{ObjectSequence}|{TargetType.FullName}|{MemberName}|{counter}", and NextString() is "{TargetType.Name}.{MemberName}-{ObjectSequence:D4}-{counter:D2}". Each member resolution gets a fresh context, so the counter starts at 0 per member. The same test produces the same values on each run. Different tests get different values. For a name outside a member default, such as a tenant or an operator, use Proto.Context.UniqueName("tenant"), which derives tenant-{TestId} from the same test id.
Domain factories
When a type enforces invariants through a factory method, let ProtoTest call it instead of a constructor:
data.For<Invoice>()
.ConstructUsing(context => Invoice.Create(
context.Value<InvoiceId>(nameof(Invoice.Id)),
context.Value<Money>(nameof(Invoice.Total))));
context.Value<TValue>(memberName) resolves that member through the normal pipeline, so With(x => x.Total, …) in a test still flows into the factory. ProtoDataConstructionContext exposes Services too.
The factory rules:
- One factory per type. A second
ConstructUsingfor the same type throws. - The factory should consume every input it needs through
Value<TValue>(...). A factory that does not consume an explicitWithvalue throws, as does resolving the same member name as two different types. - A factory returning
nullor an incompatible value throws; exceptions from inside the factory are wrapped with context. Explain()for a factory type lists only the explicitWith(...)values and the construction source (ConstructionSource), because the factory resolves the rest whenBuild()runs.
Custom resolvers
For conventions that span many types, such as fixing every property called CreatedAt to one clock value:
public sealed class FixedClockResolver : IProtoDataValueResolver
{
private static readonly DateTimeOffset Now = new(2026, 1, 1, 9, 0, 0, TimeSpan.Zero);
public bool TryResolve(ProtoDataValueContext context, out ProtoDataResolvedValue value)
{
if (context.ValueType == typeof(DateTimeOffset) && context.MemberName.EndsWith("At"))
{
value = new ProtoDataResolvedValue(Now, "fixed clock");
return true;
}
value = null!;
return false;
}
}
builder.AddData(data => data.AddValueResolver(new FixedClockResolver()));
Resolvers run after member and type providers, in registration order, and the second argument of ProtoDataResolvedValue is the source shown in Explain() and the trace.
Keeping values out of the trace
Resolved values are recorded in ProtoTrace. Redact the sensitive ones. The trace still shows where the value came from, without the value:
data.For<User>().Redact(x => x.AccessToken); // one member
data.RedactValueType<Password>(); // every value of a type
- A member is redacted when its declaring type, or any base type, matches the redacted type.
- A value is redacted when its declared or runtime type matches, including base types and interfaces;
Nullable<T>is unwrapped, and a collection is redacted when its element type is. - Non-redacted values are still walked as a graph: nested redacted members or types become
"[REDACTED]", and reference cycles become"[circular]". The graph is copied only when something was redacted.
Limits
- Redaction protects ProtoTrace only. It is best effort and says nothing about application logs, HTTP bodies or reports.
- Factory inputs come from the same pipeline. A factory cannot invent a value the pipeline would reject; unresolved members still fail.
- Module discovery is shape-based. A module needs a public parameterless constructor and a public concrete type; nested or generic modules are skipped.
- Member defaults apply to the member's declaring type and its base types, but a type provider matches the exact member type only; use a resolver for a family of types.
Links
- Data overview - the package page and quick start.
- Provisioners -
CreateAsync, the identity map and cleanup. - Execution context -
Proto.Contextand what a test can reach.