Design explicit tests for MSTest - #10606
Conversation
Define the API, selection model, data-row behavior, host integration, diagnostics, compatibility, and test plan for microsoft#5346. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 22c4cda6-f4c9-4492-89a0-9e2d80516d43 🤖
There was a problem hiding this comment.
Pull request overview
Defines RFC 024 for opt-in MSTest explicit-test behavior across VSTest and native MTP.
Changes:
- Specifies public APIs, activation rules, lifecycle, diagnostics, retries, and compatibility.
- Defines implementation surfaces and testing plans for both hosts.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The direct-selection table classified every node matched by --treenode-filter or a server graph filter as activated. That grammar has negation, != property predicates, and wildcards, so an exclusion-only or match-all filter would have activated explicit tests and /** would have been Run All that runs them. Give the tree grammar its own (matches, activates) algebra, require a discriminating non-root segment to activate, and state the fail-closed rule that activation is never assumed when it cannot be determined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cut it from 1081 to 604 lines. Every rule is still there, what went away is the repository history recital, the per-file implementation table, and the numbered acceptance test lists. Motivation now opens with three tests you would actually mark explicit, and there is a section showing how you run them, from Test Explorer, from dotnet test, and from an opt-in CI job. Added a prior art table for NUnit, xUnit v3 and TUnit, and why we follow NUnit rather than TUnit. 🤖
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
docs/RFCs/024-Explicit-Tests.md:143
TestDataRow<T>is a row and does not implementITestDataSource, so making it implement the source-named capability creates a misleading public type relationship. The existing ignore design keepsITestDataSourceIgnoreCapabilityon actual sources (DataRowAttribute/DynamicDataAttribute) while exposingIgnoreMessagedirectly onTestDataRow<T>and internalITestDataRow. Keep the explicit source capability and row properties separate in the same way.
`DataRowAttribute`, `DynamicDataAttribute`, and `TestDataRow<T>` implement the capability.
docs/RFCs/024-Explicit-Tests.md:435
- This no-user-code guarantee conflicts with the stated precedence that the assembly
ITestFilterruns first. The current path loads the test assembly, instantiates the registered filter, and callsFilterbefore resolving the test method, so module initializers, the filter constructor, andFilteritself can execute user code. Either move the explicit gate ahead of that policy or narrow this guarantee so protected tests do not rely on it.
The explicit check happens in `UnitTestRunner.RunSingleTestAsync`, before the test type is loaded and
therefore before assembly initialization, class initialization, construction, `TestInitialize`, and
the body. An assembly whose only selected tests are unactivated class, method, or unfolded-row
explicit tests runs no user code at all. Folded row declarations are the exception, see below.
TestDataRow<T> is a row, not an ITestDataSource, so making it implement ITestDataSourceExplicitCapability claimed a type relationship that does not exist. It now declares IsExplicit and ExplicitReason directly, next to IgnoreMessage, and the internal ITestDataRow exposes them, which is exactly how the ignore metadata is already split between sources and rows. Also narrow the gate guarantee. The explicit check runs after the assembly ITestFilter, and filter discovery loads the test assembly and then constructs and calls a registered [TestFilterProvider], so "runs no user code at all" was wrong. The guarantee is now no type load, no fixtures, no constructor, no TestInitialize and no body, the filter keeps its place ahead of the gate because it decides whether the test is in the run at all, and an ordering test pins the boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Both suppressed comments in the last review were right, fixed in 0b44dbd. On On the no user code guarantee: right as well, and it was wrong in two ways rather than one. I narrowed the claim rather than moving the gate. The filter answers whether the test belongs in the run at all, which is not the same question as whether the user asked for it, and a test the policy drops has to report as dropped rather than as an explicit skip, so putting the gate first would reverse that. The RFC now states the guarantee as no type load, no assembly or class initialization, no constructor, no 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (4)
docs/RFCs/024-Explicit-Tests.md:156
ITestDataSource.GetDatareturnsIEnumerable<object?[]>, so a custom source cannot returnTestDataRow<T>values directly; it must wrap each row in a single-element array. DirectIEnumerable<TestDataRow<T>>works here only becauseDynamicDataadapts arbitrary enumerables. Clarify this distinction so implementers do not copy an invalid custom-source contract.
A custom `ITestDataSource` implements the capability to mark every row it produces, or returns
`TestDataRow<T>` to mark single rows:
docs/RFCs/024-Explicit-Tests.md:356
- This tree-filter example does not match MSTest categories.
MSTestTestNodeConverter.cs:175-185encodes categoryHardwareas metadata keyHardwarewith an empty value, whileTreeNodeFilter.Matching.cs:170-173interprets the bracket sides as metadata key/value, so[Category=Hardware]matches nothing. Use an actual key/value trait example, or include a compatible category-metadata change and define how key-only categories interact with the rule that[Name=*]is non-discriminating.
| `/*/*/*/*[Category=Hardware]` | yes |
docs/RFCs/024-Explicit-Tests.md:536
- The JSON schema currently sets
additionalProperties: falseformstest.execution(docs/testconfig.schema.json:89-93), so the documentedexplicitTestModeconfiguration will be reported as invalid unless that schema is updated. Add the schema surface to the implementation plan so IDE validation and completion ship with the parser change.
| Settings | three `ExplicitTestMode` values with existing precedence, plus localized resources |
docs/RFCs/024-Explicit-Tests.md:476
- Source-wide explicit metadata is available on the
ITestDataSourceExplicitCapabilityinstance beforeGetDataruns, just as source-wide ignore is checked before enumeration inTestMethodRunner.DataRow.cs:30-39. Deferring this gate until enumeration can execute a side-effecting data source even though it was not activated; the “metadata does not exist until the source runs” rationale applies only to row-level declarations. Gate source explicitness beforeGetDataand reserve the enumeration exception for row metadata.
Folded rows have no discovery identity, so class and method explicitness is checked first, and source
and row declarations are checked as the data is enumerated, before per-row `TestInitialize`, before
test-class construction where construction is per row, and before the body. Each unactivated row
The fail-closed section said an unsupported filter constrains the run and activates nothing, while the next paragraph said an unparseable one fails. A filter whose semantics are unknown cannot constrain anything either, so split the two questions: an unevaluable constraint keeps its existing failure, and an evaluable one whose activation cannot be classified activates nothing. Also: - Say how *[Explicit=True] reaches TreeNodeFilter. It matches [Key=Value] only against TestMetadataProperty, so Explicit is one, and pre-node filtering in MtpTestElementFilter reads it from the same source. - Scope ignore precedence to metadata that is actually reached, and document the folded parent that is explicit and unactivated, whose data source never runs. - Stop calling ExplicitTestMode=Run and --filter "Explicit=True" equivalent. Run widens activation, the filter narrows selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Moving the Explicit=True paragraphs put two paragraphs between "That gap" and the gap it referred to. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ion check The folded data path claimed explicit metadata does not exist until the source runs. That is true of rows only. Source-wide explicitness is a property on the ITestDataSource attribute instance, so it is readable without enumerating, the way the source-wide IgnoreMessage already is. An unactivated source-wide declaration now gates before GetData and produces one folded-parent result. Compatibility described version skew as if a mixed pair kept running. The adapter's module initializer compares the adapter and framework informational versions and throws on any mismatch, so a mixed pair fails first. The real boundary is an adapter older than 3.10.0, which predates that check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
docs/RFCs/024-Explicit-Tests.md:680
ExplicitGate=Falseis accepted even though that combination is unsafe for a non-folded test. A persisted plain method withExplicit=TrueandExplicitGate=Falsebypasses the gate and has no per-source fallback, so inconsistent metadata can still execute an opt-in test during Run All despite the fail-closed guarantee. TreatFalseas valid only for a shape that can safely defer gating (or otherwise validate the pair), and add this case to the compatibility vectors.
still deserializes. `ExplicitGate` is not filterable, it exists only so the gate has its input, and
a missing or malformed one gates whenever `Explicit` is true. It fails closed like every other
unreadable value here, and in the same direction: the alternative, treating it as not gated, is only
safe for a folded parent, where the per-source check downstream still stops the run, and a plain
explicit method has no such check, so Run All would execute it. Over-skipping costs a folded parent
A well-formed ExplicitGate=False still bypassed the gate on a plain method, which has no per-source check downstream, so inconsistent persisted metadata could run an opt-in test under Run All. The pair is only meaningful for a folded data-driven parent, and the case already carries the shape, so the gate asks without loading the type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Right, and it is the residue of the fix two commits ago: I closed the missing-and-malformed hole and left the well-formed one open. Fixed in cb20bfc.
No new plumbing for it. The case already carries the shape in its persisted metadata, the same metadata the runner reads today before deciding whether a test takes the data-driven path, so the gate can ask without loading the type. Added to the compatibility vectors: a plain explicit method with a well-formed That is ten rounds, and I am stopping here rather than pushing again unless something is actually wrong. The count has come down from four real defects a round to one, and the last two were both residues of a single fix rather than new ground. What this needs now is a person, not another round. The design decisions accumulated over these rounds are the ones to check before approving, and they are listed in my earlier comments. The two that changed the model most are the folded parent carrying separate selection and gate state with the gate flag on the VSTest wire, and reserving 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
docs/RFCs/024-Explicit-Tests.md:203
- The “localized default” contradicts the later reporting contract: lines 721–725 always emit the localized base skip message and append
Reason:only when a declaration supplies a nonblank reason. Keeping a default in the effective-reason chain would append a second default reason and also conflicts with line 190's statement that blank values mean no reason. Remove the default from reason precedence; the base skip message already provides it.
The reason comes from the most specific explicit declaration that has one: row, then source, then
method, then class, then the localized default. An explicit declaration without a reason does not
erase a broader one, so an unreasoned explicit row under `[Explicit("Requires staging.")]` still
reports "Requires staging.".
docs/RFCs/024-Explicit-Tests.md:722
- This message is false when
ExplicitTestMode=Skipoverrides a directly selected test: the test was selected, but configuration disabled it. Define a mode-specific skip message (or use neutral “not activated” wording) so diagnostics identify the actual reason the selected test did not run.
Skipped results carry `The test is explicit and was not selected.`, with `Reason: <reason>` appended
when one exists. The reason is copied verbatim, only the decision whether it is empty inspects it,
A boolean gate flag has two values, so one corrupted bit turns any explicit parent into a legitimate looking deferral. Validating it against the case shape was not enough: a folded parent explicit at class or method scope with entirely ordinary sources passes the shape check, and its per-source checks then find nothing explicit and run every row under Run All. GatesAsExplicit becomes ExplicitScope, carrying ClassOrMethod, AllSources or SomeSources. The gate skips unless the scope is SomeSources, so deferring is a positive assertion that the explicitness is source scoped and mixed, which is the only situation with a per-source check downstream to catch it. Missing, unrecognized, or SomeSources on any other shape all gate. The reason chain no longer ends in a localized default. The skip message is always reported and already says why the test did not run, so a default there would have appended it to itself, and it contradicted blank meaning no reason. ExplicitTestMode=Skip gets its own skip message. It skips a directly selected test, so telling its author it was not selected sent them looking for a selection problem that does not exist. Test plan follows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Both suppressed comments were right too, same commit, 35ad4c4. The localized default in the reason chain. It contradicted two things at once: the statement a few lines above that blank values mean no reason, and the reporting contract, which always emits the base skip message and appends The skip message under That is eleven rounds. I am not pushing again unless something is actually wrong, and I want to be plain about why this one was worth another push after I said I was stopping: the inline comment was a third instance of the same safety hole, where a corrupted or inconsistent persisted value could run an opt-in test under Run All. That is the failure this whole design exists to prevent, so it did not seem like a place to stop on a technicality. What is left is a person. The design decisions taken across these rounds are in my earlier comments, and the three that move the model most are the folded parent carrying separate selection state and an 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
docs/RFCs/024-Explicit-Tests.md:694
ExplicitScopeis still the only persisted declaration-provenance field, so a reconstructed folded case containingExplicit=TrueandExplicitScope=SomeSourcescannot reveal that it was originally explicit at class scope with ordinary sources. The folded-shape check described here acceptsSomeSources, defers to per-source checks, and those checks run every ordinary row—the exact corruption vector that lines 820–823 claim will skip. Persist an independently checkable class/method provenance marker (or otherwise make deferral verifiable), or remove the unsupported corruption guarantee.
`Explicit` is. `SomeSources` is honored only on a folded data-driven parent, the one shape with a
per-source check downstream, and the case says which shape it is in metadata it already carries, so
no type load is needed to ask. On any other shape it is inconsistent rather than a deferral and the
gate fires, as it does for a missing or unrecognized value on any shape. Over-skipping costs a folded
parent with disagreeing sources one explicit skip instead of its ordinary source's rows, which is
docs/RFCs/024-Explicit-Tests.md:585
- The “different reasons” exception does not cover one source with a reason and a sibling with no reason, or a source-specific reason beside a broader method/class reason. Choosing the most specific available reason in those cases attributes one source's explanation to the aggregate skip representing every source. Define the parent reason as the common effective reason across all represented sources; any disagreement, including reason versus no reason, should produce no appended reason.
The reason on a gated parent follows the ordinary precedence, the most specific declaration that has
one. When the declaration is source-only and several sources carry different reasons, the parent
reports no reason rather than picking one, because a result that stands for every source cannot
attribute itself to one of them. Each source's own reason is still reported when the sources are
resolved individually.
…thing One field describing where the explicitness came from could not be validated against anything. A folded parent explicit at class scope with ordinary sources, corrupted to claim a mixed-source deferral, still read as legitimate: the shape check passes, the per-source checks then find nothing explicit, and every row runs under Run All. The corruption vector the test plan claimed would skip could not have skipped. ExplicitScope becomes ExplicitFromDeclaration and ExplicitFromSources. The gate skips whenever the test is explicit unless the declaration flag is false and the source flag says Some, so a class or method declaration keeps gating on its own and cannot be talked out of it by a value describing the sources. Either field missing or unrecognized gates. The reason on a gated parent is now defined by agreement rather than by specificity. A parent gated by a class or method declaration takes that declaration's reason. A parent gated by its sources takes the reason they agree on and reports none on any disagreement, including one source carrying a reason where a sibling carries none, because the result stands for all of them. Test plan follows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Both suppressed comments were right, fixed in 48a3ac1. The first one caught me asserting a guarantee the design could not deliver, which is worse than the hole it was meant to close. The corruption guarantee was unsupported. Right. With one field carrying the provenance, a reconstructed folded parent saying The defect was that a single field has nothing to be checked against, which is the same mistake as the boolean one round earlier, one size up. Split into The parent reason rule was incomplete. Also right, and the gap was wider than the "different reasons" case I had written: one source with a reason beside a sibling with none, and a source-specific reason beside a broader method or class one, both ended up attributing one source's explanation to a result standing for all of them. Taking your definition. A parent gated by a class or method declaration takes that declaration's reason, since one declaration covers the whole method and nothing can disagree with it. A parent gated by its sources takes the reason they agree on and reports none on any disagreement, reason versus no reason included. Per-source reasons are still reported where they belong, when the sources are resolved individually. Twelve rounds. I have said twice that I was stopping and pushed anyway, both times because the finding was in the same place: a persisted value that could let Run All execute an opt-in test. That is the one thing this design exists to prevent, so it has not felt like somewhere to stop on principle. It is worth saying plainly that three consecutive rounds found a defect in successive versions of the same fallback, each one my own, which is a fair signal that this corner deserves a human's eye more than another iteration of mine. Everything is answered, 🤖 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
docs/RFCs/024-Explicit-Tests.md:589
- This aggregate rule conflicts with the API-level precedence at lines 200–205 and the resolved answer at line 928. When a folded parent is explicit at method scope and all sources are also explicit with a shared reason, those sections say the more-specific source reason wins, while this paragraph says the method reason wins. Define the folded aggregate exception explicitly (including mixed method/source reasons) and add a corresponding test vector so reporting is deterministic.
A gated parent reports one result standing for everything under it, so its reason has to be true of
everything under it. A parent gated by a class or method declaration takes that declaration's reason,
the more specific of the two, because one declaration covers the whole method and there is nothing to
disagree with it. A parent gated by its sources takes the reason its sources agree on, and reports
none on any disagreement, including one source carrying a reason where a sibling carries none. Picking
the most specific available reason there would attribute one source's explanation to a result standing
for all of them. Each source's own reason is still reported when the sources are resolved
individually, which is where a per-source explanation belongs.
…eason rules The corruption claim implied a defence against a persisted value rewritten into a different valid value. Nothing gives that: a third provenance field is rewritten the same way, and Explicit=True rewritten to False defeats the feature before any of them are read. The rule covers metadata that cannot be believed, absent, malformed, unrecognized, or in an impossible combination, and the test plan no longer implies more. The folded aggregate reason also contradicted the per-test precedence chain and the resolved answer. Named it as the exception it is, in all three places, and added the vector where they diverge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
docs/RFCs/024-Explicit-Tests.md:256
- These commands pass MTP options to
dotnet runitself, which does not recognize them. Add the--application-argument separator so the test executable receives--filter-uidand--treenode-filter.
dotnet run --filter-uid <uid>
dotnet run --treenode-filter "/*/*/DeviceTests/*"
…gory lives
The converter writes [TestCategory("Hardware")] as
TestMetadataProperty("Hardware", string.Empty), so a category is selected as
[Hardware=*]. Keying discrimination on the value alone classified that as
non-discriminating, which meant naming a category could never start its
explicit tests, and the opt-in suite is most of what this design is for. The
table also showed [Category=Hardware], which matches no MSTest node at all.
A = predicate now discriminates on a literal on either side. Any != predicate
still does not, whatever literals it carries, and neither does [Explicit=*],
because Explicit is written on every node so asking which nodes carry it
selects all of them. That is the only key written on every node and it is
reserved, so no user property lands in the same position.
The truth table tests run against nodes the converter produced rather than
hand-built property bags, which is what would have caught this.
Also added the -- separator to the dotnet run examples, which otherwise pass
the options to dotnet run rather than to the test executable.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
docs/RFCs/024-Explicit-Tests.md:793
- This discovery plan does not populate source metadata for the folded paths it is meant to protect.
AssemblyEnumerator.TryUnfoldITestDataSourcescurrently returns for either folding mode before it even reads the source attributes (AssemblyEnumerator.cs:216-231), and can return after the first source fails to unfold without inspecting later sources. If source declarations are merged only “while unfolding,” an all-explicit folded parent is neither gated nor selectable viaExplicit=True. Pre-scan all source declarations before any folding/expansion early return; enumerate rows only when unfolding proceeds.
| Discovery | `TypeEnumerator` reads class and method declarations, `AssemblyEnumerator` merges source and row declarations while unfolding |
…pelled The exact carve-out for [Explicit=*] was bypassed by a wildcarded key. TreeNodeFilter expands * inside a property name and matches the result against every metadata key, so [Exp*=*] matches the universal Explicit property on every node while carrying literal characters, which made Run All activate every explicit test. A predicate no node can fail names nothing, whatever spelling reaches it, so [Explicit=*], [Exp*=*] and [*=*] all reduce to "has the property every node has" and none activates. Pinning the value to one of the two, as [Explicit=True] or [Exp*=True] does, excludes half the tree and discriminates. [Hardware=*] still discriminates because its key matches a property most nodes do not carry. Discovery also had to move. TryUnfoldITestDataSources returns for both fold modes before it reads the attributes, and can return again once a source fails to unfold, so merging source declarations only while unfolding left an all-explicit folded parent neither gated nor selectable. The scan happens ahead of every early return. Test plan follows, with wildcard-key vectors and a discovery test for the fold strategies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
docs/RFCs/024-Explicit-Tests.md:767
- The reservation does not cover
[TestCategory], even though native MTP converts each category intoTestMetadataProperty(category, string.Empty)(MSTestTestNodeConverter.cs:175-185). Consequently,[TestCategory("ExplicitReason")]makes/**[ExplicitReason=*]filterable on MTP while VSTest does not resolve that category by the property name, contradicting the cross-host and reason-not-filterable guarantees.[TestCategory("Explicit")]also collides with the universal built-in key, so its normal[Explicit=*]tree filter now selects every node instead of that category. Define collision handling for category names too and include both cases in compatibility notes and cross-host vectors.
`Explicit` and `ExplicitReason` are reserved property names on both hosts, compared ordinal
case-insensitively, because both hosts match case-insensitively: `ValueExpression` builds its regex
with `RegexOptions.IgnoreCase`, and `TestMethodFilter`'s supported-property dictionary and its trait
fallback both use `OrdinalIgnoreCase`. Reserving only the exact spellings would leave
`[TestProperty("explicit", "True")]` colliding. A `[TestProperty]` whose name matches either reserved
name in any casing is not written to the metadata surface or to the traits, and discovery reports a
warning naming the test, so the built-in value is the only one either host can match.
Adds
docs/RFCs/024-Explicit-Tests.md, a design for[Explicit]in MSTest that behaves the same on VSTest and Microsoft.Testing.Platform.Explicit tests are always discovered and displayed, but a broad Run All reports them as skipped. They run only when the request positively selects them. To make that precise without guessing at IDE intent, the RFC splits every request into a constraint ("is this test in the run?") and an activation ("did the user choose this test?"). An exclusion filter, a policy filter supplied by an extension, and an empty server selection can all constrain a run, but none of them can start a destructive test.
Activation is defined per request shape: concrete test-case and UID selections, a positive branch of a
TestCaseFilter/--filterexpression, and a discriminating segment of a tree-node or server graph filter. Anything whose activation cannot be determined activates nothing, so a future filter feature cannot quietly start running these tests before its semantics are designed.Also covers data rows and folding, inheritance and precedence against
[Ignore]and conditions, theExplicitTestModeoverride, retry behavior, reporting, old-adapter compatibility, implementation surfaces, and the test plan for both hosts.No production code changes. This is the design investigation asked for in the issue, and it needs approval on the public API, the activation model, the configuration override, and the documented legacy VSTest boundary.
Design for #5346