Code Coverage
Goal: reach and hold ~100% line coverage of hand-written SDK code.
Current (2026-10-15, all three target frameworks merged): 99.81% line (1546/1549) and 96.53%
branch (584/605). Both are gated in CI across every target framework since 0.6.3. The earlier
figures below were measured on net10.0 alone. The 3 uncovered lines on this run, as
Check-Coverage.ps1 names them: ExpressionTranslator.cs 293–294 (provably unreachable — see
Why not 100% below) and TcgDexCachingHandler.cs 272 (the coalesced waiter's
cancellation on the netstandard2.0 build). BoundedLru.cs 175 (a lost-race retry) is
timing-dependent: covered on this run, uncovered on some others.
| Line | Branch | Unit tests | Integration tests | |
|---|---|---|---|---|
Baseline (f45e496) |
83.2% | — | 113 | 22 |
| Now | 99.74% | 96.36% | 562 (net10.0) | 149 |
The unit tests run on net472, net8.0 and net10.0 — 1679 executions (562,
561 and 556 per framework). Coverage is collected from all three runs and
merged: the net472 run produces its own cobertura file, so code compiled
solely for netstandard2.0 is measured by the gate too.
Measure it
dotnet test TcgDex.CSharpSdk.Tests/TcgDex.CSharpSdk.Tests.csproj \
--collect:"XPlat Code Coverage" \
--settings coverlet.runsettings \
--results-directory ./TestResults
Output lands at TestResults/<guid>/coverage.cobertura.xml.
For a readable report:
dotnet tool install --global dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"TestResults/report" -reporttypes:Html
Always pass --settings coverlet.runsettings
Without it the number is meaningless. System.Text.Json's source generator
emits several thousand lines of *.g.cs, which swamp the ~550 lines of
hand-written SDK code:
| Measured | Result |
|---|---|
Everything, including *.g.cs |
78.8% |
| Hand-written code only | 83.2% |
The generated lines belong to Microsoft's generator, are exercised indirectly by
every model test, and cannot be deliberately driven to 100%. Counting them puts
the target permanently out of reach for a reason that has nothing to do with
test quality. coverlet.runsettings excludes them by attribute and by path.
Why not 100%
Two lines: the ReadMember fallback in ExpressionTranslator.cs (293–294),
which throws when a MemberExpression carries a member that is neither a field
nor a property.
That is provably unreachable rather than merely hard to reach.
Expression.MakeMemberAccess rejects any other member kind with an
ArgumentException, so no expression tree — whether written in C# or built by
hand — can carry one. Verified directly rather than assumed.
The fallback stays because removing it makes the switch non-exhaustive, and the compiler then requires some default anyway. A clear exception beats whatever the alternative would be.
The other defensive branches are tested, not excused
Six lines that were previously written off as unreachable turned out not to be.
They were unreachable only through CardQuery, whose model happens to have no
boolean property and no custom methods — an accident of the current model, not a
property of the translator.
TranslatorDefensiveTests drives the internal ExpressionTranslator with a
synthetic model that has those shapes, covering:
- an
||operand that is neither a comparison nor a method call - bitwise
&and^where a comparison was expected - an unmapped one-argument method on a property, which looks exactly like
Containsto a shape check - a relational comparison against
null, built by hand because C# rejects it as always-false - a method call or array index used as the compared value
This matters beyond the number: those paths are what a future SetQuery or
SerieQuery would hit, and they now have asserted behaviour rather than an
assumption.
Two things were deleted rather than tested, because they turned out to be genuinely dead:
- The
JsonTokenType.Nullbranches in both converters.JsonConverter<T>.HandleNulldefaults tofalse, so System.Text.Json handles null itself and never invokes a converter for one. Tests passed while the lines stayed dark, which is what exposed it. - The non-
PropertyNameguard inTcgPlayerPricingConverter.Utf8JsonReaderguarantees a property name there; malformed JSON fails inside the reader first.
That is the useful part of chasing coverage: it does not just add tests, it finds code that cannot run.
What closing the gap actually covered
For reference, since these are the areas worth keeping covered as the SDK grows:
- Transport failures — network errors, timeouts, 5xx, unparseable error bodies, empty bodies, caller cancellation, on both REST and GraphQL. These went from mostly dark to fully covered, and they are the paths a user hits when something breaks.
- Query rejections — every
NotSupportedException, asserting the message names something actionable rather than just failing. - Serialization — round-tripping cards, sets, series and the dynamic TCGplayer printing keys, which also made writing a supported feature rather than an accident.
- Every resource method — all 13 catalog endpoints and all 3 random
endpoints, each asserting its exact request URI. The hyphenated paths
(
energy-types,regulation-marks,dex-ids) are trivially mistyped as camelCase and would only fail at runtime.
How it is held
Coverage that is measured but not enforced drifts, so CI gates on it:
- name: Coverage threshold
shell: pwsh
run: ./scripts/Check-Coverage.ps1 -ResultsDirectory ./TestResults -Threshold 99.5 -BranchThreshold 95
Run the identical check locally:
dotnet test TcgDex.CSharpSdk.Tests/TcgDex.CSharpSdk.Tests.csproj --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
pwsh ./scripts/Check-Coverage.ps1
Why a script rather than a build property
coverlet.msbuild can enforce a threshold with /p:Threshold=…, but this repo
uses the XPlat collector, which produces the report and cannot gate on it.
Switching packages to get the gate would mean giving up the collector's cleaner
integration with dotnet test. A separate pass over the Cobertura output keeps
both, and prints a per-file breakdown so a failure names the file that regressed
rather than only reporting that the total moved.
The script excludes generated files exactly as the runsettings does, so the gate measures the same thing the report does.
Line and branch are gated separately
They answer different questions:
| Metric | Question |
|---|---|
| Line | Did this line run? |
| Block (what Visual Studio reports) | Did this straight-line chunk run? |
| Branch | Did we test both outcomes of this condition? |
The first two ask "did it execute". Branch asks something categorically
stronger. A line holding flipped ? a : b is fully line-covered and fully
block-covered the first time it runs — while half its behaviour has never been
exercised.
That is not hypothetical here. Line coverage sat at 99.76% while branch coverage
was 91.90%, and the gap included the operand-flipping in
ExpressionTranslator: 100 <= c.HitPoints could have emitted the wrong operator with
the whole suite green. Closing that gap is what took branch coverage to 96.06%.
The branch gate is 95%. The remaining 17 partial branches are the low-value
kind — ?? new TcgDexOptions() defaults, ?? throw guards made unreachable by
an earlier check, and TryParse failure paths on data the SDK itself stored in
a valid form. Testing those proves the ?? operator works.
Why 99.5 and not 100
The ceiling is 99.87% — the two provably unreachable lines are 0.13% of the 1482-line total. A gate at 100 could never pass.
99.5 sits just under the ceiling, leaving those two lines of headroom and essentially nothing else. That is deliberate: it was 98 while the unreachable set was larger, and it moved up once tests closed the gap. The gate is a ratchet, not a target.
Do not lower it to make a build pass. If a line is genuinely unreachable, prove it and record why here — as the two above are — rather than moving the number.
Do not lower the gate to make a build pass. If a line is genuinely unreachable, record why here — as the ones below are — rather than moving the number.
Verifying the integration without hitting the API
Nearly all of it already is offline. The unit suite does not test mocks of the SDK's own code — it deserializes recorded live responses through the SDK's own serializer context, and asserts the exact request URI of every call. So "does the SDK integrate correctly" is answered without a network call, which is why the offline suite is far larger than the live one.
The one thing offline tests structurally cannot do is notice when TCGdex changes. A frozen recording never disagrees with itself, so a renamed field would leave every offline test green while the SDK silently broke.
FixtureDriftTests closes that. It re-fetches each recording and compares the
response shape — key paths and types, not values — failing with a precise
message such as removed: 'set.cardCount.official' was Number, now absent.
Shape rather than bytes, deliberately: refreshing the fixtures today changes 7
of 16 files byte-for-byte and none of them in shape, because prices and
updated timestamps move constantly. A byte-diff check would fail daily and
teach everyone to ignore it.
Removals and type changes fail the run; new fields are reported without failing, since the API growing a field is worth knowing but is not a breakage.
scripts/Update-Fixtures.ps1 refreshes the recordings — to be run after
adjusting the SDK to a reported change, never before, since a refresh makes the
check pass whether or not the models were updated.
Surviving mutants written off
DECISIONS #19 item 2 accepts a surviving mutant only if it is written down with a reason. This is
that list for the four weakest files, which were at 77% (TcgDexCachingHandler.cs), 74%
(TcgDexTransport.cs), 62% (BoundedContent.cs) and 60% (RequestBudget.cs) in GitHub Actions
run 39020678854 (commit ddde9bd). Every other survivor in that run now has a killing test. Each
test was checked by hand: apply that exact mutant, watch the test fail, then restore the code.
If every write-off below is correct, the scores rise to 94%, 89%, 90% and 100%.
This is a projection from the per-mutant checks. No Stryker run measured it, because
local runs are limited to an 8000-character span.
Line numbers are those of ddde9bd, as printed in the report, except the TcgDexTransport.cs
rows. Those are renumbered to 8ffde05, where run 39177696453 reported the same eight mutants.
| File:line | Mutator | Why it is not killed |
|---|---|---|
TcgDexCachingHandler.cs:110 |
Statement (guard removed) | Unreachable. The handler is sealed, and HttpMessageInvoker and DelegatingHandler both reject a null request before this override runs. |
TcgDexCachingHandler.cs:162 |
Boolean (ConfigureAwait(true)) |
Untestable without a seam: not equivalent. Usually the earlier ConfigureAwait(false) in WaitForLeaderAsync has already left the caller's context. But a waiter that calls GetOrAdd after the leader's SetResult and before its TryRemove gets a completed task, nothing suspends, and this await captures the context. That window is a race no deterministic test can hold open. |
TcgDexCachingHandler.cs:210 |
Conditional (false) | Equivalent. FetchResult.FromCache(null) and FetchResult.None are the same default value. |
TcgDexCachingHandler.cs:288 |
Logical (\|\| to &&) |
Equivalent. An empty tag on either side still fails EntityTagHeaderValue.TryParse below, so the result is false either way. |
TcgDexCachingHandler.cs:320 |
Logical (\|\| to &&) |
Equivalent. When nothing was added, Remove(null) does nothing. When the added tag has already left the header, the loop removes the caller's equal tags and adds them back. The only change is their order within If-None-Match, which is a set. All 109 caching tests pass against it. |
TcgDexCachingHandler.cs:466 |
Conditional (false) | Equivalent. Copying the segment every time stores the same bytes and only costs an allocation. |
BoundedContent.cs:133 |
Equality (hint >= 0) |
Equivalent. A hint of 0 gives Math.Min(0, cap), which is 0, the same capacity as the original. |
BoundedContent.cs:145 |
Conditional (true) | Equivalent. When capacity is 0, new MemoryStream(0) behaves the same as new MemoryStream(). |
BoundedContent.cs:145 |
Equality (capacity >= 0) |
Equivalent, for the same reason: it differs from the original only at 0. |
BoundedContent.cs:198 |
Statement (ArrayPool.Return removed) |
Not observable. The pool allocates a fresh array when one is not returned, so the only cost is allocation. ArrayPool.Shared caches per thread and per core, so asserting that an array is reused would be nondeterministic. |
TcgDexTransport.cs:420 |
Arithmetic (* to /) |
Untestable without a clock seam: not equivalent. On Linux, where Stryker runs on CI, Stopwatch.Frequency (10⁹) makes the factor 10⁷/10⁹ = 0.01, so / divides by it instead of multiplying and logs a duration 10,000 times too large. Only on Windows is the factor 1. The value is logged only, and no test can bound it exactly without injecting the clock. TheLoggedDuration_IsAPlausibleNumberOfMilliseconds still catches a gross scale error. |
TcgDexTransport.cs:439 |
Conditional (false) | Equivalent. A blank body sent to the deserializer throws JsonException. That is caught at line 443 and returns the same null, so IsBlank is only a fast path. |
TcgDexTransport.cs:444 |
Block removal | Equivalent. Removing the block makes it return default, which is the same null. |
TcgDexTransport.cs:452 |
Block removal | Equivalent, for the same reason: it returns default, which is null. |
TcgDexTransport.cs:466 |
Logical (three variants) | Equivalent. When fewer whitespace bytes count as blank, those bodies go to the deserializer instead. It throws JsonException, which becomes the same null. |
TcgDexTransport.cs:472 |
Boolean (true to false) |
Equivalent, for the same reason as line 466. |
RequestBudget.cs has no write-offs. Both of its survivors are now killed.
Second round
GitHub Actions run 39057672321 (branch test/mutation-weak-files, merged as 63f02f8) left 27
undetected mutants in eight files. Thirteen now have a killing test, checked by hand the same way.
TcgDexTransport.cs still had the eight survivors written off above, then at the same line numbers
(renumbered since), so it gets no new rows. The five rows below are the rest. Projected scores, if they are correct:
ITcgDexResponseCache.cs 100% (from 50%), FlexibleStringConverter.cs 100% (80%),
TcgDexJsonContracts.cs 100% (81%), TcgDexServiceCollectionExtensions.cs 100% (81%),
TcgDexOptions.cs 100% (86%), TcgPlayerPricingConverter.cs 89% (33 of 37, from 81%),
TcgDexUserAgent.cs 83% (unchanged, 5 of 6) and TcgDexTransport.cs 89% (unchanged, 67 of 75).
Line numbers are those of 63f02f8.
| File:line | Mutator | Why it is not killed |
|---|---|---|
TcgPlayerPricingConverter.cs:62 |
Conditional (false) | Equivalent. It also decodes the name for unit and updated, but both branches continue before name is read, and GetString cannot fail on a name ValueTextEquals has just matched. The only cost is an allocation. |
TcgPlayerPricingConverter.cs:62 |
Logical (\|\| to &&) |
Equivalent. isUpdated is computed as !isUnit && …, so the two are never both true. && is always false, which is the conditional-false mutant above. |
TcgPlayerPricingConverter.cs:68 |
Conditional (false) | Equivalent. GetString() returns null on a Null token, the same value the branch supplies. |
TcgPlayerPricingConverter.cs:84 |
Statement (continue removed) |
Equivalent. JsonSerializer.Deserialize on a Null token returns null for the reference type TcgPlayerPrice, and the price is not null check below then skips it, as continue did. |
TcgDexUserAgent.cs:71 |
String ("unknown" to ""), NoCoverage |
Untestable without a seam: not equivalent. If reached, the header would carry no version. It is reached only when Assembly.GetName().Version is null, which happens for a dynamically emitted assembly and never for this compiled one, and BuildProduct is a private static initialiser. |
Third round: files below their best score
GitHub Actions run 39177696453 on 8ffde05 (0.8.0) was compared, file by file, with the best score
each file had ever reached, counting only kills by the hermetic unit tests. Kills by the live-API
integration tests do not count, because those tests are not run on every push. Six files were below
their best. Every survivor in them now has a killing test, checked by hand as above, or a row below.
The exception is TcgDexTransport.cs, whose eight remaining survivors are the ones already written
off in the first table, renumbered there.
Line numbers are those of 8ffde05.
| File:line | Mutator | Why it is not killed |
|---|---|---|
CardImage.cs:63 |
Logical (\|\| to &&) |
Equivalent. Under the mutant a blank non-null base URL goes on to build "{blank}/high.png", and Absolute rejects it because it has no http(s) scheme, so the result is the same null. The is null test cannot be removed instead: the netstandard2.0 reference assembly does not annotate string.IsNullOrWhiteSpace, and without the test that target fails to build with CS8602. ImageUrl_WithAWhitespaceBase_IsNull runs the whitespace input and pins the null. |
CardImage.cs:91 |
Logical (\|\| to &&) |
Equivalent, for the same reason: "{blank}.png" has no http(s) scheme, so Absolute returns null. |
ExpressionTranslator.cs:340 |
Statement (throw; removed), NoCoverage |
Unreachable. ExceptionDispatchInfo.Capture(...).Throw() on the line before always throws. The throw; exists only because the compiler's definite-return analysis cannot see that, and [DoesNotReturn] affects nullable flow analysis, not reachability. |
ExpressionTranslator.cs:386 |
String (message to $""), NoCoverage |
Unreachable. ReadMember is only called with a MemberExpression.Member. MemberExpression has no public constructor, and Expression.Field, Expression.Property and Expression.MakeMemberAccess reject any member that is not a field or a property, so the fallback arm has no input. |
MemoryTcgDexResponseCache.cs:39 |
Statement (guard removed) | Equivalent. The BoundedLru constructor, called two lines later, runs the same Guard.NotLessThan(maxEntries, 1). Its parameter is also named maxEntries, so the exception has the same type, message and ParamName. The guard stays because this constructor is public, and its contract should not depend on a private field's constructor. |
MemoryTcgDexResponseCache.cs:101 |
Equality (> to >=) |
Equivalent. The two differ only when the time-to-live is exactly TimeSpan.MaxValue.Ticks / 12, and then by 7 ticks. Both lifetimes are about 9.2×10¹⁸ ticks, which is longer than the whole DateTimeOffset range (about 3.2×10¹⁸), so line 107 clamps both to DateTimeOffset.MaxValue. |
MemoryTcgDexResponseCache.cs:107 |
Equality (> to >=) |
Equivalent. At the boundary now + absoluteLifetime is exactly DateTimeOffset.MaxValue, the value the clamp returns. |
Fourth round: the rest of the survivors, read one by one
Mutation run 39189935172 (branch fix/mutation-regressions) left 51 survivors. Those in files that
were not below their best had not been examined, so each was read. Sixteen now have a killing test,
checked by hand: the two writer guards in ReleaseDateConverter and TimestampConverter, the null
variants_detailed coalescing in Card, the pageSize guard (removed by DECISIONS #36; the size now
comes from CardQuery.Page) and the cancellation check in StreamAsync, the BoundedLru bound, the tagless-entry slot in DeserializedResponseCache, the
GraphQL error log, the '9' boundary in Attack.BaseDamage, and the five ConfigureAwait calls in
GraphQLTransport and StreamAsync, the "get_" prefix of the indexer check in
ExpressionTranslator, and the pricing-skip type check in TcgDexJsonContracts. The five below cannot be killed.
| File:line | Mutator | Why it is not killed |
|---|---|---|
CardQuery.cs:178 |
Statement (guard removed) | Equivalent. ExpressionTranslator.SortFieldName, called on the next line, runs the same Guard.NotNull(selector) and the parameter has the same name, so the exception has the same type, message and ParamName. SortFieldName_ANullSelector_ThrowsNamingTheParameter pins it. The guard stays because OrderBy is public and its contract should not depend on a helper. |
BoundedLru.cs:167 |
Statement (return; removed) |
Equivalent. After a successful TryAdd the code falls through to TryGetValue and TryUpdate of the entry it just added, which succeed with the same value and return. It would differ only if Evict removed that very key in between, and Evict removes the oldest entry, never the one just stored. |
TcgDexCacheOptions.cs:135 |
Equality (cards >= 0 to cards > 0) |
Equivalent. The two differ only when the path begins with /cards/ at index 0. Every request path starts with the language segment (/v2/en/cards/...), and an empty language is rejected, so index 0 has no input. |
TcgDexJsonContracts.cs:106 |
Statement (return; removed) |
Equivalent today. For any type other than Card and DetailedVariant the loop below would run, and it only touches a property named pricing; no other model has one. It would start to matter if a model gained a pricing property, which the option would then skip as well. (TcgDexJsonContracts.cs:104, the check above it, is killed by AFreshWithoutPricingContract_SkipsPricingOnTheCardAndOnEveryVariant; the shared options cache their contracts per process, so only a freshly built one runs a mutated modifier.) |
CardDiagnostics.cs:44 |
Statement (return; removed) |
Equivalent. With warnings disabled the walk would run, and every log call it makes is filtered by the generated logger's own IsEnabled check. The early return only saves the walk, which nothing observable depends on. |
What coverage does not tell you
100% line coverage means every line ran, not that behaviour is correct. A suite that exercises every line of an HTTP client while never asserting a single request URL will sit at 100% and still let a wrong endpoint ship.
The tests that caught real defects here were not the ones chasing lines — they were the ones asserting exact URLs, deserializing recorded payloads with irregular shapes, and mutation-checking that a test could actually fail. Chase the gap, but do not mistake the number for the goal.
See learnings.md for the specific defects that motivated each
of those practices.