Skip to content

Commit 0ff9122

Browse files
linkdotnetegil
authored andcommitted
feat: Add generator for StubAttribute
1 parent d71e7b7 commit 0ff9122

8 files changed

Lines changed: 309 additions & 89 deletions

File tree

src/bunit.generators/README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# bUnit Generators
22

3-
This package contains source generators for bUnit, to make it easier and more convenient to write tests.
3+
This package contains source generators for bUnit, to make it easier and more convenient to write tests.
44

55
## `AddStub` Generator
6-
This generator adds the ability to automatically generate stubs for a given type. This comes in handy, when dealing
7-
with 3rd party components that might need an extensive setup. Here a small example:
6+
This generator adds the ability to automatically generate stubs for a given type with no setup involved. The generator sits on top of the already
7+
present `AddStub` method.
8+
This comes in handy, when dealing with 3rd party components that might need an extensive setup. Here a small example:
89

910
Given the following component
1011
```razor
@@ -49,6 +50,31 @@ To use the generator, the **Interceptor** feature has to be used inside the cspr
4950
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Bunit</InterceptorsPreviewNamespaces>
5051
```
5152

53+
This limits the usage to .NET 8 and above.
54+
55+
## `StubAttribute`
56+
This generator adds the ability to automatically generate stubs for a given type via an attribute.
57+
The general setup for the given component above looks like this:
58+
```csharp
59+
namespace MyTest;
60+
61+
public class FeatureTests : TestContext
62+
{
63+
[Fact]
64+
public void Test()
65+
{
66+
ComponentFactories.Add<ThirdPartyText, ThirdPartyStub>();
67+
...
68+
}
69+
}
70+
71+
[Stub(typeof(ThirdPartyText))]
72+
internal partial class ThidPartyStub { }
73+
```
74+
75+
Current limitations of this approach:
76+
* The stubbed type is not allowed to be nested inside the test class.
77+
5278
## Developer notes
5379

5480
### Tips for developing with the generator
@@ -59,7 +85,7 @@ When changing the source generator, to see the effect, clearing the build cache
5985
dotnet build-server shutdown
6086
```
6187

62-
A good way to quicky see if the generate is producing output:
88+
A good way to quickly see if the generate is producing output:
6389

6490
```
6591
dotnet build-server shutdown && dotnet clean && dotnet test -p:TargetFramework=net8.0

src/bunit.generators/Web.Stubs/StubGenerator.cs renamed to src/bunit.generators/Web.Stubs/AddStubMethodStubGenerator/AddStubGenerator.cs

Lines changed: 8 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,14 @@
66
using Microsoft.CodeAnalysis;
77
using Microsoft.CodeAnalysis.CSharp.Syntax;
88

9-
namespace Bunit.Web.Stubs;
9+
namespace Bunit.Web.Stubs.AddStubMethodStubGenerator;
1010

1111
/// <summary>
1212
/// Generator that creates a stub that mimics the public surface of a Component.
1313
/// </summary>
1414
[Generator]
15-
public class StubGenerator : IIncrementalGenerator
15+
public class AddStubGenerator : IIncrementalGenerator
1616
{
17-
private const string CascadingParameterAttributeQualifier = "Microsoft.AspNetCore.Components.CascadingParameterAttribute";
18-
private const string ParameterAttributeQualifier = "Microsoft.AspNetCore.Components.ParameterAttribute";
19-
2017
/// <inheritdoc/>
2118
public void Initialize(IncrementalGeneratorInitializationContext context)
2219
{
@@ -32,7 +29,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
3229
static (spc, source) => Execute(source, spc));
3330
}
3431

35-
private static StubClassInfo GetStubClassInfo(GeneratorSyntaxContext context)
32+
private static AddStubClassInfo GetStubClassInfo(GeneratorSyntaxContext context)
3633
{
3734
var invocation = context.Node as InvocationExpressionSyntax;
3835
if (!IsComponentFactoryStubMethod(invocation, context.SemanticModel))
@@ -50,7 +47,7 @@ private static StubClassInfo GetStubClassInfo(GeneratorSyntaxContext context)
5047
var line = lineSpan.StartLinePosition.Line + 1;
5148
var column = lineSpan.Span.Start.Character + context.Node.ToString().IndexOf("AddStub", StringComparison.Ordinal) + 1;
5249

53-
return new StubClassInfo
50+
return new AddStubClassInfo
5451
{
5552
StubClassName = $"{symbol.Name}Stub",
5653
TargetTypeNamespace = symbol.ContainingNamespace.ToDisplayString(),
@@ -86,12 +83,12 @@ static string GetInterceptorFilePath(SyntaxTree tree, Compilation compilation)
8683
}
8784
}
8885

89-
private static void Execute(ImmutableArray<StubClassInfo> classInfos, SourceProductionContext context)
86+
private static void Execute(ImmutableArray<AddStubClassInfo> classInfos, SourceProductionContext context)
9087
{
9188
foreach (var stubClassGrouped in classInfos.GroupBy(c => c.UniqueQualifier))
9289
{
9390
var stubbedComponentGroup = stubClassGrouped.First();
94-
var didStubComponent = GenerateStubComponent(stubbedComponentGroup, context);
91+
var didStubComponent = StubComponentBuilder.GenerateStubComponent(stubbedComponentGroup, context);
9592
if (didStubComponent is false)
9693
{
9794
return;
@@ -101,82 +98,9 @@ private static void Execute(ImmutableArray<StubClassInfo> classInfos, SourceProd
10198
}
10299
}
103100

104-
private static bool GenerateStubComponent(StubClassInfo classInfo, SourceProductionContext context)
105-
{
106-
var hasSomethingToStub = false;
107-
var targetTypeSymbol = (INamedTypeSymbol)classInfo!.TargetType;
108-
var sourceBuilder = new StringBuilder();
109-
110-
sourceBuilder.AppendLine($"namespace {classInfo.TargetTypeNamespace};");
111-
sourceBuilder.AppendLine();
112-
sourceBuilder.AppendLine($"internal partial class {classInfo.StubClassName} : global::Microsoft.AspNetCore.Components.ComponentBase");
113-
sourceBuilder.Append("{");
114-
115-
foreach (var member in targetTypeSymbol
116-
.GetMembers()
117-
.OfType<IPropertySymbol>()
118-
.Where(p => p.GetAttributes()
119-
.Any(attr =>
120-
attr.AttributeClass?.ToDisplayString() ==
121-
ParameterAttributeQualifier ||
122-
attr.AttributeClass?.ToDisplayString() ==
123-
CascadingParameterAttributeQualifier)))
124-
{
125-
sourceBuilder.AppendLine();
126-
127-
hasSomethingToStub = true;
128-
var propertyType = member.Type.ToDisplayString();
129-
var propertyName = member.Name;
130-
131-
var attributeLine = GetAttributeLine(member);
132-
sourceBuilder.AppendLine(attributeLine);
133-
134-
sourceBuilder.AppendLine($"\tpublic {propertyType} {propertyName} {{ get; set; }}");
135-
}
136-
137-
sourceBuilder.AppendLine("}");
138-
139-
if (hasSomethingToStub)
140-
{
141-
context.AddSource($"{classInfo.StubClassName}.g.cs", sourceBuilder.ToString());
142-
}
143-
144-
return hasSomethingToStub;
145101

146-
string GetAttributeLine(ISymbol member)
147-
{
148-
var attribute = member.GetAttributes().First(attr =>
149-
attr.AttributeClass?.ToDisplayString() == ParameterAttributeQualifier ||
150-
attr.AttributeClass?.ToDisplayString() == CascadingParameterAttributeQualifier);
151-
152-
var attributeLine = new StringBuilder("\t[");
153-
if (attribute.AttributeClass?.ToDisplayString() == ParameterAttributeQualifier)
154-
{
155-
attributeLine.Append($"global::{ParameterAttributeQualifier}");
156-
var captureUnmatchedValuesArg = attribute.NamedArguments
157-
.FirstOrDefault(arg => arg.Key == "CaptureUnmatchedValues").Value;
158-
if (captureUnmatchedValuesArg.Value is bool captureUnmatchedValues)
159-
{
160-
var captureString = captureUnmatchedValues ? "true" : "false";
161-
attributeLine.Append($"(CaptureUnmatchedValues = {captureString})");
162-
}
163-
}
164-
else if (attribute.AttributeClass?.ToDisplayString() == CascadingParameterAttributeQualifier)
165-
{
166-
attributeLine.Append($"global::{CascadingParameterAttributeQualifier}");
167-
var nameArg = attribute.NamedArguments.FirstOrDefault(arg => arg.Key == "Name").Value;
168-
if (!nameArg.IsNull)
169-
{
170-
attributeLine.Append($"(Name = \"{nameArg.Value}\")");
171-
}
172-
}
173-
174-
attributeLine.Append("]");
175-
return attributeLine.ToString();
176-
}
177-
}
178102

179-
private static void GenerateInterceptorCode(StubClassInfo stubbedComponentGroup, IEnumerable<StubClassInfo> stubClassGrouped, SourceProductionContext context)
103+
private static void GenerateInterceptorCode(AddStubClassInfo stubbedComponentGroup, IEnumerable<AddStubClassInfo> stubClassGrouped, SourceProductionContext context)
180104
{
181105
// Generate the attribute
182106
const string attribute = @"namespace System.Runtime.CompilerServices
@@ -222,7 +146,7 @@ public InterceptsLocationAttribute(string filePath, int line, int column)
222146
}
223147
}
224148

225-
internal sealed class StubClassInfo
149+
internal sealed class AddStubClassInfo
226150
{
227151
public string StubClassName { get; set; }
228152
public string TargetTypeNamespace { get; set; }
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.Linq;
2+
using System.Text;
3+
using Microsoft.CodeAnalysis;
4+
5+
namespace Bunit.Web.Stubs.AddStubMethodStubGenerator;
6+
7+
internal static class StubComponentBuilder
8+
{
9+
private const string CascadingParameterAttributeQualifier = "Microsoft.AspNetCore.Components.CascadingParameterAttribute";
10+
private const string ParameterAttributeQualifier = "Microsoft.AspNetCore.Components.ParameterAttribute";
11+
12+
public static bool GenerateStubComponent(AddStubClassInfo classInfo, SourceProductionContext context)
13+
{
14+
var hasSomethingToStub = false;
15+
var targetTypeSymbol = (INamedTypeSymbol)classInfo!.TargetType;
16+
var sourceBuilder = new StringBuilder();
17+
18+
sourceBuilder.AppendLine($"namespace {classInfo.TargetTypeNamespace};");
19+
sourceBuilder.AppendLine();
20+
sourceBuilder.AppendLine($"internal partial class {classInfo.StubClassName} : global::Microsoft.AspNetCore.Components.ComponentBase");
21+
sourceBuilder.Append("{");
22+
23+
foreach (var member in targetTypeSymbol
24+
.GetMembers()
25+
.OfType<IPropertySymbol>()
26+
.Where(p => p.GetAttributes()
27+
.Any(attr =>
28+
attr.AttributeClass?.ToDisplayString() ==
29+
ParameterAttributeQualifier ||
30+
attr.AttributeClass?.ToDisplayString() ==
31+
CascadingParameterAttributeQualifier)))
32+
{
33+
sourceBuilder.AppendLine();
34+
35+
hasSomethingToStub = true;
36+
var propertyType = member.Type.ToDisplayString();
37+
var propertyName = member.Name;
38+
39+
var attributeLine = GetAttributeLine(member);
40+
sourceBuilder.AppendLine(attributeLine);
41+
42+
sourceBuilder.AppendLine($"\tpublic {propertyType} {propertyName} {{ get; set; }}");
43+
}
44+
45+
sourceBuilder.AppendLine("}");
46+
47+
if (hasSomethingToStub)
48+
{
49+
context.AddSource($"{classInfo.StubClassName}.g.cs", sourceBuilder.ToString());
50+
}
51+
52+
return hasSomethingToStub;
53+
54+
string GetAttributeLine(ISymbol member)
55+
{
56+
var attribute = member.GetAttributes().First(attr =>
57+
attr.AttributeClass?.ToDisplayString() == ParameterAttributeQualifier ||
58+
attr.AttributeClass?.ToDisplayString() == CascadingParameterAttributeQualifier);
59+
60+
var attributeLine = new StringBuilder("\t[");
61+
if (attribute.AttributeClass?.ToDisplayString() == ParameterAttributeQualifier)
62+
{
63+
attributeLine.Append($"global::{ParameterAttributeQualifier}");
64+
var captureUnmatchedValuesArg = attribute.NamedArguments
65+
.FirstOrDefault(arg => arg.Key == "CaptureUnmatchedValues").Value;
66+
if (captureUnmatchedValuesArg.Value is bool captureUnmatchedValues)
67+
{
68+
var captureString = captureUnmatchedValues ? "true" : "false";
69+
attributeLine.Append($"(CaptureUnmatchedValues = {captureString})");
70+
}
71+
}
72+
else if (attribute.AttributeClass?.ToDisplayString() == CascadingParameterAttributeQualifier)
73+
{
74+
attributeLine.Append($"global::{CascadingParameterAttributeQualifier}");
75+
var nameArg = attribute.NamedArguments.FirstOrDefault(arg => arg.Key == "Name").Value;
76+
if (!nameArg.IsNull)
77+
{
78+
attributeLine.Append($"(Name = \"{nameArg.Value}\")");
79+
}
80+
}
81+
82+
attributeLine.Append("]");
83+
return attributeLine.ToString();
84+
}
85+
}
86+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Bunit.Web.Stubs.AttributeStubGenerator;
2+
3+
internal static class StubAttribute
4+
{
5+
public static string StubAttributeSource = @"#if NET5_0_OR_GREATER
6+
namespace Bunit;
7+
8+
/// <summary>
9+
/// Indicates that the component will be enriched by a generated class.
10+
/// </summary>
11+
[global::System.AttributeUsage(global::System.AttributeTargets.Class, Inherited = false)]
12+
internal sealed class StubAttribute : global::System.Attribute
13+
{
14+
/// <summary>
15+
/// The target type of the component the stub represents.
16+
/// </summary>
17+
public global::System.Type TargetType { get; }
18+
19+
/// <summary>
20+
/// Creates an instance of the <see cref=""StubAttribute""/>.
21+
/// </summary>
22+
/// <param name=""targetType"">The target type of the component the stub represents.</param>
23+
public StubAttribute(global::System.Type targetType)
24+
{
25+
TargetType = targetType;
26+
}
27+
}
28+
#endif";
29+
}

0 commit comments

Comments
 (0)