Skip to main content

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​

MemberDoes
data.Valuesa 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:

  1. With(...) in the test. Source: Explicit.
  2. A member default: data.For<T>().Default(x => x.Member, …), resolved by walking the target type and its base types. Source: MemberDefault.
  3. A type provider: data.Values.Use<TValue>(…), matched on the exact type only. Source: TypeProvider.
  4. A custom resolver: IProtoDataValueResolver, in registration order. A throwing resolver is wrapped in ProtoDataException. Source: CustomResolver.
  5. 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 null for a nullable parameter. Source: ConstructorDefault.
  6. A safe built-in, for a member with no constructor default. Source: BuiltIn:
    • null for a nullable member or a nullable-annotated reference
    • a generated string for string, and a generated Guid
    • an empty array or list for array, IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T>, IList<T> and List<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.

Each member shows its source in Explain() and the trace. This is 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.

Which one to use:

  • the provider overload when the value must be unique per test
  • a member default when the value belongs to one object type
  • a type provider when every member of a type shares it
  • a resolver when the convention covers 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:

MemberIs
TestIdthe running test's id
ObjectSequencethe object's position among objects built in this test
TargetType, ValueType, MemberNamewhat is being resolved
Servicesthe test's service provider
Clockthe test's clock, so a default stamp follows Proto.Context.Clock and moves when the test advances it
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}". 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 ConstructUsing for the same type throws.
  • The factory should consume every input it needs through Value<TValue>(...). A factory that does not consume an explicit With value throws, as does resolving the same member name as two different types.
  • A factory returning null or an incompatible value throws. Exceptions from inside the factory are wrapped with context.
  • Explain() for a factory type lists only the explicit With(...) values and the construction source (ConstructionSource), because the factory resolves the rest when Build() runs.

Custom resolvers​

For conventions that cover 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. 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, and 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.