В прошлом году обновление .Net принесло фичу: генераторы исходного кода. Мне стало интересно что это такое и я решил написать генератор моков, чтоб на вход брал интерфейс или абстрактный класс и выдавал моки, которые можно использовать в тестировании с aot компиляторами. Почти сразу встал вопрос: а как тестировать сам генератор? На тот момент официальная поваренная книга не содержала рецепт как это сделать правильно. Позже эту проблему исправили, но, возможно, вам будет интересно посмотреть как работают тесты в моём проекте.
В поваренной книге есть простой рецепт как именно запускать генератор. Вы можете натравить его на кусок исходного кода и убедиться, что генерация завершается без ошибок. И тут возникает вопрос: как убедиться что код создан правильно и правильно работает? Можно конечно взять какой-то эталонный код, разобрать его с помощью CSharpSyntaxTree.ParseText и потом сравнить через IsEquivalentTo. Однако код имеет свойство меняться, да и сравнение с кодом функционально идентичным, но отличающийся комментариями и пробельными символами давало у меня отрицательный результат. Что-же пойдём длинным путём:
Создадим компиляцию;
Создадим и запустим генератор;
Выполним сборку библиотеки и загрузим её в текущий процесс;
Найдём там полученный код и выполним его.
Компиляция
Запуск компилятора производится с помощью функции CSharpCompilation.Create. Здесь можно добавить код и подключить ссылки на библиотеки. Исходный код подготавливается с помощью CSharpSyntaxTree.ParseText, а библиотеки MetadataReference.CreateFromFile (есть варианты для потоков и массивов). Как добыть путь? В большинстве случаев всё просто:
typeof(UnresolvedType).Assembly.Location
Однако в некоторых случаях тип находится в базовой (reference) сборке, тогда работает вот это:
Assembly.Load(new AssemblyName("System.Linq.Expressions")).Location
Assembly.Load(new AssemblyName("System.Runtime")).Location
Assembly.Load(new AssemblyName("netstandard")).Location
Как может выглядеть создание компиляции
protected static CSharpCompilation CreateCompilation(string source, string compilationName)
=> CSharpCompilation.Create(compilationName,
syntaxTrees: new[]
{
CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Preview))
},
references: new[]
{
MetadataReference.CreateFromFile(Assembly.GetCallingAssembly().Location),
MetadataReference.CreateFromFile(typeof(string).Assembly.Location),
MetadataReference.CreateFromFile(typeof(LightMock.InvocationInfo).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IMock<>).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Xunit.Assert).Assembly.Location),
MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Linq.Expressions")).Location),
MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("System.Runtime")).Location),
MetadataReference.CreateFromFile(Assembly.Load(new AssemblyName("netstandard")).Location),
},
options: new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
Запуск генератора и создание сборки
Тут всё просто: дёргается CSharpGeneratorDriver.Create, туда отдаётся генератор, опции компиляции и дополнительные тексты (aka AdditionalFiles из csproj). Потом из CSharpGeneratorDriver.RunGeneratorsAndUpdateCompilation получается обновлённая компиляция, из которой можно получить байт код сборки. На этом этапе можно записать получившиеся ошибки и предупреждения в, например ITestOutputHelper от Xunit для последующего анализа. Это проще, чем тыкать в поля в отладчике и при просмотре выглядит как окошко Output студии.
Как может выглядеть запуск генератора и получени сборки
protected (ImmutableArray<Diagnostic> diagnostics, bool success, byte[] assembly) DoCompile(string source, string compilationName)
{
var compilation = CreateCompilation(source, compilationName);
var driver = CSharpGeneratorDriver.Create(
ImmutableArray.Create(new LightMockGenerator()),
Enumerable.Empty<AdditionalText>(),
(CSharpParseOptions)compilation.SyntaxTrees.First().Options);
driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out var diagnostics);
var ms = new MemoryStream();
var result = updatedCompilation.Emit(ms);
foreach (var i in result.Diagnostics)
testOutputHelper.WriteLine(i.ToString());
return (diagnostics, result.Success, ms.ToArray());
}
Загрузка библиотеки и поиск кода
В .Net Core для этого придумали AssemblyLoadContext. Этот класс может загружать и выгружать сборки. После загрузки вы получаете ссылку на Assembly, с которой можно работать. Тут опять ничего сложного: рефлексия спешит на помощь. Остаётся решить к какому типу приводить полученный объект. Вы всегда можете использовать dynamic или приводить к какому-то известному интерфейсу. Интерфейс может лежать в сборке с тестами, ссылку на которую можно, также добавить в компиляцию. Я использую интерфейс, в сборке с тестами и добавляю в компиляцию исходный код с классом, который от этого интерфейса наследуется.
Интерфейс может выглядеть так
public interface ITestScript<T>
where T : class
{
IMock<T> Context { get; } // интерфейс для сгенерированного кода
T MockObject { get; } // интерфейс для сгенерированнго объекта
int DoRun(); // чтобы тестировать сгенерированные функции,
// которые сложно пробросить наружу
}
Пример дополнительного исходного кода
using System;
using Xunit;
namespace LightMock.Generator.Tests.Mock
{
public class AbstractClassWithBasicMethods : ITestScript<AAbstractClassWithBasicMethods>
{
// этот объект Mock<T> был сгенерирован
private readonly Mock<AAbstractClassWithBasicMethods> mock;
public AbstractClassWithBasicMethods()
=> mock = new Mock<AAbstractClassWithBasicMethods>();
public IMock<AAbstractClassWithBasicMethods> Context => mock;
public AAbstractClassWithBasicMethods MockObject => mock.Object;
public int DoRun()
{
// функция Protected() была сгенерирована
mock.Protected().Arrange(f => f.ProtectedGetSomething()).Returns(1234);
Assert.Equal(expected: 1234, mock.Object.InvokeProtectedGetSomething());
mock.Object.InvokeProtectedDoSomething(5678);
mock.Protected().Assert(f => f.ProtectedDoSomething(5678));
return 42;
}
}
}
Проверка опций анализатора
Если нужно проверить опции, которые добавляются в файл проекта, то придётся провести дополнительную работу: создать подклассы для AnalyzerConfigOptionsProvider и AnalyzerConfigOptions.
Например так
sealed class MockAnalyzerConfigOptions : AnalyzerConfigOptions
{
public static MockAnalyzerConfigOptions Empty { get; }
= new MockAnalyzerConfigOptions(ImmutableDictionary<string, string>.Empty);
private readonly ImmutableDictionary<string, string> backing;
public MockAnalyzerConfigOptions(ImmutableDictionary<string, string> backing)
=> this.backing = backing;
public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value)
=> backing.TryGetValue(key, out value);
}
sealed class MockAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
{
private readonly ImmutableDictionary<object, AnalyzerConfigOptions> otherOptions;
public MockAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions)
: this(globalOptions, ImmutableDictionary<object, AnalyzerConfigOptions>.Empty)
{ }
public MockAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions,
ImmutableDictionary<object, AnalyzerConfigOptions> otherOptions)
{
GlobalOptions = globalOptions;
this.otherOptions = otherOptions;
}
public static MockAnalyzerConfigOptionsProvider Empty { get; }
= new MockAnalyzerConfigOptionsProvider(
MockAnalyzerConfigOptions.Empty,
ImmutableDictionary<object, AnalyzerConfigOptions>.Empty);
public override AnalyzerConfigOptions GlobalOptions { get; }
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
=> GetOptionsPrivate(tree);
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
=> GetOptionsPrivate(textFile);
AnalyzerConfigOptions GetOptionsPrivate(object o)
=> otherOptions.TryGetValue(o, out var options) ? options : MockAnalyzerConfigOptions.Empty;
}
В CSharpGeneratorDriver.Create есть параметр optionsProvider, запихивается туда. У меня в генераторе реализована единственная опция, которая отключает генерацию кода. Проверяется в тесте просто, нашла рефлексия генерируемый код или нет.
Дополнительно
Если занимаетесь разработкой генератора исходного кода не забывайте следить за поваренной книгой она время от времени обновляется.
Вы можете добавить один и тот-же файл в проект как исходный код и как ресурс. Полезно для проверки шаблонов компилятором и их рефакторинга, а также для доступа к именам классов, полей и методов. В проекте можно использовать маски. Будьте осторожны студия на это может реагировать некорректно.
Когда добавляете исходный код в компиляцию, то не забывайте указывать теги. Эти теги потом помогут понять в какой части сгенерированного кода компилятор нашёл ошибку.
Также полезен текстовый редактор, который может в подсветку шарповского кода и переход по номерам строки и символа. Нужно, чтобы скопипастить сгенерированный код, а потом посмотреть ошибку, которую для вас сохранил, например ITestOutputHelper из Xunit.
Незабывайте проверять отмену генерации, через полученный CancellationToken. Так студия меньше фризит.
Генератор моков тут. Это бета версия и к использованию в проде не рекомендуется.
DmitryLTL
Когда делал генератор и подключил к нему внешнюю библиотеку, получился нехороший эффект. Эти библиотеки вылезли в проект как доступные анализаторы, хотя они не реализовывают интерфейс.
В конце концов победил, полухаком, но хотелось бы понять как другие люди это делают. На просторах интернета решения не нашёл. Может потому что не многие ещё тогда этим занимались.
Ещё совет тем кто отлаживает на других проектах, при установки из локального nuget, номера версий увеличивать при компиляции. Либо кэш нугета для этого пакета всегда чистить. Наступил на грабли пару раз.
Fynjy007 Автор
Если я правильно понял проблему, то чтобы пакет не вылез зависимостью к проекту куда анализатор добавляется, нужно устанавливать
PrivateAssets="all"
. Пример:В вышеприведённом примере LightMock будет добавлен в проект зависимостью, а остальные пакеты нет. То же самое со ссылками на проекты в решении.
DmitryLTL
Нет, проблема в том что если вы добавите например newtonsoft.json к своему проекту генератора, после чего слелаете nuget пакет для распространения, то в это пакет вы должны добавить dll от newtonsoft, чтобы он корректно работал, там где он не установлен.
Но если его добавить в nuget, то при установке он вылезет под references/analyzers хотя это и не генератор.
Мне не хотелось чтобы он там болтался, вот и пытался найти одобреное решение для проблемы.
Fynjy007 Автор
Хм. Не задумывался над этим… Посмотрел у себя, действительно оно показывает зависимости генератора как анализаторы в netstandard 2.0 библиотеке. Однако для uwp, ios и андроид нет. Возможно технология сыровата и разработчики студии не сделали её корректную поддержку в студии.
DmitryLTL
Скорее всего да, но я делал генератор для распространения пользователям и не хотел чтобы кишки болтались, пользователи бы начали жаловаться, вот и пришлось искать решение.
А без дополнительных библиотек разрабатывать грустно. Всё в одну dll запихать будет довольно сложно.