From b9b71df1b222abcd8e11f6dc04c0a5094a2fdd3b Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 17 Aug 2026 21:56:01 +1000 Subject: [PATCH] Generate the TestContext property for record test classes A record is a class, so it is a legal MSTest test class, but only ClassDeclarationSyntax passed the syntax filter. A [UsesVerify] [TestClass] partial record compiled and then failed at run time with the misleading "TestContext is null. Ensure test class has a `[UsesVerify]` attribute". Records are now eligible, and since a partial declaration has to repeat the kind of the type, the emitter takes the keyword from the declaration rather than always writing `class`. The same applies to the enclosing types, so GetParentClasses also handles a record struct parent, whose kind is two tokens. Record structs stay ineligible as targets: [UsesVerify] is only valid on a class. --- ...tributeOnClassNestedInRecords.verified.txt | 31 +++++++ ...ordTests.HasAttributeOnRecord.verified.txt | 25 ++++++ ...sts.HasAttributeOnRecordClass.verified.txt | 25 ++++++ ...ts.HasAttributeOnRecordStruct.verified.txt | 1 + .../RecordTests.cs | 83 +++++++++++++++++++ .../ClassToGenerate.cs | 4 + src/Verify.MSTest.SourceGenerator/Emitter.cs | 2 +- .../Extensions.cs | 15 ++++ src/Verify.MSTest.SourceGenerator/Parser.cs | 8 +- .../UsesVerifyGenerator.cs | 14 +++- .../RecordTests.ShouldPass.verified.txt | 1 + src/Verify.MSTest.Tests/RecordTests.cs | 9 ++ src/todo.md | 2 +- 13 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnClassNestedInRecords.verified.txt create mode 100644 src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecord.verified.txt create mode 100644 src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordClass.verified.txt create mode 100644 src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordStruct.verified.txt create mode 100644 src/Verify.MSTest.SourceGenerator.Tests/RecordTests.cs create mode 100644 src/Verify.MSTest.Tests/RecordTests.ShouldPass.verified.txt create mode 100644 src/Verify.MSTest.Tests/RecordTests.cs diff --git a/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnClassNestedInRecords.verified.txt b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnClassNestedInRecords.verified.txt new file mode 100644 index 0000000000..8ab3ff7350 --- /dev/null +++ b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnClassNestedInRecords.verified.txt @@ -0,0 +1,31 @@ +{ + Item1: UsesVerify.g.cs, + Item2: +//----------------------------------------------------- +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior +// and will be lost when the code is regenerated. +// +//----------------------------------------------------- + +namespace Foo +{ +partial record Outer +{ +partial record struct Middle +{ +partial class Bar +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")] + public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext; + set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType()); + } +} +} +} +} + +} \ No newline at end of file diff --git a/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecord.verified.txt b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecord.verified.txt new file mode 100644 index 0000000000..5630303268 --- /dev/null +++ b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecord.verified.txt @@ -0,0 +1,25 @@ +{ + Item1: UsesVerify.g.cs, + Item2: +//----------------------------------------------------- +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior +// and will be lost when the code is regenerated. +// +//----------------------------------------------------- + +namespace Foo +{ +partial record Bar +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")] + public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext; + set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType()); + } +} +} + +} \ No newline at end of file diff --git a/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordClass.verified.txt b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordClass.verified.txt new file mode 100644 index 0000000000..5630303268 --- /dev/null +++ b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordClass.verified.txt @@ -0,0 +1,25 @@ +{ + Item1: UsesVerify.g.cs, + Item2: +//----------------------------------------------------- +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior +// and will be lost when the code is regenerated. +// +//----------------------------------------------------- + +namespace Foo +{ +partial record Bar +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Verify.MSTest.SourceGenerator", "1.0.0.0")] + public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get => global::VerifyMSTest.Verifier.CurrentTestContext.Value!.TestContext; + set => global::VerifyMSTest.Verifier.CurrentTestContext.Value = new global::VerifyMSTest.TestExecutionContext(value, GetType()); + } +} +} + +} \ No newline at end of file diff --git a/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordStruct.verified.txt b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordStruct.verified.txt new file mode 100644 index 0000000000..22fdca1b26 --- /dev/null +++ b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.HasAttributeOnRecordStruct.verified.txt @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.cs b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.cs new file mode 100644 index 0000000000..b2f93280ea --- /dev/null +++ b/src/Verify.MSTest.SourceGenerator.Tests/RecordTests.cs @@ -0,0 +1,83 @@ +// A record is a class, so it is a legal MSTest test class +[TestClass] +public partial class RecordTests : TestBase +{ + [TestMethod] + public Task HasAttributeOnRecord() + { + var source = """ + using VerifyMSTest; + + namespace Foo; + + [UsesVerify] + public partial record Bar + { + } + """; + + return VerifyGenerator(TestDriver.Run(source)); + } + + [TestMethod] + public Task HasAttributeOnRecordClass() + { + var source = """ + using VerifyMSTest; + + namespace Foo; + + [UsesVerify] + public partial record class Bar + { + } + """; + + return VerifyGenerator(TestDriver.Run(source)); + } + + // A record struct is not a class, and [UsesVerify] is only valid on a class, + // so nothing is generated for one + [TestMethod] + public Task HasAttributeOnRecordStruct() + { + var source = """ + using VerifyMSTest; + + namespace Foo; + + [UsesVerify] + public partial record struct Bar + { + } + """; + + return VerifyGenerator( + TestDriver.Run(source), + expectedDiagnostics: ["CS0592"]); + } + + // The partial declaration has to repeat the kind of every parent too + [TestMethod] + public Task HasAttributeOnClassNestedInRecords() + { + var source = """ + using VerifyMSTest; + + namespace Foo; + + public partial record Outer + { + public partial record struct Middle + { + [UsesVerify] + public partial class Bar + { + } + } + } + """; + + return VerifyGenerator(TestDriver.Run(source)); + } +} diff --git a/src/Verify.MSTest.SourceGenerator/ClassToGenerate.cs b/src/Verify.MSTest.SourceGenerator/ClassToGenerate.cs index 3271ef3c7c..2540459992 100644 --- a/src/Verify.MSTest.SourceGenerator/ClassToGenerate.cs +++ b/src/Verify.MSTest.SourceGenerator/ClassToGenerate.cs @@ -11,6 +11,7 @@ readonly record struct ClassToGenerate( string? Namespace, string ClassName, + string Keyword, ClassToGenerate.PropertyFlags TestContextPropertyFlags, ParentClass[] ParentClasses) { @@ -24,12 +25,14 @@ public enum PropertyFlags public string? Namespace { get; } = Namespace; public string ClassName { get; } = ClassName; + public string Keyword { get; } = Keyword; public ParentClass[] ParentClasses { get; } = ParentClasses; public PropertyFlags TestContextPropertyFlags { get; } = TestContextPropertyFlags; public bool Equals(ClassToGenerate other) => Namespace == other.Namespace && ClassName == other.ClassName && + Keyword == other.Keyword && TestContextPropertyFlags == other.TestContextPropertyFlags && ParentClasses.SequenceEqual(other.ParentClasses); @@ -41,6 +44,7 @@ public override int GetHashCode() var hash = 1430287; hash = hash * 7302013 ^ (Namespace ?? string.Empty).GetHashCode(); hash = hash * 7302013 ^ ClassName.GetHashCode(); + hash = hash * 7302013 ^ Keyword.GetHashCode(); hash = hash * 7302013 ^ TestContextPropertyFlags.GetHashCode(); // Include (up to) the last 8 elements in the hash code to balance performance and specificity. diff --git a/src/Verify.MSTest.SourceGenerator/Emitter.cs b/src/Verify.MSTest.SourceGenerator/Emitter.cs index 671221c4ca..02b263f23b 100644 --- a/src/Verify.MSTest.SourceGenerator/Emitter.cs +++ b/src/Verify.MSTest.SourceGenerator/Emitter.cs @@ -62,7 +62,7 @@ void WriteClass(ClassToGenerate toGenerate) { builder.AppendLine( $$""" - partial class {{toGenerate.ClassName}} + partial {{toGenerate.Keyword}} {{toGenerate.ClassName}} { """); AppendGetter(toGenerate.TestContextPropertyFlags); diff --git a/src/Verify.MSTest.SourceGenerator/Extensions.cs b/src/Verify.MSTest.SourceGenerator/Extensions.cs index 6b54e56522..622f08d7e4 100644 --- a/src/Verify.MSTest.SourceGenerator/Extensions.cs +++ b/src/Verify.MSTest.SourceGenerator/Extensions.cs @@ -3,6 +3,21 @@ static class Extensions public static string GetTypeNameWithGenericParameters(this TypeDeclarationSyntax syntax) => syntax.Identifier.ToString() + syntax.TypeParameterList; + /// + /// The keyword to redeclare the type with. A partial declaration has to repeat the kind, + /// and for a record struct that kind is two tokens: `Keyword` is only the `record` half. + /// + public static string GetPartialKeyword(this TypeDeclarationSyntax syntax) + { + if (syntax is RecordDeclarationSyntax record && + record.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)) + { + return "record struct"; + } + + return syntax.Keyword.ValueText; + } + public static IEnumerable GetBaseTypes(this ITypeSymbol symbol) { var baseType = symbol.BaseType; diff --git a/src/Verify.MSTest.SourceGenerator/Parser.cs b/src/Verify.MSTest.SourceGenerator/Parser.cs index c0fdc6a3f6..143b4394ba 100644 --- a/src/Verify.MSTest.SourceGenerator/Parser.cs +++ b/src/Verify.MSTest.SourceGenerator/Parser.cs @@ -15,6 +15,7 @@ public static ClassToGenerate Parse(INamedTypeSymbol symbol, TypeDeclarationSynt return new( Namespace: ns, ClassName: name, + Keyword: syntax.GetPartialKeyword(), TestContextPropertyFlags: propertyFlags, ParentClasses: parents); } @@ -54,12 +55,13 @@ static IEnumerable BaseClassesOf(INamedTypeSymbol typeSymbol) static ParentClass[] GetParentClasses(TypeDeclarationSyntax syntax, Cancel cancel) { - // We can only be nested in class/struct/record + // We can only be nested in class/struct/record/record struct static bool IsAllowedKind(SyntaxKind kind) => kind is SyntaxKind.ClassDeclaration or SyntaxKind.StructDeclaration or - SyntaxKind.RecordDeclaration; + SyntaxKind.RecordDeclaration or + SyntaxKind.RecordStructDeclaration; var parents = new Stack(); @@ -71,7 +73,7 @@ SyntaxKind.StructDeclaration or cancel.ThrowIfCancellationRequested(); parents.Push(new( - Keyword: parent.Keyword.ValueText, + Keyword: parent.GetPartialKeyword(), Name: parent.GetTypeNameWithGenericParameters())); parent = parent.Parent as TypeDeclarationSyntax; diff --git a/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs b/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs index 6257bac837..fcdfc1bd90 100644 --- a/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs +++ b/src/Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs @@ -118,12 +118,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static bool HasTestClassAttribute(INamedTypeSymbol symbol, INamedTypeSymbol testClassType) => !symbol.HasAttributeOfType(testClassType, includeDerived: true); - // Require at least one attribute list: both paths only care about classes + // Require at least one attribute list: both paths only care about types // carrying [UsesVerify] or [TestClass]. Without this filter the uncached - // CreateSyntaxProvider transform runs semantic work for every class in the + // CreateSyntaxProvider transform runs semantic work for every type in the // consuming project on every keystroke. + // Records are eligible too: a record class is a class, so it is a legal test class. + // A record struct is not, and [UsesVerify] does not permit one. static bool IsSyntaxEligibleForGeneration(SyntaxNode node, Cancel _) => - node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + node switch + { + ClassDeclarationSyntax syntax => syntax.AttributeLists.Count > 0, + RecordDeclarationSyntax syntax => syntax.AttributeLists.Count > 0 && + !syntax.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword), + _ => false + }; static bool IsAssemblyEligibleForGeneration(IAssemblySymbol assembly, INamedTypeSymbol markerType) => assembly.HasAttributeOfType(markerType, includeDerived: false); diff --git a/src/Verify.MSTest.Tests/RecordTests.ShouldPass.verified.txt b/src/Verify.MSTest.Tests/RecordTests.ShouldPass.verified.txt new file mode 100644 index 0000000000..329c499a35 --- /dev/null +++ b/src/Verify.MSTest.Tests/RecordTests.ShouldPass.verified.txt @@ -0,0 +1 @@ +RecordValue \ No newline at end of file diff --git a/src/Verify.MSTest.Tests/RecordTests.cs b/src/Verify.MSTest.Tests/RecordTests.cs new file mode 100644 index 0000000000..47b17473eb --- /dev/null +++ b/src/Verify.MSTest.Tests/RecordTests.cs @@ -0,0 +1,9 @@ +// A record is a class, so it is a legal test class. Without generator support the +// TestContext property is never generated and this fails with "TestContext is null". +[TestClass] +public partial record RecordTests +{ + [TestMethod] + public Task ShouldPass() => + Verify("RecordValue"); +} diff --git a/src/todo.md b/src/todo.md index 03784f69b0..ab779a7bed 100644 --- a/src/todo.md +++ b/src/todo.md @@ -64,7 +64,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des - [ ] **Stack-trace scrubber destroys paren-less `at` frames** (NativeAOT: `at MyApp!+0x1a2b3c`). `Verify/Serialization/Scrubbers/ScrubStackTrace.cs:36-58` — `IndexOf('(')`/`')'` return −1, slice keeps zero chars → frame becomes an empty line, or the literal `...)` with `removeParams: true`. -- [ ] **MSTest source generator ignores `record` test classes.** +- [x] **MSTest source generator ignores `record` test classes.** `Verify.MSTest.SourceGenerator/UsesVerifyGenerator.cs:125-126` — only `ClassDeclarationSyntax` is eligible; `[UsesVerify] [TestClass] partial record` compiles then fails at runtime with the misleading "TestContext is null" error. `Parser.GetParentClasses` similarly stops at a `record struct` parent. - [ ] **Unclosed JSON object for empty `CombinationResults`.**