
Итак, повторю, работающий тест – это минимальный критерий работающего кода. Мы в некоторых сервисах в качестве кэша используем Apache Ignite, и вот как раз эта часть кода с кэшированием не была покрыта тестами. Ниже опишу, как я справлялся с задачей, но, сначала сделаю пару ремарок + дам пару определений.
1) Цель статьи – показать, как можно решить проблему при написании юнит тестов, когда в коде есть зависимости от внешнего ресурса со статическими методами.
-
Unit тестирование – процесс в программировании, позволяет проверить бизнес-логику исходного кода, работает в оперативной памяти и не взаимодействует с внешними источниками (БД, файловая система, сеть и т.д.).
Для запуска Unit тестов существует множество инструментов; лично я использую xUnit, AutoFixture, Moq. -
xUnit – это технология для модульного тестирования;
-
AutoFixture упрощает инициализацию тестовых данных;
-
Moq предназначен для имитации объектов или для создания так называемых фейковых объектов.
-
Статический метод не принадлежит объекту, он — часть класса и поэтому не может быть переопределен.
2) В данной статье приведен способ покрытия одного метода, но таким же образом можно покрыть и другие методы Apache Ignite. А также описанный подход подойдет для использования и с другими системами, где могут быть проблемы с покрытием кода тестами.
-
Apache Ignite – это распределенная система управления базами данных для высокопроизводительных вычислений. Мы используем ее для кеширования данных.
Погнали!
Пример первоначального репозитория:
/// <summary> /// Изначальный репозиторий. /// </summary> public class DemoRepository { /// <summary> /// Клиент Ignite. /// </summary> private readonly IIgniteClient _igniteClient; public DemoRepository(IIgniteClient igniteClient) { _igniteClient = igniteClient; } public IList<DemoModel> Get( DemoFilter filter ) { // Получаем существующий кэш с указанным именем или создаем новый, используя конфигурацию шаблона. var cache = _igniteClient.GetOrCreateCache<int, DemoModel>(nameof(DemoModel)); var cacheData = cache // Получаем данные из кэша. // Результирующий запрос будет преобразован в запрос SQL кэша. .AsCacheQueryable() .Where( item => // Фильтруем данные по Id. filter.Ids == null || filter.Ids.Contains(item.Value.Id) ) // Сортируем. .OrderBy(item => item.Key) // Используем пейджинг: Skip - сколько данных пропустить, Take - количество получаемых данных. .Skip(filter.PageSize * filter.PageIndex) .Take(filter.PageSize); // Приводим к списку IList<DemoModel>. return cacheData .Select(item => item.Value) .ToList(); } }
Пример моделей:
/// <summary> /// Пример модели. /// </summary> public class DemoModel { public int Id { get; } public string Name { get; } public DemoModel(int id, string name) { Id = id; Name = name; } } /// <summary> /// Пример фильтра. /// </summary> public class DemoFilter { /// <summary> /// Фильтр по Id. /// </summary> public IList<int> Ids { get; } /// <summary> /// Номер страницы получаемых данных. /// </summary> public int PageIndex { get; } /// <summary> /// Количество получаемых данных. /// </summary> public int PageSize { get; } public DemoFilter(IList<int> ids, int pageIndex, int pageSize) { Ids = ids; PageIndex = pageIndex; PageSize = pageSize; } }
И пример теста:
public class DemoRepositoryTests { private readonly Fixture _fixture; private readonly Mock<IIgniteClient> _igniteClient; private readonly DemoRepository _repository; private readonly Mock<ICacheClient<int, DemoModel>> _cachClient; public DemoRepositoryTests() { _fixture = new Fixture(); _igniteClient = new Mock<IIgniteClient>(); _repository = new DemoRepository( _igniteClient.Object ); _cachClient = new Mock<ICacheClient<int, DemoModel>>(); } [Fact] public void When_Get_then_success_test() { // Arrange var demoModel1 = _fixture.Create<DemoModel>(); var demoModel2 = _fixture.Create<DemoModel>(); var demoModel3 = _fixture.Create<DemoModel>(); var cacheList = new List<ICacheEntry<int, DemoModel>> { new CacheEntry<int, DemoModel>(demoModel1.Id, demoModel1), new CacheEntry<int, DemoModel>(demoModel2.Id, demoModel2), new CacheEntry<int, DemoModel>(demoModel3.Id, demoModel3), }; var filter = new DemoFilter( ids: new[] { demoModel1.Id }, pageIndex: 0, pageSize: 2 ); _igniteClient .Setup( item => item.GetOrCreateCache<int, DemoModel>( It.IsAny<string>() ) ) .Returns(_cachClient.Object); _cachClient .Setup( item => item.AsCacheQueryable() ) .Returns(cacheList.AsQueryable()); // Act var result = _repository.Get( filter ); // Assert // Проверим, что найден только один нужный нам элемент. Assert.Single(result); // Проверим, что нужный нам элемент находится в ответе. Assert.Contains(result, model => model.Id == demoModel1.Id); // Проверим, что элементы, которые не соответствуют условию, в ответе не содержаться. Assert.DoesNotContain(result, model => model.Id == demoModel2.Id || model.Id == demoModel3.Id); }
Проблема
На первый взгляд все просто: мокнули GetOrCreateCache и AsCacheQueryable и написали проверку правильной выборки. С моком метода GetOrCreateCache проблем нет, т.к. он есть в интерфейсе. А вот с AsCacheQueryable будет проблема, т.к. этот метод статический, и мы не можем его ни мокнуть, ни переопределить.
public static IQueryable<ICacheEntry<TKey, TValue>> AsCacheQueryable<TKey, TValue>( this ICacheClient<TKey, TValue> cache)
Решение
Для начала сделаем обвязку над ICacheClient, в котором используется статический метод AsCacheQueryable.
/// <summary> /// Обертка кэша Ignite. /// </summary> public interface IDemoCacheWrapper<TK, TV> : ICacheClient<TK, TV> { /// <summary> /// Получить доступ к кэшу через IQueryable. /// </summary> IQueryable<ICacheEntry<TK, TV>> AsQueryable(); }
Затем сделаем обвязку над IgniteClient, чтобы метод GetOrCreateCache возвращал нам IDemoCacheWrapper.
/// <summary> /// Обертка над Ignite. /// </summary> public interface IDemoIgniteWrapper { /// <summary> /// Получить или создать кэш. /// </summary> IDemoCacheWrapper<TK, TV> GetOrCreateCache<TK, TV>( string name ); }
И доработаем репозиторий
/// <summary> /// Доработанный репозиторий работающий с обертками над Ignite /// </summary> public class V2DemoRepository { private readonly IDemoIgniteWrapper _igniteWrapper; public V2DemoRepository(IDemoIgniteWrapper igniteWrapper) { _igniteWrapper = igniteWrapper; } public IList<DemoModel> Get( DemoFilter filter ) { // Получаем существующий кэш с указанным именем или создаем новый, используя конфигурацию шаблона. var cache = _igniteWrapper.GetOrCreateCache<int, DemoModel>(nameof(DemoModel)); var cacheData = cache // Получаем данные из кэша. // Результирующий запрос будет преобразован в запрос SQL кэша. .AsQueryable() .Where( item => // Фильтруем данные по Id. filter.Ids == null || filter.Ids.Contains(item.Value.Id) ) // Сортируем. .OrderBy(item => item.Key) // Используем пейджинг: Skip - сколько данных пропустить, Take - количество получаемых данных. .Skip(filter.PageSize * filter.PageIndex) .Take(filter.PageSize); return cacheData .Select(item => item.Value) .ToList(); } } /// <summary> /// Обертка над Ignite реализация. /// </summary> public class DemoIgniteWrapper : IDemoIgniteWrapper { private IIgniteClient _igniteClient; public DemoIgniteWrapper(IIgniteClient igniteClient) { _igniteClient = igniteClient; } public IDemoCacheWrapper<TK, TV> GetOrCreateCache<TK, TV>( string name ) { var cache = _igniteClient.GetOrCreateCache<TK, TV>(name); return new DemoCacheWrapper<TK, TV>(cache); } } /// <summary> /// Обертка кэша Ignite реализация /// </summary> public class DemoCacheWrapper<TK, TV> : IDemoCacheWrapper<TK, TV> { private readonly ICacheClient<TK, TV> _cacheClient; public DemoCacheWrapper( ICacheClient<TK, TV> cacheClient ) { _cacheClient = cacheClient ?? throw new ArgumentNullException(nameof(cacheClient)); } /// <summary> /// Получить доступ к кэшу через IQueryable. /// </summary> public IQueryable<ICacheEntry<TK, TV>> AsQueryable() { return _cacheClient.AsCacheQueryable(); }
Теперь осталось доработать тест
public V2DemoRepositoryTests() { _fixture = new Fixture(); _igniteWrapper = new Mock<IDemoIgniteWrapper>(); _repository = new V2DemoRepository( _igniteWrapper.Object ); _cachClient = new Mock<IDemoCacheWrapper<int, DemoModel>>(); } [Fact] public void When_Get_Then_success_test() { // Arrange var demoModel1 = _fixture.Create<DemoModel>(); var demoModel2 = _fixture.Create<DemoModel>(); var demoModel3 = _fixture.Create<DemoModel>(); var cacheList = new List<ICacheEntry<int, DemoModel>> { new CacheEntry<int, DemoModel>(demoModel1.Id, demoModel1), new CacheEntry<int, DemoModel>(demoModel2.Id, demoModel2), new CacheEntry<int, DemoModel>(demoModel3.Id, demoModel3), }; var filter = new DemoFilter( ids: new[] { demoModel1.Id }, pageIndex: 0, pageSize: 2 ); // Мокаем создание кэша. _igniteWrapper .Setup( item => item.GetOrCreateCache<int, DemoModel>( It.IsAny<string>() ) ) .Returns(_cachClient.Object); // Мокаем получение данных кэша. _cachClient .Setup( item => item.AsQueryable() ) .Returns(cacheList.AsQueryable()); // Act var result = _repository.Get( filter ); // Assert // Проверим, что найден только один нужный нам элемент. Assert.Single(result); // Проверим, что нужный нам элемент находится в ответе. Assert.Contains(result, model => model.Id == demoModel1.Id); // Проверим, что элементы, которые не соответствуют условию, в ответе не содержаться Assert.DoesNotContain(result, model => model.Id == demoModel2.Id || model.Id == demoModel3.Id); } }
Заключение
Таким образом, мы смогли протестировать логику, которая была зависима от статических методов. Если вы в своей работе сталкивались с подобными задачами, поделитесь в комментариях своими решениями. Буду рад узнать новое или ответить на ваши вопросы!
ссылка на оригинал статьи https://habr.com/ru/articles/894502/
Добавить комментарий