ConventionMapper.Testing
1.9.0
See the version list below for details.
dotnet add package ConventionMapper.Testing --version 1.9.0
NuGet\Install-Package ConventionMapper.Testing -Version 1.9.0
<PackageReference Include="ConventionMapper.Testing" Version="1.9.0" />
<PackageVersion Include="ConventionMapper.Testing" Version="1.9.0" />
<PackageReference Include="ConventionMapper.Testing" />
paket add ConventionMapper.Testing --version 1.9.0
#r "nuget: ConventionMapper.Testing, 1.9.0"
#:package ConventionMapper.Testing@1.9.0
#addin nuget:?package=ConventionMapper.Testing&version=1.9.0
#tool nuget:?package=ConventionMapper.Testing&version=1.9.0
ConventionMapper.Testing
Test helpers for mappings written with ConventionMapper or
ConventionMapper.SourceGenerator. No dependency on a
test framework: failures throw MapperAssertException, which xUnit, NUnit and MSTest all report.
dotnet add package ConventionMapper.Testing
Which members does a map write?
MapperAssert.WritesExactly fills a source object (distinct values, nested objects, one-element collections), runs the
map and compares the destination members that got a value with the list you expect:
MapperAssert.WritesExactly((LocationData s) => s.ToImportData("GL1", 10m),
"LocationNumber", "Street1", "City", "GlClassCode", "Exposure");
With the source generator, each mapper class has WriteSets: the members each generated method writes. Compare the
real behavior with it, without listing members by hand:
MapperAssert.WritesExactly((LocationData s) => s.ToImportData(), Mappings.WriteSets["ToImportData(LocationData)"]);
Keys are "Name(ParameterTypes)" as in the data-flow report, plus "Name" for methods without overloads. To survive
renames and signature changes, look the write set up by name and parameter types instead:
Mappings.WriteSetOf(nameof(Mappings.ToImportData), typeof(LocationData)), or
MapperAssert.WriteSetOf(typeof(Mappings), nameof(Mappings.ToImportData), typeof(LocationData)). Update
methods use MapperAssert.UpdatesExactly((Dto s, Entity t) => s.Update(t), expected).
Do the values come from the right members?
WritesExactly checks which members are written, not where their values come from: a swapped pair (SubmitUserId
filled from CompleteUser.Id, CompleteUserId from SubmitUser.Id) passes it. MapperAssert.WritesFrom checks each
destination member against the source member path it should be copied from. Every sample value is distinct, so a value
that comes from the wrong member is found, and the failure names the member it really came from:
MapperAssert.WritesFrom((Claim c) => c.ToDto(), ("SubmitUserId", "SubmitUser.Id"), ("CompleteUserId", "CompleteUser.Id"));
// CompleteUserId: expected the value of CompleteUser.Id (12), but it holds 9, the value of SubmitUser.Id
With the source generator, use the generated lineage, so the data-flow report becomes an executable test:
MapperAssert.WritesFrom((Claim c) => c.ToDto(), MapperAssert.LineageOf(typeof(Mappings), nameof(Mappings.ToDto), typeof(Claim)));
Nested objects mapped by another method are left to that method's own check. Built-in conversions (numbers, ToString,
enum names) are followed; a conversion it can't follow is only reported when the value clearly came from elsewhere.
A baseline for every map
[Fact]
public void Every_map_matches_its_write_set() =>
MapperAssert.AllMappersMatchWriteSets(typeof(Mappings));
Runs every generated create and update method with sample data (extra parameters filled too) and compares it with the
generator's WriteSets and Lineage: it must not throw, must not write members outside its write set, must write the
members it copies from a filled source, and must copy each from the right member. A new map gets this coverage before
anyone writes a dedicated test. When sample values aren't valid input (a string that is parsed, say), pass options:
AllMappersMatchWriteSets(typeof(Mappings), new SampleDataOptions().For<Row, string>(r => r.Code, n => n.ToString())).
Projections and collection merges are skipped.
Round trips
For a map and its reverse, back(to(x)) must equal x, deeply (nested objects member by member, collections item by item):
MapperAssert.RoundTrips<Person, PersonDto>(Mappings.ToDto, Mappings.ToPerson);
// 100 randomized inputs: nullable members sometimes null, empty, blank, very long and non-ASCII strings,
// zero and extreme numbers, empty and multi-item collections.
MapperAssert.RoundTrips<Person, PersonDto>(Mappings.ToDto, Mappings.ToPerson, runs: 100);
// Members the reverse map can't restore on purpose:
MapperAssert.RoundTrips<Person, PersonLite>(Mappings.ToLite, Mappings.FromLite, ignore: new[] { "Nickname" });
A failure lists the member paths that changed, or the exception, with the seed of that run. Replay it with
options: new SampleDataOptions().Randomized(seed).
Snapshots of the generated code
A library upgrade or an attribute change can alter what the generator writes for a mapper. Snapshot one method's generated code in its own file, so the change fails a test that names that mapper:
[Fact]
public void ToDto_generated_code_is_reviewed() =>
MapperAssert.GeneratedCodeMatches(typeof(Mappings), nameof(Mappings.ToDto), new[] { typeof(Order) });
// generated-code/Mappings.ToDto(Order).cs, next to this test file
[Fact]
public void Generated_code_is_reviewed() =>
MapperAssert.GeneratedCodeMatchesDirectory(typeof(Mappings)); // generated-code/Mappings/<method>.cs, one per method
The project that contains the mapper needs <ConventionMapperEmbedGeneratedCode>true</ConventionMapperEmbedGeneratedCode>.
A missing file is created and the test fails once so it gets reviewed and committed; a change fails with a line diff; run the
tests with CONVENTIONMAPPER_UPDATE_SNAPSHOTS=1 to accept changes. In directory mode, a file for a method that no longer exists
fails as stale.
Is every mapping method tested?
[Fact]
public void Every_mapping_method_has_a_test() =>
MapperAssert.AllMethodsCovered(typeof(Mappings), typeof(MappingTests).Assembly);
Fails with the list of generated mapping methods that no code in the test assembly calls (directly, as a method group or inside an expression tree).
A call alone doesn't check anything, so there is a stricter version: every create and update method must run inside a
WritesExactly or UpdatesExactly check (projections must at least be called).
[Fact]
public void Every_mapping_method_is_verified() =>
MapperAssert.AllMethodsVerified(typeof(Mappings), typeof(MappingTests).Assembly);
Is every mapping method used?
[Fact]
public void No_unused_maps() =>
MapperAssert.AllMethodsUsed(typeof(Mappings), typeof(OrderService).Assembly);
Fails for mapping methods that no code in the given production assemblies uses: maps only tests call, or nothing does, are candidates for deletion. A method reached through another used method of the mapper (a nested object's map) counts as used.
Sample data
SampleData.Create<T>() builds the filled objects used above. Strings carry the member name ("Street1-7"), so a value
that lands in the wrong member is easy to spot in a failure. Constructor parameters are filled, and a private
parameterless constructor (common on EF Core entities) is used when there is no public one.
SampleDataOptions customizes it; every check that fills objects takes them:
var options = new SampleDataOptions()
.For<Address, string>(a => a.ZipCode, n => (10000 + n).ToString()) // valid values for one member
.For<Money>(() => new Money(10m, "EUR")) // every value of a type
.ForMember("CountryCode", () => "GB") // a member of this name in any type
.Enum(Status.Active, Status.Closed) // only these enum values
.Construct<IShape>(() => new Square()); // interfaces, or types without a usable constructor
options.CollectionSize = 3; // items per collection (default 1)
options.MaxDepth = 2; // nesting depth (default 4)
options.FillNonPublicSetters = true; // also private and init-only setters
var address = SampleData.Create<Address>(options);
Builders
SampleData.Builder<T>() starts from a filled object and sets only what a test cares about, so setup stays short:
var referral = SampleData.Builder<Referral>()
.With(r => r.Status, "A")
.With(r => r.Address.City, "Leeds") // nested; missing objects on the way are created
.Without(r => r.ClosedOn) // null / default
.Build();
var referrals = SampleData.Builder<Referral>().With(r => r.Number, i => 1000 + i).BuildMany(3); // distinct values
var blank = SampleData.Builder<Referral>().Blank().With(r => r.Id, 7).Build(); // start empty
Private and init-only setters and get-only auto-properties can be set, so entities that guard their state can be built too.
Configure(r => ...) runs any other setup, and builders take the same SampleDataOptions.
Randomized(seed) switches to random edge cases, repeatable with the seed. Random values no longer carry member names,
so WritesFrom and AllMappersMatchWriteSets refuse it; it is meant for RoundTrips and your own tests.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on ConventionMapper.Testing:
| Package | Downloads |
|---|---|
|
ConventionMapper.Testing.EntityFrameworkCore
Database checks for mappings into EF Core entities: DatabaseAssert.SavesRoundTrip maps, saves and reads back an entity inside a rolled-back transaction and reports every value the database changed (database defaults that replace a copied false or 0, truncated text, rounded numbers), and DatabaseAssert.ModelMatchesDatabase compares the EF Core model's columns, nullability, maximum lengths and defaults with the real schema (SQL Server and SQLite). |
GitHub repositories
This package is not used by any popular GitHub repositories.
## 1.9.0 (unreleased)
Fixes from adopting 1.8.0 in a large codebase, plus values, EF Core awareness, new kinds of methods, data protection, integrations, migration tooling and behavior tests.
**Upgrading:**
- `[MapInherit]` naming a method with several matching overloads is now error CM0019 (1.8.0 silently took the one with the fewest parameters); add the parameter types: `[MapInherit(nameof(ToDto), typeof(Order))]`.
- New warnings can appear on existing code: CM0028 (a map writes a database-generated key or a navigation into an EF Core entity), CM0027 (a string can be longer than an entity column allows), CM0030 (a conversion method uses the current culture), CM0031 (a map uses an `[Obsolete]` member) and CM0032 (`[Sensitive]` data passed to a logger, exception message or `ToString()`, including records with `[Sensitive]` properties). Each points at a real risk; fix it, or acknowledge map warnings with `[MapReviewed]`. With warnings as errors, check before upgrading.
- Generated members are marked `[ExcludeFromCodeCoverage]`, so coverage percentages change. `<ConventionMapperExcludeFromCodeCoverage>false</ConventionMapperExcludeFromCodeCoverage>` keeps them in.
- Generated text conversions (`ToString()`, `Parse`) now always use the invariant culture. Code that relied on the server's culture gets invariant text.
- Re-record data-flow snapshots (`CONVENTIONMAPPER_UPDATE_SNAPSHOTS=1`): plain `Id` / `…Id` names are no longer flagged as keys, and the report has new sections.
### Source generator
- **"Review first" lists only real keys.** A member is a key when it is marked `[Key]` or `[ForeignKey]`, named by `[PrimaryKey]` or a navigation's `[ForeignKey]`, or, when the destination is an EF Core entity (a `DbSet<T>` on a `DbContext` or an `IEntityTypeConfiguration<T>`, in this assembly or a referenced one, plus the types their navigations reach), a key by EF's conventions (`Id`, `<Type>Id`, `<Navigation>Id`, `<PrincipalType>Id`). Names alone (`ErrorObjectId`, `RecordID`, `ReadByUserId` on a DTO) no longer count.
- **`[MapReviewed("PolicyId")]`** on a method or the mapper class takes reviewed keys off the list (the row says `key (reviewed)`). It is inherited through `[MapInherit]`; an unknown name is CM0008.
- **Exact case decides `[MapMember]` sources.** `"CreateUser"` binds to the source member and `"createUser"` to a parameter with that name, so parameters keep natural names. A path starting with the source parameter's name (`"source.CreateUser"`) always means the source. CM0014 remains for paths that match both with neither in exact case.
- **`[MapInherit(nameof(ToDto), typeof(Order), ...)]`** picks an overload by its parameter types; several matching overloads without them is error CM0019, which suggests the attribute to write.
- **`WriteSetOf(nameof(ToDto), typeof(Order))`** on every mapper class: the write set by method name and parameter types. A rename is a compile error, a changed signature throws with the current overloads.
- **CM0024 (warning):** the IDE is still running an older generator after a package upgrade (the cause of confusing errors such as ambiguous calls). Restart the IDE or its C# language server. Works for upgrades from 1.9.0 on.
- Generated files always use LF line endings (the data-flow report mixed CRLF and LF on Windows).
- The CM0023 fix names the parent overload by its parameter types when it is overloaded.
### Values
- **`[MapConstant("Status", FormStatus.New)]`**: a constant for a member, instead of a parameter that only ever receives one.
- **`[MapFrom(nameof(Compute), "Member")]`**: one member computed by a static method that takes the source (and any extra parameters by name). Expression-bodied methods are inlined in projections, so they run in SQL.
- **`[MapRequired("ProgramId")]`**: throws a descriptive `InvalidOperationException` when the source is null, instead of writing a default.
- **`[MapTruncate("City")]`** cuts a string to the destination's maximum length (`[MaxLength]`, `[StringLength]`, the EF model, or a given length). **CM0027** marks strings that can be longer than the destination allows (warning for EF Core entities, info otherwise).
- **Projections with parameters**: `IQueryable<ProgramData> ProjectToProgramData(this IQueryable<Program> q, int selectedProgramId)`; the values are query parameters.
- **`[MapMergeKey(nameof(Input.ContextId))]`** on `void Merge(IEnumerable<S>, ICollection<T>)`: updates matching items in place, adds new ones, removes the rest (`RemoveMissing = false` keeps them).
- CM0025: a value attribute that doesn't fit its member or method.
### EF Core awareness
- The generator reads the EF Core model from source (`DbSet<T>`, `IEntityTypeConfiguration<T>`, `modelBuilder.Entity<T>()`, and fluent `HasKey`, `HasForeignKey`, `HasMaxLength`, `ValueGenerated*`, `UseIdentityColumn`, `HasDefaultValueSql`, `HasComputedColumnSql`), so scaffolded entities without attributes get accurate keys, lengths and generated values.
- **CM0028 (warning):** a map writes a database-generated key or a navigation property into an entity.
- New docs section: copying one entity to another (explicit mode, keys as parameters, navigations left alone).
### Data protection
- **CM0032 (warning):** an object with `[Sensitive]` members (directly, nested or in collections) passed to an `ILogger` or Serilog call or a `[LoggerMessage]` method, put into an exception message, or printed by a `ToString()` override or a record's generated `ToString()`. Logging is the most common leak path.
- **CM0026 (info):** a copied member whose name looks personal (`Ssn`, `TaxId`, `DateOfBirth`, `Email`, `Phone`, `BankAccount`, also with prefixes such as `StrSsn`) isn't `[Sensitive]`. The fix *Mark [Sensitive]* adds the attribute.
- **`[MapRedact] T Redact(T value)`**: a copy with every `[Sensitive]` member masked, deeply.
- **`[MapLogString] string ToLogString(T value)`**: log-safe text, `Person { Id = 7, Ssn = *** }`.
- **Generated diffs:** `IReadOnlyList<FieldChange> Diff(Policy before, Endorsement after)` (or a tuple list for public methods), with the same member matching as a map; `[Sensitive]` values show as `***`. Useful for audit trails.
### Data-flow report
- **Source members no map reads**: dead data, or a forgotten mapping.
- **Maps to `X` side by side**: methods that write the same destination type, compared member by member.
- A failing snapshot describes the change in plain English ("ToEndorsementLocation now also writes TaxCode"), including removed and added DTO members marked as API changes.
- `DataFlowReport.Csv`, `.Json` and `.Mermaid` exports: column-level lineage for privacy requests and impact analysis; `[Sensitive]` flows are drawn as thick arrows.
- Generated `Lineage` and `LineageOf(nameof(M), typeof(...))` on every mapper class: destination member → the source path it is copied from.
### Migration
- **CM0029 (info):** object initializers and conversion operators that copy three or more same-name members by hand (threshold `conventionmapper.copy_min_members`). *Convert to generated mapping* creates an explicit-mode mapping method (`[MapExplicit]`, `[MapMember]`, `[MapConstant]`, parameters for the rest) and calls it; for operators, *Replace the operator with a generated mapping (update call sites)* removes the operator and rewrites its implicit and explicit uses across the solution. Raise the severity to warning to list every candidate in the build output.
### New kinds of methods
- **`[MapPatch]`** on an update method: copies only members the caller set; null values and `Optional<T>`-shaped wrappers without a value leave the target unchanged.
- **`[MapCombine]`** with **`[MapPrefer("saved", "City")]`**: one object from several sources, each member from the first source that has a value.
- **`[MapWhen("Discount", nameof(IsEligible))]`**: a member written only when a predicate holds (create, update and projections), shown in the report.
- **Database rows:** `T Read(IDataRecord row)` and `List<T> ReadAll(IDataReader reader)` with ordinals and column types looked up once, typed getters and `DBNull` handling; no reflection.
- **`[MapClone]`** deep copies, **`[MapEquality]`** `IEqualityComparer<T>` over the mapped members, **`[MapPreserveReferences]`** for shared instances and cycles.
- **`[MapValidate]`**: `Validate(T)` from maximum lengths, required members and `[Range]`.
- Each `IQueryable` projection also gets its expression as a property (`ProjectToDtoExpression`) for composing with other queries, OData or specifications.
### Correctness
- Text conversions in generated code use the invariant culture; **`[MapFormat("EffectiveDate", "yyyy-MM-dd")]`** fixes a format per member. **CM0030** flags culture-dependent parsing and formatting in conversion and `[MapFrom]` methods.
- **`[MapChecked("Count")]`**: narrowing conversions (`long` → `int`, `decimal` → `double`) and `DateTime` → `DateOnly` throw `OverflowException` naming the member instead of losing data. `DateTime` → `DateOnly` / `TimeOnly` is CM0016 without it.
- **CM0031:** a map reads or writes an `[Obsolete]` member.
### Integrations
- **Protobuf:** `Timestamp` ↔ `DateTime` / `DateTimeOffset`, `Duration` ↔ `TimeSpan`, `ByteString` ↔ `byte[]`, `repeated` and `map` fields (filled with `Add`), `oneof`, and null strings skipped instead of throwing.
- **Immutable and frozen collections** (`ImmutableArray`, `ImmutableList`, `ImmutableHashSet`, `ImmutableDictionary`, `FrozenSet`, `FrozenDictionary`) through their builders; get-only collections are filled with `Add`.
- **`[MapUse(typeof(SharedMappings))]`**: nested members use mapping methods of another class, such as shared maps in a referenced project.
- Generated members get `[GeneratedCode]` and `[ExcludeFromCodeCoverage]`; the committed `Generated/` folder is excluded from SonarQube.
- Collection maps avoid LINQ and iterate lists through a span on .NET 5 and later.
### Documentation and IDE
- **Every diagnostic links to its own section** of the new [docs/diagnostics.md](https://github.com/katragvb/Conventionmapper/blob/main/docs/diagnostics.md) (cause, example, fix).
- Generated method docs (`ConventionMapperGeneratedDocs`) carry each source member's `<summary>` and the method's write set, so hovering a mapping method shows where every value comes from.
- **CM0033 (hidden):** *Copy documentation from 'Order.Status'* adds the source member's summary to an undocumented destination member.
- Design rules recorded in [docs/adr](https://github.com/katragvb/Conventionmapper/blob/main/docs/adr/README.md), planned work in [ROADMAP.md](https://github.com/katragvb/Conventionmapper/blob/main/ROADMAP.md).
### Runtime package
- `IsAotCompatible`: trimmed and Native AOT apps get one warning where they create a `MapperConfiguration` (pointing to the source generator), instead of warnings from inside the library.
- **CM0032** for projects without the source generator.
### Generated code in the repository
- CI is also detected from `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, `BUILDKITE`, `TEAMCITY_VERSION`, `CIRCLECI`, `BITBUCKET_BUILD_NUMBER`, `CODEBUILD_BUILD_ID` and `HARNESS_BUILD_ID`. Systems that set none of them (Concourse) need `-p:ConventionMapperCheckGeneratedCode=true`.
- A build that rewrites the generated files says so in the build log, so a CI system that isn't detected is visible.
### ConventionMapper.Testing
- **`MapperAssert.AllMethodsVerified`**: every create and update method must run inside a `WritesExactly` / `UpdatesExactly` check (projections must be called), so a test that calls a map without asserting no longer counts.
- **`MapperAssert.WriteSetOf(typeof(Mappings), nameof(Mappings.ToDto), typeof(Order))`**: typed write-set lookup.
- **`MapperAssert.WritesFrom`**: checks each destination value came from the expected source member, with the generated lineage (`MapperAssert.LineageOf`). Catches swapped pairs and names the member the value really came from.
- **`MapperAssert.AllMappersMatchWriteSets(typeof(Mappings))`**: runs every generated create and update method with sample data and compares it with its write set and lineage. A zero-effort baseline for new maps.
- **`MapperAssert.RoundTrips(to, back)`**: deep comparison of `back(to(x))` with `x`; `runs: 100` uses randomized inputs (nulls, empty and long strings, extremes) and reports the failing seed.
- **`MapperAssert.AllMethodsUsed(typeof(Mappings), productionAssemblies)`**: maps no production code uses.
- **`SampleDataOptions`**: per-type and per-member values, enum subsets, factories for interfaces and types without a usable constructor, collection size, depth, and a seeded random mode. `SampleData` also uses private parameterless constructors.
- **`SampleData.Builder<T>()`**: fluent test objects, `.With(r => r.Address.City, "Leeds").Without(r => r.ClosedOn).Build()`, with nested paths, guarded setters and `BuildMany` with distinct values.
- **`MapperAssert.GeneratedCodeMatches`** and **`GeneratedCodeMatchesDirectory`**: snapshot each method's generated code in its own file (needs `ConventionMapperEmbedGeneratedCode` in the mapper's project).
Full history: CHANGELOG.md in this package, or https://github.com/katragvb/Conventionmapper/blob/main/CHANGELOG.md