Document Throws/ContainsSingle null return inside Assert.Scope in RFC 011#9186
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Updates RFC 011 documentation to explicitly call out that the Assert.Throws* family behaves like other value-returning assertions (e.g., ContainsSingle) under Assert.Scope(): failures are collected and the API returns null/default rather than throwing immediately, which can lead to secondary downstream symptoms if the return value is used before scope disposal.
Changes:
- Adds
Throws<T>/ThrowsExactly<T>/ThrowsAsync<T>/ThrowsExactlyAsync<T>to the RFC’s value-returning assertion discussion (Option 2 and Option 3 sections). - Adds a new “Value-returning assertions return
null/defaultin scoped mode” subsection with an illustrativeThrowsexample and guidance.
Show a summary per file
| File | Description |
|---|---|
| docs/RFCs/011-Soft-Assertions-Nullability-Design.md | Expands RFC 011 to document Throws* null/default return behavior inside Assert.Scope() and provides an example + guidance. |
Copilot's findings
- Files reviewed: 1/1 changed files
- Comments generated: 0
Evangelink
left a comment
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
| # | Dimension | Verdict |
|---|---|---|
| 17 | Documentation Accuracy | 🔵 1 NIT |
✅ 21/22 dimensions clean.
- Documentation Accuracy — "(except
Fail/Inconclusive)" parenthetical omits null precondition checks as a second hard-exception class (inline comment on line 193)
Review notes (documentation-only PR)
Dimensions 1–16, 18–22 are N/A — this PR changes only docs/RFCs/011-Soft-Assertions-Nullability-Design.md with no code, tests, build scripts, or serialization formats.
Dimension 17 — Documentation Accuracy: The three changes are accurate and consistent with the implementation.
-
Tier 1 bullet (Option 2, rejected) — line 87: Correctly adds the
Throws*family alongsideContainsSingleto the "returningnullcauses downstream errors" rationale in the rejected option's Tier 1 list. The phrasing "returningnull" (rather than "null/default(T)") is intentionally tighter than the ContainsSingle entry —Tis constrained toException(a reference type) sodefault(T)is alwaysnull. Accurate. -
Pros bullet (Option 3, chosen) — line 109: Correctly adds all four async/sync variants to the enumerated list of soft assertions. Preserves the existing sort order (ContainsSingle → Throws family → IsTrue/IsFalse). Accurate.
-
New subsection "Value-returning assertions return
null/defaultin scoped mode" — lines 186–209: The behaviour description and code example are accurate and confirmed by the implementation inAssert.ThrowsException.Core.cs, which doesreturn null!afterReportAssertFailedreturns (soft mode). Theusingform is appropriate for the synchronousThrows<T>example; the async variants (ThrowsAsync<T>,ThrowsExactlyAsync<T>) are named in the text. The guidance "move the call outside the scope, or restructure so nothing depends on the return value until after the scope is disposed" is both correct and actionable — if the assertion succeeds (exception thrown), the scope collects no failure and Dispose does not throw, so post-scope code can safely use the return value; if it fails, Dispose throws before post-scope code runs, providing the intended protection.
One NIT filed inline (see above): the "(except Fail/Inconclusive)" shorthand omits the also-documented CheckParameterNotNull precondition checks. This is a completeness suggestion, not a correctness problem — the full exception list is already given in the preceding bullet points.
🤖 Automated content by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review (on PR ready) workflow. · 489.3 AIC · ⌖ 12.4 AIC · ◷
| - `Assert.ContainsSingle(...)` returns the single matched element. | ||
| - `Assert.Throws<T>(...)`, `Assert.ThrowsExactly<T>(...)`, `Assert.ThrowsAsync<T>(...)`, and `Assert.ThrowsExactlyAsync<T>(...)` return the caught exception. | ||
|
|
||
| These are soft within a scope, like every other assertion (except `Fail`/`Inconclusive`). When such an assertion *fails* inside a scope, the failure is collected and execution continues — so the method returns `null`/`default(T)` rather than throwing. The non-null return contract (and any nullable annotation) is intentionally not enforced in scoped mode, exactly as described above for all other postconditions. |
There was a problem hiding this comment.
[NIT] Documentation Accuracy
The parenthetical "(except Fail/Inconclusive)" omits the second class of hard exceptions already documented in the bullet points immediately above: null precondition checks (CheckParameterNotNull) inside Assert APIs also throw directly rather than collecting. A reader encountering this sentence in isolation could reasonably conclude that only Fail/Inconclusive are exceptions to the soft-collection rule.
Recommendation: Expand to keep the statement self-contained:
These are soft within a scope, like every other assertion (except `Fail`/`Inconclusive` and null precondition checks).
Alternatively, add a parenthetical cross-reference: "(see exceptions listed above)" to signal explicitly that the full list is already documented.
Follow-up to discussion on #9069.
RFC 011 documented that value-returning assertions return
null/defaultwhen they fail inside anAssert.Scope(), but only namedContainsSingle. TheAssert.Throws/ThrowsExactly/ThrowsAsync/ThrowsExactlyAsyncfamily has the identical behavior (return null!) yet wasn't mentioned anywhere in the RFC, which is what made the behavior in #9069 look like a bug.This PR:
Throws*family alongsideContainsSinglein the value-returning assertion notes (Option 2 list and the chosen Option 3all assertions participate in soft collectionlist).null/defaultin scoped mode subsection with aThrowsexample showing theNullReferenceException-as-secondary-symptom behavior.No behavior change — documentation only. The corresponding user-facing docs are in dotnet/docs#54409.