Skip to content

Commit c3c21fe

Browse files
EvangelinkCopilotAmaury Levé
authored
Add [MemberCondition] attribute for static-member-based test conditions (#9070) (#9071)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Amaury Levé <amaury@evangelink.dev>
1 parent 12efac8 commit c3c21fe

3 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Collections.ObjectModel;
5+
6+
namespace Microsoft.VisualStudio.TestTools.UnitTesting;
7+
8+
/// <summary>
9+
/// Conditionally runs or ignores a test class or test method based on the value of one or more
10+
/// <see langword="static"/> <see cref="bool"/> members (property, field, or parameterless method)
11+
/// referenced by <see cref="Type"/> and member name.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// When multiple member names are supplied to a single attribute, their values are combined with
16+
/// a logical AND: the attribute's <see cref="IsConditionMet"/> is <see langword="true"/> only if
17+
/// every referenced member evaluates to <see langword="true"/>.
18+
/// </para>
19+
/// <para>
20+
/// Each <see cref="MemberConditionAttribute"/> instance forms its own <see cref="ConditionBaseAttribute.GroupName"/>,
21+
/// so stacking multiple <see cref="MemberConditionAttribute"/> declarations on the same target is combined
22+
/// with a logical AND, matching the typical <c>[ConditionalFact]</c> usage pattern in other test frameworks.
23+
/// </para>
24+
/// <para>
25+
/// If the referenced member cannot be found as a <see langword="public"/> <see langword="static"/>
26+
/// <see cref="bool"/> property, field, or parameterless method, or (for methods) requires parameters,
27+
/// evaluating <see cref="IsConditionMet"/> throws an <see cref="InvalidOperationException"/>. This
28+
/// surfaces as a test error rather than a silent skip so typos and refactors don't accidentally
29+
/// disable tests.
30+
/// </para>
31+
/// <para>
32+
/// This attribute isn't inherited. Applying it to a base class will not affect derived classes.
33+
/// </para>
34+
/// <example>
35+
/// <code>
36+
/// [TestMethod]
37+
/// [MemberCondition(typeof(Environment), nameof(Environment.Is64BitProcess))]
38+
/// public void Only_Runs_On_64Bit() { }
39+
///
40+
/// [TestMethod]
41+
/// [MemberCondition(typeof(PlatformDetection),
42+
/// nameof(PlatformDetection.IsNotBrowser),
43+
/// nameof(PlatformDetection.IsThreadingSupported))]
44+
/// public void Requires_Threading_And_Not_Browser() { }
45+
///
46+
/// [TestMethod]
47+
/// [MemberCondition(ConditionMode.Exclude, typeof(PlatformDetection), nameof(PlatformDetection.IsMonoRuntime))]
48+
/// public void Does_Not_Run_On_Mono() { }
49+
/// </code>
50+
/// </example>
51+
/// </remarks>
52+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
53+
public sealed class MemberConditionAttribute : ConditionBaseAttribute
54+
{
55+
private const DynamicallyAccessedMemberTypes RequiredMembers =
56+
DynamicallyAccessedMemberTypes.PublicProperties
57+
| DynamicallyAccessedMemberTypes.PublicFields
58+
| DynamicallyAccessedMemberTypes.PublicMethods;
59+
60+
private readonly string[] _conditionMemberNames;
61+
private string? _groupName;
62+
private ReadOnlyCollection<string>? _conditionMemberNamesView;
63+
private Func<bool>[]? _evaluators;
64+
65+
/// <summary>
66+
/// Initializes a new instance of the <see cref="MemberConditionAttribute"/> class with
67+
/// <see cref="ConditionMode.Include"/> semantics: the test runs only when the referenced
68+
/// member evaluates to <see langword="true"/>.
69+
/// </summary>
70+
/// <param name="conditionType">The type declaring the static member to evaluate.</param>
71+
/// <param name="conditionMemberName">
72+
/// The name of the <see langword="public"/> <see langword="static"/> <see cref="bool"/> member
73+
/// (property, field, or parameterless method) to evaluate.
74+
/// </param>
75+
public MemberConditionAttribute(
76+
[DynamicallyAccessedMembers(RequiredMembers)] Type conditionType,
77+
string conditionMemberName)
78+
: this(ConditionMode.Include, conditionType, conditionMemberName, additionalConditionMemberNames: [])
79+
{
80+
}
81+
82+
/// <summary>
83+
/// Initializes a new instance of the <see cref="MemberConditionAttribute"/> class with
84+
/// <see cref="ConditionMode.Include"/> semantics: the test runs only when every referenced
85+
/// member evaluates to <see langword="true"/>.
86+
/// </summary>
87+
/// <param name="conditionType">The type declaring the static member(s) to evaluate.</param>
88+
/// <param name="conditionMemberName">
89+
/// The name of the first <see langword="public"/> <see langword="static"/> <see cref="bool"/>
90+
/// member (property, field, or parameterless method) to evaluate.
91+
/// </param>
92+
/// <param name="additionalConditionMemberNames">
93+
/// Additional <see langword="public"/> <see langword="static"/> <see cref="bool"/> member
94+
/// name(s) to evaluate. All referenced members are AND-combined.
95+
/// </param>
96+
public MemberConditionAttribute(
97+
[DynamicallyAccessedMembers(RequiredMembers)] Type conditionType,
98+
string conditionMemberName,
99+
params string[] additionalConditionMemberNames)
100+
: this(ConditionMode.Include, conditionType, conditionMemberName, additionalConditionMemberNames)
101+
{
102+
}
103+
104+
/// <summary>
105+
/// Initializes a new instance of the <see cref="MemberConditionAttribute"/> class.
106+
/// </summary>
107+
/// <param name="mode">
108+
/// Whether the test should be included (run when the condition is met) or excluded
109+
/// (skipped when the condition is met).
110+
/// </param>
111+
/// <param name="conditionType">The type declaring the static member to evaluate.</param>
112+
/// <param name="conditionMemberName">
113+
/// The name of the <see langword="public"/> <see langword="static"/> <see cref="bool"/> member
114+
/// (property, field, or parameterless method) to evaluate.
115+
/// </param>
116+
public MemberConditionAttribute(
117+
ConditionMode mode,
118+
[DynamicallyAccessedMembers(RequiredMembers)] Type conditionType,
119+
string conditionMemberName)
120+
: this(mode, conditionType, conditionMemberName, additionalConditionMemberNames: [])
121+
{
122+
}
123+
124+
/// <summary>
125+
/// Initializes a new instance of the <see cref="MemberConditionAttribute"/> class.
126+
/// </summary>
127+
/// <param name="mode">
128+
/// Whether the test should be included (run when the condition is met) or excluded
129+
/// (skipped when the condition is met).
130+
/// </param>
131+
/// <param name="conditionType">The type declaring the static member(s) to evaluate.</param>
132+
/// <param name="conditionMemberName">
133+
/// The name of the first <see langword="public"/> <see langword="static"/> <see cref="bool"/>
134+
/// member (property, field, or parameterless method) to evaluate.
135+
/// </param>
136+
/// <param name="additionalConditionMemberNames">
137+
/// Additional <see langword="public"/> <see langword="static"/> <see cref="bool"/> member
138+
/// name(s) to evaluate. All referenced members are AND-combined.
139+
/// </param>
140+
public MemberConditionAttribute(
141+
ConditionMode mode,
142+
[DynamicallyAccessedMembers(RequiredMembers)] Type conditionType,
143+
string conditionMemberName,
144+
params string[] additionalConditionMemberNames)
145+
: base(mode)
146+
{
147+
ConditionType = conditionType ?? throw new ArgumentNullException(nameof(conditionType));
148+
if (conditionMemberName is null)
149+
{
150+
throw new ArgumentNullException(nameof(conditionMemberName));
151+
}
152+
153+
if (StringEx.IsNullOrWhiteSpace(conditionMemberName))
154+
{
155+
throw new ArgumentException(
156+
"Condition member name must not be empty or whitespace.",
157+
nameof(conditionMemberName));
158+
}
159+
160+
if (additionalConditionMemberNames is null || additionalConditionMemberNames.Length == 0)
161+
{
162+
_conditionMemberNames = [conditionMemberName];
163+
}
164+
else
165+
{
166+
_conditionMemberNames = new string[additionalConditionMemberNames.Length + 1];
167+
_conditionMemberNames[0] = conditionMemberName;
168+
for (int i = 0; i < additionalConditionMemberNames.Length; i++)
169+
{
170+
string name = additionalConditionMemberNames[i];
171+
if (StringEx.IsNullOrWhiteSpace(name))
172+
{
173+
throw new ArgumentException(
174+
"Condition member names must not be null, empty, or whitespace.",
175+
nameof(additionalConditionMemberNames));
176+
}
177+
178+
_conditionMemberNames[i + 1] = name;
179+
}
180+
}
181+
182+
IgnoreMessage = mode == ConditionMode.Include
183+
? $"Test is only supported when ({FormatMemberList()}) on '{conditionType.FullName ?? conditionType.Name}' is true."
184+
: $"Test is not supported when ({FormatMemberList()}) on '{conditionType.FullName ?? conditionType.Name}' is true.";
185+
}
186+
187+
/// <summary>
188+
/// Gets the type declaring the <see langword="static"/> member(s) used to evaluate the condition.
189+
/// </summary>
190+
[DynamicallyAccessedMembers(RequiredMembers)]
191+
public Type ConditionType { get; }
192+
193+
/// <summary>
194+
/// Gets the name(s) of the <see langword="static"/> <see cref="bool"/> member(s) (property,
195+
/// field, or parameterless method) on <see cref="ConditionType"/> evaluated for this condition.
196+
/// Multiple values are combined with a logical AND.
197+
/// </summary>
198+
public IReadOnlyList<string> ConditionMemberNames
199+
=> _conditionMemberNamesView ??= new ReadOnlyCollection<string>(_conditionMemberNames);
200+
201+
/// <inheritdoc />
202+
/// <remarks>
203+
/// Each <see cref="MemberConditionAttribute"/> instance produces a group name derived from
204+
/// <see cref="ConditionType"/>, <see cref="ConditionMemberNames"/>, and
205+
/// <see cref="ConditionBaseAttribute.Mode"/>, so stacking multiple <see cref="MemberConditionAttribute"/>
206+
/// declarations on the same target combines them with a logical AND -- including pairs with
207+
/// the same type/members but opposite <see cref="ConditionMode"/> values, which would otherwise
208+
/// silently cancel each other out.
209+
/// </remarks>
210+
public override string GroupName
211+
=> _groupName ??= $"{nameof(MemberConditionAttribute)}:{ConditionType.FullName ?? ConditionType.Name}:{string.Join("|", _conditionMemberNames)}:{Mode}";
212+
213+
/// <inheritdoc />
214+
/// <remarks>
215+
/// All referenced members are evaluated in order and combined with a logical AND. Throws
216+
/// <see cref="InvalidOperationException"/> if a member can't be resolved as a
217+
/// <see langword="public"/> <see langword="static"/> <see cref="bool"/> property, field, or
218+
/// parameterless method. Resolved members are cached after the first access so subsequent
219+
/// evaluations don't pay the reflection cost again.
220+
/// </remarks>
221+
public override bool IsConditionMet
222+
{
223+
get
224+
{
225+
Func<bool>[] evaluators = _evaluators ??= BuildEvaluators();
226+
return evaluators.All(static evaluator => evaluator());
227+
}
228+
}
229+
230+
private Func<bool>[] BuildEvaluators()
231+
{
232+
var evaluators = new Func<bool>[_conditionMemberNames.Length];
233+
for (int i = 0; i < _conditionMemberNames.Length; i++)
234+
{
235+
evaluators[i] = BuildEvaluator(_conditionMemberNames[i]);
236+
}
237+
238+
return evaluators;
239+
}
240+
241+
private Func<bool> BuildEvaluator(string memberName)
242+
{
243+
const BindingFlags Flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy;
244+
string typeName = ConditionType.FullName ?? ConditionType.Name;
245+
246+
PropertyInfo? property = ConditionType.GetProperty(memberName, Flags);
247+
if (property is not null)
248+
{
249+
return property.PropertyType != typeof(bool)
250+
|| property.GetIndexParameters().Length != 0
251+
|| property.GetGetMethod(nonPublic: false) is null
252+
? throw new InvalidOperationException(
253+
$"Member '{typeName}.{memberName}' must be a public static bool readable parameterless property to be used with [MemberCondition].")
254+
: () => (bool)property.GetValue(null)!;
255+
}
256+
257+
FieldInfo? field = ConditionType.GetField(memberName, Flags);
258+
if (field is not null)
259+
{
260+
return field.FieldType != typeof(bool)
261+
? throw new InvalidOperationException(
262+
$"Member '{typeName}.{memberName}' must be a public static bool field to be used with [MemberCondition].")
263+
: () => (bool)field.GetValue(null)!;
264+
}
265+
266+
MethodInfo? method = ConditionType.GetMethod(memberName, Flags, binder: null, types: Type.EmptyTypes, modifiers: null)
267+
?? throw new InvalidOperationException(
268+
$"Could not find a public static bool property, field, or parameterless method named '{memberName}' on type '{typeName}'.");
269+
270+
return method.ReturnType != typeof(bool)
271+
? throw new InvalidOperationException(
272+
$"Member '{typeName}.{memberName}' must be a public static parameterless bool method to be used with [MemberCondition].")
273+
: () => (bool)method.Invoke(null, null)!;
274+
}
275+
276+
private string FormatMemberList()
277+
=> _conditionMemberNames.Length == 1
278+
? _conditionMemberNames[0]
279+
: string.Join(" AND ", _conditionMemberNames);
280+
}

src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ Microsoft.VisualStudio.TestTools.UnitTesting.AssemblyFixtureProviderAttribute.As
55
Microsoft.VisualStudio.TestTools.UnitTesting.AssemblyFixtureProviderAttribute.FixtureType.get -> System.Type!
66
Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ActualText.get -> string?
77
Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException.ExpectedText.get -> string?
8+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute
9+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.MemberConditionAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ConditionMode mode, System.Type! conditionType, string! conditionMemberName) -> void
10+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.MemberConditionAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ConditionMode mode, System.Type! conditionType, string! conditionMemberName, params string![]! additionalConditionMemberNames) -> void
11+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.MemberConditionAttribute(System.Type! conditionType, string! conditionMemberName) -> void
12+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.MemberConditionAttribute(System.Type! conditionType, string! conditionMemberName, params string![]! additionalConditionMemberNames) -> void
13+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.ConditionMemberNames.get -> System.Collections.Generic.IReadOnlyList<string!>!
14+
Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.ConditionType.get -> System.Type!
15+
override Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.GroupName.get -> string!
16+
override Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute.IsConditionMet.get -> bool
817
Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder
918
Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder.InAnyOrder = 1 -> Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder
1019
Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder.InOrder = 0 -> Microsoft.VisualStudio.TestTools.UnitTesting.SequenceOrder

0 commit comments

Comments
 (0)