Как стать автором
Обновить
94.49

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

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров844

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

Заключение

Таким образом, мы смогли протестировать логику, которая была зависима от статических методов. Если вы в своей работе сталкивались с подобными задачами, поделитесь в комментариях своими решениями. Буду рад узнать новое или ответить на ваши вопросы!

Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+8
Комментарии9

Публикации

Информация

Сайт
t2.ru
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия