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

e12b087bc3214467edfec6ac1eac190b.png

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

Заключение

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

© Habrahabr.ru