
Итак, повторю, работающий тест – это минимальный критерий работающего кода. Мы в некоторых сервисах в качестве кэша используем 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);
}
}
Заключение
Таким образом, мы смогли протестировать логику, которая была зависима от статических методов. Если вы в своей работе сталкивались с подобными задачами, поделитесь в комментариях своими решениями. Буду рад узнать новое или ответить на ваши вопросы!