How the library is tested
To test your own value objects, see Test your value objects. This page is about the library's own suites.
Four suites, each with a distinct job:
- UnitTests — behaviour of generated code, and of every integration package called directly, in ten projects,
one per package family of
.github/ci-families.json, each of which CI runs in a job of its own (ADR-0013):tests/AdCodicem.ValueObjects.UnitTestsfor the core packages, andtests/AdCodicem.ValueObjects.UnitTests.<Family>for each other family (AspNet,EfCore,Data,Serialization,Hosts,GraphQL,Text,Observability,TestData); every other project compiles the sample value objects from the core project's folder, and each runs as a test process of its own. The sample value objects declare each of the 22 underlying types and each option and hook at least once, so that what the generator emits for every one of them runs rather than only compiles. Generated sources are emitted to disk during the build, so they can be read when diagnosing a failure instead of decompiled from memory. Value objects written by hand reach what the generator always replaces, such as the default members of the contracts, and seven small fixture assemblies hold what a test assembly cannot: a generated value object whose module has not been used yet, annotated value objects in an assembly the generator does not run on, generated value objects in an assembly that does not reference the JSON package, generated value objects of every underlying type in an assembly that opts into XML serialization, generated value objects beside a query type HotChocolate's source generator binds, Blazor forms written in Razor markup, and[LoggerMessage]methods Microsoft.Gen.Logging writes, beside value objects classified as personal data. What needs a server and no container runs here too, over TestHost: ASP.NET Core's pipelines, a SignalR hub talking MessagePack with the .NET client, and a protobuf-net.Grpc code-first service and a contract-first gRPC service with the .NET gRPC client; and over SQLite in memory: collections of value objects through Entity Framework Core, every sample value object among them stored and read back, queries over their elements translated, queries on.Valuetranslated to the column, for every sample value object as the same query over its underlying type is, and the CHECK constraints written from the rules: each rule written or left out per provider, SQLite taking every value at the boundaries of the rules it writes and refusing another writer's row, the names, scripts and shapes of table the constraints come in, and the patterns, written in each dialect or failing the model on a construct or an option the database would read apart, the writer checked construct by construct against what .NET makes of the same pattern, and the code of the rule a refused row broke, on SQLite's error and on errors shaped as PostgreSQL's and SQL Server's, every name the convention writes read back to its rule, SQL Server's message in each language it is translated into, a name read past each separator a message may put around it, and a refusal reported while the results of a batch are read, which only a save interceptor sees. Property-based tests run the lawsIValueObject<TSelf, TValue>states over generated input, the values each type accepts drawn by the test-data sampler from the rules it declares, through the arbitraries of the FsCheck package, and the test-data sampler itself draws a value of every sample value object. The AutoFixture, Bogus and FsCheck packages fill objects holding value objects in every shape a member takes. Microsoft.Extensions.AI builds a tool over every sample value object, alone, nullable and in a list, whose schema and whose answer to a refused argument are checked, and a scripted model stands for a real one: no test calls a model, and the request the OpenAI adapter sends in strict mode is captured before it leaves. A Model Context Protocol server lists a tool over every sample value object to the SDK's own client, over a pair of pipes in process, and answers each refused argument with a tool execution error carrying its rule. The Azure Functions converter is called as the isolated worker calls it, over every sample value object, and the problem details middleware runs over invocations written by the test, through the worker's own abstractions, answering each refusal as a minimal API answers it, in the request's language too. HotChocolate schemas built in process take every sample value object as a scalar of its own, from the edges its rules declare to the values they rule out, as literals and variables, and answer each refusal with its code, in the language of the execution and, over TestHost, of the request. Blazor forms rendered through bUnit read every sample value object's text through its rules, alone and nullable, the text an input writes reading back to its instance and each value its rules rule out refused with its code, in each culture the renderer calls for and in French from a resource, and the text it writes reads back in every culture .NET lists, those whose calendar is not the Gregorian one included; the static and the prerendered pages of the Razor fixture are served over TestHost, the framework's own messages pinned, and the patterns an input writes for browsers are checked against .NET here and against Node in the text family's job,unit (text). The DataAnnotations attributes hold the text of every sample value object to its rules, in both forms, and refuse each default instance, throughValidator, options validated as a host starts and by the validator[OptionsValidator]writes, CommunityToolkit.Mvvm'sObservableValidator, MVC and a minimal API underAddValidation()over TestHost, with their messages in French from a resource, and both OpenAPI documents describe each value object's text as the other does. Newtonsoft.Json givenValueObjectContractResolveralone writes and reads every underlying type as it does with the converter in its settings, a generic construction, value objects written by hand and one carrying[JsonObject]included, keeps the converter a type names, leaves the contracts of the resolver it wraps as they were, and converts the values of a JSON Patch document applied outside MVC. JSON Patch documents applied throughTryApplyTo, over TestHost and through its handler, read each value through its rules in every operation, on members, nullable members, lists, arrays and dictionaries keyed by value objects or holding them, every sample value object among them read as a body reads it, and report each refusal with its code under the path of its operation, beside what JSON Patch's ownApplyTodoes. NJsonSchema's two generators describe every sample value object as its underlying type on each wire their settings describe, and what each serializer writes of it, from the edges its rules declare to values drawn from them, validates as Confluent's serializer validates a message; NSwag's documents over TestHost, on System.Text.Json, on Newtonsoft.Json and in Swagger 2.0, describe each value object and each text, the System.Text.Json one as the built-in stack's document does but for a bound no decimal holds, and a C# client generated from one compiles; Confluent's serdes, over a substitute registry client, register the rules and refuse a tampered message with its code. Keys typed as value objects are generated over aGuidand each integer type, a round trip through SQLite in memory included, aGuidone left unset is refused in every shape the convention concerns, and an integer key to generate is refused on Entity Framework Core's in-memory provider, which is not relational. - GeneratorTests — the generator itself: emission, every diagnostic, hook detection, the analyzers, and
incremental caching, the analyzer of the DataAnnotations attributes and its
PublishAotgate included, the analyzer of serializers' contract attributes and its code fix, over protobuf-net itself and the other serializers' attributes declared under their real names, and the analyzer of data classifications held to the tags Microsoft.Gen.Logging, run beside the generator, writes, the analyzer of non-Unicode text held to what .NET's regular expressions match under each option. It drives Roslyn directly rather than through a testing harness that binds to an older xUnit, and it compiles snippets without implicit usings, which is what catches an unqualified name that slipped into emitted code. The incrementality tests assert on the Roslyn incremental step run reason — the only way to notice a caching regression, since losing incrementality breaks nothing visible while making every IDE keystroke re-run the pipeline. - IntegrationTests — real PostgreSQL and SQL Server, asserting against
information_schemathat value objects reach the column types they claim, a collection of them the PostgreSQL array of its element's type, Dapper and EF Core round trips, a query over the elements of a collection included,Guidvalue-object keys generated as each provider generates aGuid, in the order the engine sorts them, and integer ones by the engine's identity column, a prefix search on.Valuereading the column itself, with a parameter declared as the column,.Valueof a value object over a type the provider converts, and the CHECK constraints written from the rules, on PostgreSQL and SQL Server 2022, taking every value each type accepts and refusing another writer's row, and a row the context writes with the code of the rule it broke, fromSaveChanges, a refusal in a later statement of a batch included,ExecuteUpdate, raw SQL and a migration, in every language SQL Server writes its messages in, and with none on SQL Server for a raw query reading back the rows it writes, their patterns matched value by value as .NET matches them on PostgreSQL and on SQL Server 2025, a value object declaringUnicode = falseinvarcharand compared throughvarcharparameters from EF Core and Dapper, an Always Encrypted column included, beside the audit of the rows a migration to it converts and the migration of a key, plus the API surface end to end; and a real MongoDB server, which stores a value object of every underlying type MongoDB.Bson can represent as the document its primitive writes, refuses one overInt128orUInt128, and answers each LINQ andBuildersshape over them,.Valueincluded; which, given the$jsonSchemavalidator built from the rules, refuses another writer's document and stores every value each type accepts, reads each pattern the validator publishes as .NET does, and mints an entity identifier for a document inserted without one. And the Azure Functions host, in Microsoft'sdotnet-isolatedimage, running an application of the isolated worker with Azurite for its queues: route values and query values bound through the rules, each refusal answered with the minimal API's validation problem and its code, a[FromBody]one included, in the request's language, a failure no value object explains keeping the host's 500, the middleware registered before the integration keeping it too, with its warning, and a queue message's binding data bound beside the message its binding reads. These need a Docker daemon: Testcontainers starts the engines and the host for the run. - RdgTests — minimal API endpoints whose binding the Request Delegate Generator writes, as it does in every
build under
PublishAotorPublishTrimmed, over value objects declared in the endpoints' own project: the case the Request Delegate Generator cannot see the generator's output for. A route, query or header value binds, a refused one answers 400, an absent optional one bindsnull, and a test fails if the RDG did not write the binding, so that the suite cannot silently become one more test of the reflection-based binding. The same refusals are answered with the minimal API problem details, in each setting ofThrowOnBadRequest, where the RDG refuses no empty query text and takes an empty header for an absent one, and the DataAnnotations attributes are validated byAddValidation()over the binding the RDG writes, each refusal answered as the reflection-based binding answers it.
AdCodicem.ValueObjects.Testing ships the contract kit (ValueObjectContract) described in
Test your value objects; the unit tests use it on every generated sample value
object but two, so the framework's own test suite is a live example of how a consumer would use it. Floor and
Celsius cannot satisfy it: their formatting hooks write text such as floor 3 or 21 °C, which does not parse
back, and the kit requires a text round trip. Every one of those contracts also asks the kit to derive the values its
schema rules out (DerivesRejectedValues), and checks that the type refuses each of them.
Native AOT and compiled models
Three applications, built as an application builds them, are published rather than tested.
The first references every package that claims to be AOT-compatible: the contracts, the generated code, the JSON package
with a source-generated context and the JSON Schema it exports, the minimal API problem details, the identifiers,
FluentValidation, the Serilog integration, the Microsoft.Extensions.AI one, the Model Context Protocol one, the gRPC
one and the Blazor one, all but
the HotChocolate one, whose framework warns on its own publish and starts natively only rooted
(GraphQL), and whose
refusal of a value object nothing registered the unit suite runs instead under the JIT, in a child process whose
runtime supports no dynamic code, and the protobuf-net one, which the third publishes. Its
value
objects cover each of the 22 underlying types, a pattern hook, a
closed set, an identifier, a generic value object registered by hand, and two value objects written by hand, registered
without a converter, which the JSON factory serves its general-purpose one, beside a third that nothing registers, which
it refuses. It runs a fixed script over every one of them — the typed path, the descriptor, JSON, FluentValidation,
Serilog, each value object logged checked against the bare value it carries, the schema of every one in a tool, tools
built over the context answering refused arguments with their rule, a Model Context Protocol server in process listing
its tools on two protocol versions and answering refused arguments, and requests over Kestrel to minimal API
endpoints the Request Delegate Generator binds, refused values answered with problem details in each setting of
ThrowOnBadRequest, in English and with the messages of a resource read through the localizer, each answer checked
against the status, the codes and, translated, the messages the script expects, and the DataAnnotations attributes, both
forms and [NotDefault], through Validator, the validator [OptionsValidator] writes and a minimal API under
AddValidation(), each refusal checked against its code, and a Blazor form of every one of them through
HtmlRenderer, each text of its underlying type typed into its input, alone and nullable — once under the JIT and
once as a native AOT binary. CI's native AOT job fails on any trimming or AOT warning in the publish, but the two
Microsoft.AspNetCore.Components reports for itself once a component renders, which the project lists in its
aot-warnings.allowed, and on any difference between the two outputs. It runs after every merge, on a pull request
labelled ci:heavy, and before every preview and stable release, which publish nothing unless it passes, so the claim
of AOT compatibility is run, not only analysed.
The second holds an Entity Framework Core context mapping every value object the conventions map, required and
optional, a generic one, an identifier as the key and collections of them, Guid and int keys it generates and a
Guid key it refuses to add unset, beside a strict context, both filtering on .Value and adding the CHECK
constraints from the rules. dotnet ef dbcontext optimize writes its compiled model on every pull request reaching it, which
the project then builds on; with the heavy jobs, the integration job takes it on a round trip through SQL Server, whose
tables EnsureCreated creates with the constraints, a row SQL writes breaking a rule refused with the rule's code, and
the native AOT job writes the model for native AOT, with its queries precompiled, and publishes it.
Compiled models says what holds there.
The third publishes protobuf-net's compile-time model over the value objects of the first one's domain whose underlying
type protobuf-net writes, twenty of the twenty-three, each naming its serializer on a partial declaration, and two more
over a Guid and a decimal written as text:
a message of all of them written as the same message over the primitives and read back, each value its rules refuse
refused with its code, a trusted profile reading one anyway, a field left out read as the default instance, and a
default refused on write, once under the JIT and once as a native binary. The model protobuf-net generates warns at
each call into a serializer, its annotations reaching the converters every value object nests: the job lets through
the warnings the project lists, aot-warnings.allowed, and fails on any other, this library's included
(protobuf-net).
The compatibility island
The four suites, thirteen test projects of the solution, build and test the source. One more project tests the
packages: tests/Compat, outside the solution,
installs the thirty-two packages exactly as they were packed — from the folder the build packs into, at that one
version, never from nuget.org — into net11.0 applications on the .NET 11 release candidate. It has its own SDK, its own package
versions and no transitive pinning, so the dependency floors of the packages meet the next major as they would in an
application. It runs the generator in that SDK's compiler, Entity Framework Core 11 on SQLite, SQL Server and
PostgreSQL, collections of value objects, queries on .Value, CHECK constraints from the rules on SQLite and a pattern
constraint on PostgreSQL included, a refused row carrying the code of its rule, System.Text.Json source generation, ASP.NET Core model binding on System.Text.Json and on Newtonsoft.Json,
a JSON Patch document through TryApplyTo, and a minimal API with its problem details, their messages and FluentValidation's in the language of the request, the
DataAnnotations attributes through Validator, options validation, the validator [OptionsValidator] writes, MVC 11 and
the minimal API validation of Microsoft.Extensions.Validation 11, its own message keys and its handling of an
attribute's exception included,
Microsoft.AspNetCore.OpenApi 11 over Microsoft.OpenApi 3, Dapper,
MongoDB.Driver on MongoDB 7.0 with a collection validator and entity identifiers minted on insert, MessagePack and SignalR's MessagePack hub protocol
11 over TestHost, protobuf-net on a runtime model and on its compile-time model and a protobuf-net.Grpc code-first
service over TestHost, a contract-first gRPC service answering through the gRPC package,
FluentValidation, Newtonsoft.Json through its settings and its contract resolver, Serilog, Microsoft.Extensions.AI
tools and structured output, Model Context Protocol tools over the SDK's own client, the Azure Functions converter and
the problem details middleware beside ASP.NET Core 11, HotChocolate 16 scalars in a schema built in process, their
messages in the language of the execution, a Blazor input rendered by HtmlRenderer on the Components of .NET 11, its
messages from a resource, an NSwag 14.7
document and Confluent's JSON serdes 2.16 over NJsonSchema,
XmlSerializer and
DataContractSerializer over a domain that opts into XML serialization, the contract kit, and the test-data sampler
drawing each value object of that domain from the schema the generator of that SDK wrote, through AutoFixture, Bogus and
FsCheck too. On Entity Framework Core 11, keys typed as value objects are generated over a Guid and an int, and a
Guid one left unset is refused. A second project installs
the Swashbuckle package, Swashbuckle 10 failing on the Microsoft.OpenApi 3 the first one's
Microsoft.AspNetCore.OpenApi 11 brings: there, Swashbuckle documents a minimal API over Microsoft.OpenApi 2, as in
an application that takes Swashbuckle alone.
CI's compat (.NET 11) job runs it with the heavy jobs: after every merge and on a pull request labelled ci:heavy,
against the packages that commit packs, and before every preview and stable release, against the very packages they
publish. It gates every stable release, previews once .NET 11 ships, and never a pull request
(ADR-0013).
The "On the next .NET" column of Supported frameworks states what it covers.
Two ways to reach a value object — and why bugs hide in one of them
- Typed path. The static abstract members of
IValueObject<TSelf, TValue>(Create,TryCreate,TryParse,Normalize,Validate). This is what domain code and the generic integrations use — the ASP.NET binder, the EF converter, the Dapper handler and the MongoDB serializer are all generic and closed over the concrete types at startup, so per-request work is fully typed and allocates nothing extra. - Boxed path. A descriptor resolved from a runtime registry, for callers that only know a
Typeat run time — dynamic parsing, the OpenAPI transformer and the Swashbuckle filters, model-binder resolution.
Unit tests naturally exercise the typed path, so a defect confined to the descriptor can be invisible to them unless the suite deliberately covers that surface too. That asymmetry is worth keeping in mind when adding a test for a new rule: prove it holds on both paths, not just the one that's convenient to call from a unit test.
Coverage
A pull request reports the coverage of the suites it runs through Codecov: 95 % of the lines it changes, and the
project's coverage down by half a point at most. It runs the unit project of each package family it changes, each in
a job of its own, and uploads the reports of all of them under each of those families' flags, a flag keeping the lines
of its own packages, those another family's tests reach included; the families it does not run keep their coverage
from the commit before, and one that runs only the tests reading the documentation, or no test, uploads nothing. The
integration suite reports with the heavy jobs, after the merge or on a pull request labelled ci:heavy. That is a
floor; the aim is everything that can be covered, under a rule that keeps the number honest — a defensive branch no
test reaches stays, a private member is covered through its callers or not at all, and code is removed only when no
input can reach it. The rule, and the two things the figure cannot show (a package no test loads, and the code the
generator emits into the consumer's assembly), are on the Contributing page.
Stack
xUnit v3, AwesomeAssertions, NSubstitute, FsCheck, Testcontainers.
Commands
dotnet build -c Release
dotnet test -c Release # every suite, the unit suite's ten projects included
dotnet test --project tests/AdCodicem.ValueObjects.UnitTests # the core family's project of the unit suite
dotnet test --project tests/AdCodicem.ValueObjects.UnitTests.Text # another family's
# Every family's project of the unit suite (`tests` in .github/ci-families.json), as previews and releases run them:
# each one runs, and xargs fails when any failed
jq -r '.families[].tests' .github/ci-families.json | xargs -I{} dotnet test --project tests/{} -c Release
dotnet test --project tests/AdCodicem.ValueObjects.GeneratorTests # the generator itself
dotnet test --project tests/AdCodicem.ValueObjects.IntegrationTests # needs Docker
dotnet test --project tests/AdCodicem.ValueObjects.RdgTests # minimal APIs through the RDG
dotnet pack src/AdCodicem.ValueObjects.Packages.slnf -c Release -o artifacts/packages # the packable projects only
# The compatibility island, from its own folder, with the .NET 11 SDK its global.json names. Pack at a fresh
# version: NuGet reuses whatever its global packages folder already holds at a version it has seen.
v=0.0.0-compat.$(date +%s)
MINVERVERSIONOVERRIDE=$v dotnet pack src/AdCodicem.ValueObjects.Packages.slnf -c Release -o artifacts/packages
cd tests/Compat && dotnet test --project AdCodicem.ValueObjects.CompatTests.csproj -p:AdCodicemVersion=$v
# the main project needs Docker for PostgreSQL, SQL Server and MongoDB; without it, add --filter-not-trait "Requires=Docker"
dotnet test --project Swashbuckle/AdCodicem.ValueObjects.CompatTests.Swashbuckle.csproj -p:AdCodicemVersion=$v # no Docker
# Native AOT (needs clang and zlib), and the compiled model: compiled only (no database), for the JIT (a round trip,
# needs Docker) and native AOT
.github/scripts/native-aot.sh
.github/scripts/native-aot.sh artifacts/native-aot-protobuf tests/NativeAot/AdCodicem.ValueObjects.NativeAot.ProtobufNet
.github/scripts/compiled-model.sh compile
.github/scripts/compiled-model.sh jit
.github/scripts/compiled-model.sh aot
# One test, on the project of its family (Microsoft Testing Platform: a wildcard pattern, not a substring)
dotnet test --project tests/AdCodicem.ValueObjects.UnitTests.Text --filter-method "*The_name_of_the_test*"
TreatWarningsAsErrors is on repository-wide, so a warning fails the build before it reaches any of the four
suites. The island sets it too.
Next: Benchmarks, for the numbers behind the design decisions.