Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Вступление
Думаю, большинство читателей согласится, что автоматизированное тестирование - полезный, а во многих областях даже необходимый, этап создания программ. А так как программисты - народ ленивый, то и инструментов, облегчающих этот этап существует немало. Одним из таких инструментов является AutoFixture - средство для генерации тестовых экземпляров. Этот инструмент уже не раз упомянался на Хабре, например тут. Далее я расскажу, с какой проблемой столкнулся в попытке применить AutoFixture в своей работе и как решил эту проблему.
Вкратце напомню, как выглядит использование AutoFixture на практике.
using AutoFixture;
var fixture = new Fixture();
var intValue = fixture.Create<int>();
Console.WriteLine(intValue);
var complexType = fixture.Create<ComplexType>();
Console.WriteLine(complexType);
var collection = fixture.Create<List<ComplexType>>();
Console.WriteLine(string.Join(", ", collection));
record ComplexType(int IntValue, string StringValue);
Как видно из приведённого выше примера, инструмент способен создавать и встроенные типы, и пользовательские, и коллекции произвольных типов. Главное - чтобы у них был доступен конструктор, а типы его параметров, в свою очередь подходили под эти же условия.
Проблема
Мне в работе понадобилось создавать тестовые данные типов gRPC сообщений. Сами эти типы генерируются автоматически по proto-файлам.
Для начала, давайте создадим экземпляр сообщения для такого контракта:
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
using AutoFixture;
using AutoFixtureWithGrpc;
var fixture = new Fixture();
var message = fixture.Create<HelloRequest>();
Console.WriteLine(message);
Пока всё работает: экземпляр создаётся, свойство инициализируется непустой строкой, класс!
Попробуем добавить поле с атрибутом repeated. По спецификации protobuf такие поля могут иметь любое количество элементов.
message HelloRequest {
string name = 1;
repeated int32 lucky_numbers = 2;
}
Бам!!! Что случилось? Коллекция LuckyNumbers
в экземпляре сгенерированного типа оказывается пустой. Дело в том, что AutoFixture по умолчанию инициализирует экземпляр типа, вызывая его конструктор, а затем все доступные сеттеры свойств. А repeated-поля контракта становятся свойствами, у которых есть только геттер, а сеттера нет:
public sealed partial class HelloRequest : pb::IMessage<HelloRequest>
{
// .. часть кода пропущена для краткости
public HelloRequest() { }
public pbc::RepeatedField<int> LuckyNumbers {
get { /* ... */ }
}
}
Из кода видно, что у свойства LuckyNumbers
отсутсвует доступный сеттер, поэтому-то AutoFixture и не смог заполнить коллекцию элементами!
Быстрое "гугление" подсказало, что можно покрутить настройки AutoFixture таким образом:
var fixture = new Fixture();
fixture.Behaviors.Add(new ReadonlyCollectionPropertiesBehavior());
Такая настройка должна сообщить инструменту, что нужно заполнять свойства-коллекции даже если у них отсутствует доступный сеттер. Лишь бы был геттер, да метод Add
у коллекции.
Пробуем:
fixture.Behaviors.Add(new ReadonlyCollectionPropertiesBehavior());
var message = fixture.Create<HelloRequest>();
Console.WriteLine(message.LuckyNumbers.Count);
и получаю Бам №2!!! :
System.Reflection.AmbiguousMatchException: Ambiguous match found.
Тут я, признаюсь, немного приуныл. Затем решил проверить, в чём же дело: в AutoFixture или в сгенерированном по контракту коде. Для этого я набросал небольшой класс с таким же свойством без сеттера с той лишь разницей, что в этот раз типом коллекции был простой List<int>
.
class Investigation
{
private readonly List<int> _values = new();
public List<int> Ints => _values;
}
fixture.Behaviors.Add(new ReadonlyCollectionPropertiesBehavior());
var message = fixture.Create<Investigation>();
Console.WriteLine(message.Ints.Count);
На этот раз никакого исключения не вылетело, в коллеции лежали элементы, как и положено. Подозрение, что в прошлый раз исключение появилось из-за особенностей класса RepeatedField<T>
всё крепло.
Я зарылся в отладчик, пытаясь понять, что же такого неоднозначного (ambiguous) было в RepeatedField
, чего не было у List
. В отладчике ставлю точку останова на исключение System.Reflection.AmbiguousMatchException
.
Довольно быстро выяснилось, что исключение происходит в методе InstanceMethodQuery.SelectMethods
. Благо, исходный код инструмента открыт, привожу текст метода:
public IEnumerable<IMethod> SelectMethods(Type type = default)
{
var method = this.Owner.GetType().GetTypeInfo().GetMethod(this.MethodName);
return method == null
? new IMethod[0]
: new IMethod[] { new InstanceMethod(method, this.Owner) };
}
И при этом MethodName
имеет значение "Add". Обозреватель сборок в Rider-е показал (см. картинку), что у типа RepeaterField есть два публичных метода Add: один для одиночного элемента, другой - для их последовательности. Поэтому-то AutoFixture не мог выбрать, какой именно метод ему нужен и падал с ошибкой. А если точнее, то падал метод GetMethod
в кишках дотнетовского рантайма.
Решение
Ну что же, причина проблемы стала ясна. Оставалось придумать решение. Я решил добавить в AutoFixture дополнительную настройку, позволяющую инициализировать именно экземпляры типа RepeatedField<T>
. По счастью, у этого злополучного типа оказался метод AddRange
, который я и собрался использовать для наполнения коллекции.
Я решил идти проверенным методом copy-paste и продублировать код ReadonlyCollectionPropertiesBehavior
, меняя его лишь по необходимости. Оказалось, что менять придётся совсем немного: поиск подходящего метода инициализации (того самого AddRange
) и подготовку параметров для него. Потому что если ReadonlyCollectionPropertiesBehavior
заполнял коллекцию поэлементно, вызывая Add
, то мне предстояло сперва подготовить последовательность элементов, и лишь затем единожды вызвать AddRange
, передав её всю целиком.
Тут уже никаких сложностей не осталось. Готовое решение можно найти в моём репозитории на гитхабе.
Я благодарен авторам AutoFixture за такой полезный инструмент и призываю всех шарпистов рассмотреть возможность использовать его в своей практике.