Test data
ProtoTest.Data builds test objects from deterministic defaults. Write only the values the test is about. Hand the object to your application to create it for real.
[ProtoTest]
public void An_overdue_invoice_of_125()
{
var invoice = Proto.Context.Data().For<Invoice>()
.With(x => x.Total, 125m)
.With(x => x.Status, InvoiceStatus.Overdue)
.Build();
Assert.That(invoice.Total, Is.EqualTo(125m));
}
Run it with dotnet test. A green run prints Passed An_overdue_invoice_of_125, and the trace records a data.build operation with a data.value.resolve event per member.
What it adds
Numbers, enums, dates and your own value objects are never invented: if nothing supplies a member, the build fails with a ProtoDataException naming it, rather than a silently wrong 0.
A reader sees immediately that the test above cares about an overdue invoice of 125. Every other property, such as the id, the customer or the currency, comes from defaults you configure once.
Install
dotnet add package ProtoTest.Data
ProtoTest targets .NET 8, 9 and 10; the template defaults to net10.0 unless --framework is passed. ProtoTest.Data depends only on ProtoTest.Core.
Compose
builder
.AddData(data => data.AddDefaults<InvoiceDataDefaults>())
.AddDataProvisioner<Invoice, InvoiceProvisioner>();
AddData registers the Data capability (ProtoCapabilityKinds.Data) and a scoped IProtoData, one per test. Repeated calls share one registry. Each AddData callback runs once, and shared registrations are kept only once. The reference lists the signatures, the configuration entries and the context API.
The tasks
The An_overdue_invoice_of_125 test above is the whole pattern: state the values the test is about, build, assert.
var project = Proto.Context.Data()
.For<CreateProjectRequest>()
.With(request => request.Name, "atlas")
.Build();
Build() is enough when the test only needs an object; when the application must actually create it, use CreateAsync and a provisioner.
Going further
Several at once
The configure callback receives each item's builder and its zero-based index:
var projects = Proto.Context.Data().For<CreateProjectRequest>()
.BuildMany(3, (project, index) => project.With(x => x.Name, $"atlas-{index}"));
var members = await Proto.Context.Data()
.For<InviteMemberRequest>()
.CreateManyAsync<MembershipResponse>(7);
Values set with With before BuildMany apply to every item.
Constructors
ProtoTest creates objects through the single public constructor or the parameterless one. It matches constructor parameters to properties by name, ignoring case. Then it sets the remaining writable properties. Records work naturally through their primary constructor. Multiple public constructors without a parameterless route, a With on a member the chosen route cannot assign, and a non-writable property that is named in With all throw ProtoDataException. For types that protect their invariants, register a domain factory instead.
The identity map
CreateAsync and CreateManyAsync results are tracked per test under the identity string the provisioner returned, and Ref<T> resolves them again, typically to wire a foreign key:
var projects = await Proto.Context.Data()
.For<CreateProjectRequest>()
.CreateManyAsync<ProjectResponse>(2);
var first = Proto.Context.Data().Ref<ProjectResponse>(projects[0].Id);
Matching, scoping and failure rules live with the provisioner contract. In short: identities are case-sensitive, zero or ambiguous matches throw, only created (never built) values are in the map, and the map never crosses tests.
Explain
When a value surprises you, ask where it came from, before constructing anything. The shape of the output, with illustrative values:
var plan = Proto.Context.Data().For<Invoice>()
.With(x => x.Total, 125m)
.Explain();
foreach (var value in plan.Values)
Console.WriteLine($"{value.MemberName} = {value.Value} [{value.SourceKind}]");
// Total = 125 [Explicit]
// Currency = EUR [MemberDefault]
// Customer = customer-7F3A [MemberDefault]
// Id = 3fa85f64-5717-4562-b3fc-2c963f66afa6 [TypeProvider]
Each ProtoDataValueExplanation carries MemberName, ValueType, Value, SourceKind and Source, which for defaults is the module that registered them. SourceKind is one of Explicit, MemberDefault, TypeProvider, CustomResolver, BuiltIn or ConstructorDefault. For a factory type, Explain() lists only the explicit With(...) values plus the construction source, because the factory resolves its inputs when Build() runs.
Reference
public static IProtoHostBuilder AddData(
this IProtoHostBuilder builder,
Action<ProtoDataConfiguration>? configure = null);
public static IProtoHostBuilder AddDataProvisioner<T, TProvisioner>(this IProtoHostBuilder builder)
where TProvisioner : class, IProtoDataProvisioner<T>;
public static IProtoHostBuilder AddDataProvisioner<TInput, TResult, TProvisioner>(this IProtoHostBuilder builder)
where TProvisioner : class, IProtoDataProvisioner<TInput, TResult>;
Options and keys
ProtoTest.Data has no options type and no ProtoTest:Data configuration section. Everything is configured through the AddData callback:
| Entry point | Configures |
|---|---|
data.AddDefaults<TModule>() | a defaults module with a public parameterless constructor |
data.AddDefaultsFromAssembly(assembly) | every public, concrete, non-generic module in an assembly, ordered by full type name (ordinal) |
data.For<T>().Default(...) | member defaults, and domain factories with ConstructUsing(...) |
data.Values.Use<T>(...) | a provider for every member of a type |
data.AddValueResolver(...) | convention resolvers, run in registration order |
data.RedactValueType<TValue>() | every resolved value of a type, in ProtoTrace |
Defaults lists the precedence order and every signature.
Context API
IProtoData data = Proto.Context.Data();
| Member | Does |
|---|---|
For<T>() | starts a ProtoDataObjectBuilder<T> |
Ref<T>(identity = null) | resolves a value CreateAsync provisioned earlier in this test, from the identity map |
On the builder:
| Member | Does |
|---|---|
With(member, value) | sets a scenario-relevant member; returns the same builder |
Explain() | resolves and describes every value without constructing the object |
Build() / BuildMany(count, configure) | constructs in memory only |
CreateAsync() / CreateAsync<TResult>() | builds, provisions through the registered provisioner, and returns the application's value |
CreateManyAsync(count, configure) / CreateManyAsync<TResult>(...) | the same for count independently resolved objects |
In the trace and coverage
Every builder operation is traced with source ProtoTest.Data. The shape of the tree:
data.create · Invoice → MembershipResponse # operation
├─ data.value.resolve · Total = 125 # [Explicit] from With
├─ data.value.resolve · Currency = EUR # [MemberDefault] InvoiceDataDefaults
└─ data.provision · NorthstarMemberProvisioner # child: request → app → member.Id
└─ value item value:membership:42 # identity map entry
| Operation | Notes |
|---|---|
data.explain, data.build | carry data.type, data.object_sequence, data.member_count and data.construction_source (Reflection or the factory source) |
data.create, data.create_many | parent the data.provision operation; data.create adds data.identity |
data.build_many | adds data.type and data.count |
data.create_many | adds data.type, data.result_type and data.count |
data.provision | adds data.input_type, data.result_type, data.provisioner, data.identity, data.owned and data.value_id |
data.cleanup | runs in the release phase with data.type, data.identity, data.provisioner |
Each resolved member writes a data.value.resolve event under the build or explain operation, with data.type, data.member, data.value_type, data.value, data.redacted, data.source_kind and data.source. An unresolved member writes the same event with data.source_kind = "Unresolved" before the exception is thrown.
Provisioned values are tracked as a value item whose id is {type}:{id}; the user-facing form in data.value_id is value:{type}:{id}, for example value:invoice_line:INV-1. The type segment is the CLR type name in snake_case with generic arity dropped (Envelope<InvoiceLine> becomes envelope); without an identity the segment ends in #{n}.
The package emits no observations and ships no coverage collector: its evidence lives in ProtoTrace. Redaction protects that trace graph only; it says nothing about application logs or HTTP bodies.
Skip
The capability is name "Data", kind data (ProtoCapabilityKinds.Data). Skip with:
[RequiresCapability(ProtoCapabilityKinds.Data)]
There are no package-specific skip attributes. See Skip conditions.
Limits
- No invented semantics. As above: an unresolved member fails with the member's name instead of a silently wrong
0. - Reflection needs a public constructor. Multiple public constructors without a parameterless one is an error;
Withmust target a settable property or be consumed by a registered factory. - The identity map is per test and read-only for
Build. Values built in memory are not referenceable, and another test's provisioned data is out of reach. - Ref identity matching is case-sensitive (
StringComparison.Ordinal). - Redaction is best-effort and trace-only. Reference cycles are cut to
[circular], and nothing else is redacted, not logs, not HTTP bodies, not reports. - No retry or transaction semantics. A
Cleanupthat fails is aggregated by the core release path like any other test resource. - Duplicate registrations fail. Registering the same member, type or factory twice throws
ProtoDataExceptionnaming both sources. Two different provisioners for one input/result pair both register and fail when that pair is used.
Links
- Defaults - modules, precedence, factories, resolvers and redaction.
- Provisioners - creating data in your application,
Ref<T>and cleanup. - SQL - a per-test database connection for the created rows.
- The demo creates and checks data end to end in
samples/Northstar.ProtoTest/SheetsJourney.cs, with defaults insamples/Northstar.ProtoTest/NorthstarData.csand registration insamples/Northstar.ProtoTest/Setup.cs.