Skip to main content

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 pointConfigures
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();
MemberDoes
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:

MemberDoes
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
OperationNotes
data.explain, data.buildcarry data.type, data.object_sequence, data.member_count and data.construction_source (Reflection or the factory source)
data.create, data.create_manyparent the data.provision operation; data.create adds data.identity
data.build_manyadds data.type and data.count
data.create_manyadds data.type, data.result_type and data.count
data.provisionadds data.input_type, data.result_type, data.provisioner, data.identity, data.owned and data.value_id
data.cleanupruns 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; With must 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 Cleanup that 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 ProtoDataException naming both sources. Two different provisioners for one input/result pair both register and fail when that pair is used.