По непроверенным данным, половина несчастных случаев происходит после слов "смотри, как я умею", другая же половина — после "ерунда, смотри, как надо".
Тут один приятель, увидев фокус с тестами без тестов с использованием обобщенных аттрибутов из preview версии C# и особенностей экосистемы NUnit, отметил, что все сделано транс-ректально, а сам бы он применил Fody, и вышло бы гораздо лучше. Демонстрировать, к сожалению, ничего не стал. А мне вспомнился комментарий к описанию другого преодоления концептуального ограничения языка. Тогда руки не дошли попробовать, а сейчас вот решил глянуть, что это за птица, и проверить, поможет ли она написать более элегантное решение.
Дано
Итак, если еще больше упростить то, что использовалось в фокусе, то имеем следующее:
Некоторый скрипт `IScript`, который может быть атакой `Attack` или продвинутой атакой `AdvancedAttack`
public interface IScript { }
public class Attack : IScript { }
public class AdvancedAttack : Attack { }
Проверяющий скрипт валидатор `IValidator`, который может быть простым `OrdinaryValidator` или продвинутым `AdvancedValidator`
public interface IValidator
{
void Validate(IScript script);
}
public class OrdinaryValidator : IValidator
{
void IValidator.Validate(IScript script)
{
if (script is Attack && script is not AdvancedAttack)
throw new Exception("Attack detected.");
}
}
public class AdvancedValidator : IValidator
{
void IValidator.Validate(IScript script)
{
if (script is Attack)
throw new Exception("Attack detected.");
}
}
И проверка `ICheck`. Мы будем использовать проверку на обнаружение атаки `AttackDetected`
public interface ICheck
{
bool Check(IValidator validator, IScript script);
}
public class AttackDetected : ICheck
{
public bool Check(IValidator validator, IScript script)
{
try
{
validator.Validate(script);
return false;
}
catch
{
return true;
}
}
}
Тесты для проверки взаимодействия валидаторов (простого OrdinaryValidator
и продвинутого AdvancedValidator
) с продвинутой атакой AdvancedAttack
можно написать так:
using NUnit.Framework;
[TestFixture]
public class DemoTests : IDeclarativeTest
{
public void Test<TValidator, TScript, TCheck>(bool expected)
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new() =>
DefaultDeclarativeTest.Test<TValidator, TScript, TCheck>(expected);
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
}
Фокус в том, что это тесты без тела метода, описываемые аттрибутом и в нем же содержащиеся.
Под капотом `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` выглядит так
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : GenericCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
public DeclarativeCaseAttribute(bool expected)
: base(expected) { }
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
IEnumerable<TestMethod> tests;
var type = suite.TypeInfo.Type;
if (!typeof(IDeclarativeTest).IsAssignableFrom(type))
tests = base.BuildFrom(method, suite)
.SetNotRunnable($"{type} does not implement {typeof(IDeclarativeTest)}.");
else if (!method.MethodInfo.IsIdle())
tests = base.BuildFrom(method, suite)
.SetNotRunnable("Method is not idle, i.e. does something.");
else
tests = base.BuildFrom(new MethodWrapper(type, nameof(IDeclarativeTest.Test)), suite);
return tests.Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
}
private static string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({
string.Join(',', test.Arguments)})";
}
Он наследует от GenericCaseAttribute
, который в отличие от своего базового класса TestCaseAttribute
умеет в обобщенные методы через повторную реализацию ITestBuilder
.
`GenericCaseAttribute`
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class GenericCaseAttribute : TestCaseAttribute, ITestBuilder
{
private readonly IReadOnlyCollection<Type> typeArguments;
public GenericCaseAttribute(params object[] arguments)
: base(arguments) => typeArguments = GetType().GetGenericArguments();
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
try
{
return base.BuildFrom(
method.IsGenericMethodDefinition || typeArguments.Any() ?
method.MakeGenericMethod(typeArguments.ToArray()) :
method,
suite);
}
catch (Exception ex)
{
return base.BuildFrom(method, suite).SetNotRunnable(ex.Message);
}
}
}
Сам DeclarativeCaseAttribute<TValidator, TScript, TCheck>
тоже повторно реализует ITestBuilder
, чтобы подменять тестовый метод на IDeclarativeTest.Test
, и для этого требует реализации IDeclarativeTest
тестовым классом. (Вариант с подменой тестового метода на метод аттрибута без необходимости реализации IDeclarativeTest
тестовым классом возможен, но только с использование неочевидного и недокументориованного поведения NUnit, поэтому он был отвергнут в окончательной версии). Дополнительно над именами тестов совершаются магические действия для красивой работы Test Explorer.
Еще всякая мелочь происходит в методах расширения
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
public static class TestMethodExtensions
{
public static IEnumerable<TestMethod> SetNotRunnable(this IEnumerable<TestMethod> tests, string message)
{
foreach(var test in tests)
yield return test.SetNotRunnable(message);
}
public static TestMethod SetNotRunnable(this TestMethod test, string message)
{
test.RunState = RunState.NotRunnable;
test.Properties.Set(PropertyNames.SkipReason, message);
return test;
}
}
public static class MethodInfoExtensions
{
private static readonly IReadOnlyCollection<byte> idle = new[]
{
OpCodes.Nop,
OpCodes.Ret
}.Select(code => (byte)code.Value).ToArray();
public static bool IsIdle(this MethodInfo method)
{
var body = method.GetMethodBody();
if (body.LocalVariables.Any())
return false;
if (body.GetILAsByteArray().Except(idle).Any())
return false;
if (method.DeclaringType.GetMethods(BindingFlags.Static | BindingFlags.NonPublic)
.Any(candidate => IsLocalMethod(candidate, method)))
return false;
return true;
}
private static bool IsLocalMethod(MethodInfo method, MethodInfo container) =>
method.Name.StartsWith($"<{container.Name}>") &&
method.GetCustomAttributes<CompilerGeneratedAttribute>().Any();
}
В тестовом классе DemoTests
реализация IDeclarativeTest
делегирует работу DefaultDeclarativeTest
, котрорый, фактически, и содержит код теста.
using NUnit.Framework;
public static class DefaultDeclarativeTest
{
public static void Test<TValidator, TScript, TCheck>(bool expected)
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
// Arrange
IValidator validator = new TValidator();
IScript script = new TScript();
ICheck check = new TCheck();
// Act
bool actual = check.Check(validator, script);
// Assert
Assert.AreEqual(expected, actual);
}
}
Лирическое отступление
DeclarativeCaseAttribute<TValidator, TScript, TCheck>
и DefaultDeclarativeTest
немного отличаются от своих версий из прошлой статьи благодаря self-review через некоторое время после публикации. Вообще, ревью - отличная штука. Помню, в одной небольшой команде у нас была практика, когда каждый ревьюил каждого. Это было добровольно, но обычно откликалось более одного человека. И пока кто-то погружался в глубины архитектуры, другие находили мелочи, ускользавшие от взгляда первых, и результат становился только лучше. Короче, братие, да ревьюите друг друга.
Решение
Для начала удаляем GenericCaseAttribute
с IDeclarativeTest
и их следы. Попутно в DeclarativeCaseAttribute<TValidator, TScript, TCheck>
заводим поле bool expected
и используем его для хранения аргумента без передачи в конструктор TestCaseAttribute
(для предотвращения падения тестов с сообщением "Arguments provided for method with no parameters").
Теперь `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` выглядит так
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : TestCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
IEnumerable<TestMethod> tests;
if (!method.MethodInfo.IsIdle())
tests = base.BuildFrom(method, suite)
.SetNotRunnable("Method is not idle, i.e. does something.");
else
tests = base.BuildFrom(method, suite);
return tests.Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
}
private string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({expected})";
}
А тестовый класс так (тесты, кстати, уже ничего не делают):
using NUnit.Framework;
[TestFixture]
public class DemoTests
{
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
}
Пришло время выбрать weaver. Можно посмотреть через NuGet UI по фильтру "fody" и заметить, что потенциально подходят MethodBoundaryAspect.Fody и MethodDecorator. Они оба живые, но у первого в 1.5 раза больше загрузок, поэтому останавливаемся на нем. Есть еще одна причина, но о ней чуть позже.
NuGet UI
Идем в пример работы с аспектом и по косвенным признакам видим, что он представляет из себя класс. А DeclarativeCaseAttribute<TValidator, TScript, TCheck>
уже наследует TestCaseAttribute
. К счастью, для NUnit главное - реализация соответствующих интерфейсов (те же TestCaseAttribute
и TestAttribute
не связаны цепочкой наследования, но оба распознаются, как тестовые аттрибуты). Демонстрация этого нюанса и есть причина выбора weaver'а. Так что наследуем DeclarativeCaseAttribute<TValidator, TScript, TCheck>
от OnMethodBoundaryAspect
и переопределяем, например, void OnEntry(MethodExecutionArgs)
, а реализацию ITestBuilder
, ITestCaseData
, IImplyFixture
делаем с помощью поля TestCaseAttribute testCaseAttribute
. Заодно предотвращаем падение тестов с сообщением "Method is not idle, i.e. does something" (weaver переписывает метод до его добавления в тесты), удаляя соответствующую проверку (но потом хорошо бы ее вернуть).
`DeclarativeCaseAttribute<TValidator, TScript, TCheck>`
using MethodBoundaryAspect.Fody.Attributes;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> :
OnMethodBoundaryAspect, ITestBuilder, ITestCaseData, IImplyFixture
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
private readonly TestCaseAttribute testCaseAttribute = new();
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public override void OnEntry(MethodExecutionArgs arg) =>
DefaultDeclarativeTest.Test<TValidator, TScript, TCheck>(expected);
public IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
testCaseAttribute.BuildFrom(method, suite).Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
public object ExpectedResult => testCaseAttribute.ExpectedResult;
public bool HasExpectedResult => testCaseAttribute.HasExpectedResult;
public string TestName => testCaseAttribute.TestName;
public RunState RunState => testCaseAttribute.RunState;
public object[] Arguments => testCaseAttribute.Arguments;
public IPropertyBag Properties => testCaseAttribute.Properties;
private string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({expected})";
}
В награду за все эти приседания получаем падение тестов с сообщением:
Message:
System.InvalidOperationException : Could not execute the method because either the method itself or the containing type is not fully instantiated.
Stack Trace:
DeclarativeCaseAttribute`3.ctor(Boolean expected)
DemoTests.TestAdvancedAttack()
Думаю, пора проверить, что же там нагенерировалось в тестовый метод. Смотрим содержимое метода в каком-нибудь ILSpy и видим:
object[] __var_0 = new object[0];
MethodExecutionArgs __var_4 = new MethodExecutionArgs();
__var_4.Arguments = __var_0;
MethodBase __var_5 = (__var_4.Method = MethodInfos._methodInfo_2742FEFF28FE4F5C0DA05D8E6FB631BC053D523283F80EE7DFD2FA576C10BE40);
DemoTests __var_1 = (DemoTests)(__var_4.Instance = this);
DeclarativeCaseAttribute<OrdinaryValidator, AdvancedAttack, AttackDetected> __var_6 = (DeclarativeCaseAttribute<OrdinaryValidator, AdvancedAttack, AttackDetected>)(object)new DeclarativeCaseAttribute<, , >(expected: false);
DeclarativeCaseAttribute<AdvancedValidator, AdvancedAttack, AttackDetected> __var_8 = (DeclarativeCaseAttribute<AdvancedValidator, AdvancedAttack, AttackDetected>)(object)new DeclarativeCaseAttribute<, , >(expected: true);
((DeclarativeCaseAttribute<, , >)(object)__var_6).OnEntry(__var_4);
object __var_7 = __var_4.MethodExecutionTag;
FlowBehavior __var_2 = __var_4.FlowBehavior;
if (__var_2 != FlowBehavior.Return)
{
((DeclarativeCaseAttribute<, , >)(object)__var_8).OnEntry(__var_4);
object __var_9 = __var_4.MethodExecutionTag;
FlowBehavior __var_3 = __var_4.FlowBehavior;
if (__var_3 != FlowBehavior.Return)
{
$_executor_TestAdvancedAttack();
}
}
Очевидно, для каждого DeclarativeCaseAttribute<TValidator, TScript, TCheck>
добавляется дополнительный код. Это легко подтвердить, меня количество аттрибутов. Также присутствие в сгенерированном коде DeclarativeCaseAttribute<, , >(expected: false)
говорит о трудностях работы с обобщенными типами. Как-то пока не выходит каменный цветок.
Возвращаемся к наследованию от `TestCaseAttribute` и немного рефакторим `string CreateName(IMethodInfo, Func<Type, string>)`
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : TestCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
base.BuildFrom(method, suite).Select(test =>
{
test.FullName = CreateName(method, type => type.FullName);
test.Name = CreateName(method, type => type.Name);
return test;
});
private string CreateName(IMethodInfo method, Func<Type, string> nameSelector) =>
$"{nameSelector(method.TypeInfo.Type)}.{method.Name}<{
string.Join(",", types.Select(nameSelector))}>({expected})";
}
Создаем аттрибут DeclarativeAttribute
:
using MethodBoundaryAspect.Fody.Attributes;
public class DeclarativeAttribute : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs arg)
{
arg.FlowBehavior = FlowBehavior.Return; // original method's code won't execute
// ???
}
}
И применяем его к тестам:
[Declarative]
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
Теперь возникает вопрос, что же делать в void OnEntry(MethodExecutionArgs)
и как узнать, в связи с каким DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>
он вызывается. Если вы хотите ответов - их есть у меня. Но сперва небольшой рефакторинг.
В `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` реализуем `IDeclarativeTest`, помещая в интерфейсный метод код теста, класс `DefaultDeclarativeTest` же за ненадобностью удаляем.
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public interface IDeclarativeTest
{
public void Test();
}
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> :
TestCaseAttribute, ITestBuilder, IDeclarativeTest
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
// ...
public void Test()
{
// Arrange
IValidator validator = new TValidator();
IScript script = new TScript();
ICheck check = new TCheck();
// Act
bool actual = check.Check(validator, script);
// Assert
Assert.AreEqual(expected, actual);
}
// ...
}
А теперь финт ушами. У NUnit.Framework.Internal.Test
есть замечательное свойство IPropertyBag Properties
. Используем его в DeclarativeCaseAttribute<TValidator, TScript, TCheck>
при создании тестов:
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
base.BuildFrom(method, suite).Select(test =>
{
test.Properties.Add(typeof(DeclarativeCaseAttribute<TValidator, TScript, TCheck>).FullName, this);
test.FullName = CreateName(method, type => type.FullName);
test.Name = CreateName(method, type => type.Name);
return test;
});
Эти Properties
доступны в NUnit.Framework.TestContext.TestAdapter
через PropertyBagAdapter Properties
, а текущий тест доступен через NUnit.Framework.TestContext.CurrentContext.Test
. Для удобства можно упростить доступ к Properties
с помощью метода расширения:
using System.Collections.Generic;
using System.Linq;
using static NUnit.Framework.TestContext;
public static class TestAdapterExtensions
{
public static IEnumerable<object> GetProperties(this TestAdapter test) =>
test.Properties.Keys.SelectMany(key => test.Properties[key]);
}
И тогда реализация DeclarativeAttribute
выглядит так:
using MethodBoundaryAspect.Fody.Attributes;
using NUnit.Framework;
using System.Linq;
public class DeclarativeAttribute : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs arg)
{
arg.FlowBehavior = FlowBehavior.Return; // original method's code won't execute
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
}
Ранее была удалена защита от непустых тестовых методов. Настало время ее вернуть:
public override void OnEntry(MethodExecutionArgs arg)
{
var method = arg.Method.DeclaringType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
.Single(m => m.Name.Equals($"$_executor_{arg.Method.Name}"));
if (!method.IsIdle())
throw new InvalidOperationException("Test method is not idle, i.e. does something.");
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
Чтобы не вешать DeclarativeAttribute
на каждый тестовый метод:
[TestFixture]
public class DemoTests
{
[Declarative]
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
public void TestOrdinaryValidator() { }
[Declarative]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedValidator() { }
}
Можно применить его всего один раз ко всему тестовому классу:
[TestFixture, Declarative]
public class DemoTests
{
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
public void TestOrdinaryValidator() { }
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedValidator() { }
}
Но тогда пропатчатся все методы класса, и добавление такого теста:
[Test]
public void NormalTest()
{
Assert.Pass();
}
Приведет к его падению с сообщением "System.InvalidOperationException: Test method is not idle, i.e. does something", но, к счастью, это тоже лечится. Если вызывающий метод не помечен аттрибутом, реализующим IDeclarativeTest
, то досрочно выходим из void OnEntry(MethodExecutionArgs)
:
public override void OnEntry(MethodExecutionArgs arg)
{
var isIDeclarativeTest = arg.Method.CustomAttributes
.Select(attribute => attribute.AttributeType)
.Any(type => type.IsAssignableTo(typeof(IDeclarativeTest)));
if (!isIDeclarativeTest)
return;
var method = arg.Method.DeclaringType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
.Single(m => m.Name.Equals($"$_executor_{arg.Method.Name}"));
if (!method.IsIdle())
throw new InvalidOperationException("Test method is not idle, i.e. does something.");
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
Конечно, void NormalTest()
все равно пропатчится, но внешне это будет незаметно.
Ответ
С помощью наиболее подходящего из доступных Fody-плагинов, особенностей NUnit и такой-то матери удалось реализовать концепцию декларативных тестов из первоначальной публикации, но по субъективным ощущениям решение получилось еще более костыльное. Возможно, написание собственного плагина могло бы улучшить ситуацию.
ggo
Без обид.
Но для меня важный критерий для тестов - может ли посторонний человек посмотреть на код теста, на результат теста и через 10-30 секунд сказать что тест проверяет, и почему он сломался. Пусть даже с не 100% точностью.
Вот смотрю я на код тестов, и понимаю, что даже примерно ответить на такой вопрос сложно. Без погружения к предметную область данного конкретного теста.
CSDev Автор
Тесты, о которых Вы пишете - то, к чему надо стремиться. В рафинированном виде такие встречаются только в обучающих материалах по TDD. В жизни обычно приходишь на новый проект, смотришь на юнит тесты и ужасаешься. В некоторых компаниях их до сих пор вообще не применяют. А даже там, где юнит тестирование организованно более или менее прилично, могут быть иерархии наследования тестовых классов с переопределениями вспомогательных методов типа SetUp(), TearDown() или CreateSut(), чтобы не было дублирующегося кода. Если впадать в крайности пуризма, то такие методы даже без наследования тестовых классов надо объявлять ересью.
Это решается конвенционально. Также как, например, организации тестов по принципу AAA или создание sut только через CreateSut(). Просто вся команда знает (и в проекте даже соответствующая инструкция лежит), что если тест помечен
DeclarativeCaseAttribute<TValidator, TScript, TCheck>
, то дляэто означает, что фактически тест содержится в
void Test<TValidator, TScript, TCheck>(bool)
тестового класса, а дляподразумевается, что непосредственно код теста лежит в
void Test()
аттрибута (пара кликов/хот-кеев - и перед Вами фактическая реализация теста). Если так делать постоянно, то оно начнет восприниматься, как само собой разумеющееся. Многие ли задумываются о том, что происходит под капотом, когда для добавления теста просто пишут[Test]
, а потом в Test Explorer видят результат его выполнения?Вообще статья является продолжением другой публикации с пометкой "ненормальное программирование". Я необоснованно предположил, что в сознании читающего этот аттрибут неявно наследуется. Сейчас исправлю. В обеих описывается не как нужно делать, а как можно. Вот пришла человеку в голову занимательная идея, он ее воплотить попытался, глубже в инструменты погрузился, столкнулся с ограничениями, но нашел workaround'ы, а свой опыт здесь описал. Наверное, с названием "Как хакнуть NUnit, чтобы заставить делать странное" воспринималось бы иначе.