C#: использование Unit test с Apache Ignite

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