Статьи

Интеграционное тестирование с Neo4j с использованием C #

В отличие от прототипного модульного тестирования, которое предназначено для работы с небольшими единицами изолированного кода, интеграционное тестирование — это тип тестирования, который обычно предназначен для тестирования взаимодействия между двумя или более взаимосвязанными частями программной системы.

Распространенной областью, где интеграционное тестирование дает высокую отдачу от усилий, является точка взаимодействия между приложением и базой данных базы данных. Этот тип интеграционного тестирования позволяет проверить ожидаемое поведение запросов, отправляемых в базу данных, а также последующее преобразование этого набора данных в модели предметной области или другие структуры данных в коде.

Хотя проекты NoSQL C # становятся все более распространенными, большинство корпоративных приложений все еще моделируют данные, используя традиционные базы данных SQL. Поэтому может быть сложно найти хорошие примеры и рекомендации для построения интеграционных тестов 1  с использованием решений NoSQL. 

Однако, независимо от того, используете ли вы базы данных SQL или  NoSQL , написание повторяемых интеграционных тестов почти всегда зависит от использования транзакций базы данных, поскольку это позволяет обрабатывать данные, созданные во время интеграционного тестирования, как временные данные, которые автоматически очищаются по завершении тестового примера. ,

Конечно, наличие транзакционных возможностей является обязательным практически во всех приложениях, но особенно полезно для разработчиков при создании интеграционных тестов.

Neo4j и Neo4jClient

Начиная с версии 2.0,  Neo4j  представил конечную точку транзакции HTTP, которая позволяла привязкам к языку программирования не на основе Java использовать преимущества полностью баз данных транзакционного графа. 

Для среды C # обычно есть две основные привязки, которые может использовать разработчик:  Neo4jClient и  Cypher.NET . Последний был построен с учетом транзакций; однако, исходя из своего личного вкуса, я предпочитаю плавную способность Neo4jClient писать   запросы Cypher

Кроме того, когда мне нужно было решить, какую библиотеку использовать в масштабном проекте моей компании в начале 2014 года, Neo4jClient оказался более активным проектом из этих двух, но для этой библиотеки не было реализации транзакций.

У меня было два варианта: либо выбрать Cypher.NET, либо воспользоваться моделью открытого исходного кода Neo4jClient и самостоятельно реализовать возможности транзакций. Я выбрал последнее, что помогло моей команде и мне легко писать и понимать запросы Cypher, и в то же время писать их со знанием того, что мы могли бы написать интеграционные тесты для наших модулей и легко регрессивно тестировать любые изменения, внесенные в запросы или обработку данных. , 

Начиная с Neo4jClient 1.1.0.x, вы можете использовать реализацию транзакций для своих собственных проектов, будь то для тестирования или для моделирования вашего решения 2 .

Реализация транзакции Neo4jClient представляет новые интерфейсы, которые отражают транзакционный API. Это следующие 3 :

public interface ITransactionGraphClient : IGraphClient
{
    ITransaction BeginTransaction();
    ITransaction BeginTransaction(TransactionScopeOption scopeOption);
    void EndTransaction();
}

public enum TransactionScopeOption
{
    Join, // default value
    RequiresNew,
    Suppress
}

public interface ITransaction : IDisposable
{
    void Commit();
    void Rollback();
}

Запуск и управление транзакциями очень прост и использует многие из тех же соглашений в типичных транзакциях клиента SQL:

public void ExecuteCypher()
{
    ITransactionalGraphClient graphClient = new GraphClient(new Uri("http://path/to/your/db/data"));

    graphClient.Connect();

    using (ITransaction transaction = graphClient.BeginTransaction())
    {
        // create two nodes on different queries
        graphClient.Cypher.Create("(n:Node {id: 1})").ExecuteWithoutResults();

        graphClient.Cypher.Create("(n:Node {id: 2})").ExecuteWithoutResults();

        // commit the created nodes
        transaction.Commit();
    }
}

Вместо того, чтобы создавать узлы сразу, механизм транзакций Neo4j теперь ждет, пока не будет выполнен коммит, чтобы сделать такие данные доступными для всех читателей. Помимо этого аспекта, в этом коде есть две детали, которые мы рассмотрим.

Первый из них  ITransaction реализует IDisposableто есть, когда поток кода выходит из блока using, транзакция должна быть зафиксирована, в противном случае она будет автоматически откатываться (тот же шаблон, с которым вы столкнулись бы для типичных транзакций клиента SQL). 

Например:

public void RolledbackTransaction()
{
    ITransactionalGraphClient graphClient = new GraphClient(new Uri("http://path/to/your/db/data"));

    graphClient.Connect();

    using (ITransaction transaction = graphClient.BeginTransaction())
    {
        // create two nodes on different queries
        graphClient.Cypher.Create("(n:Node {id: 1})").ExecuteWithoutResults();

        graphClient.Cypher.Create("(n:Node {id: 2})").ExecuteWithoutResults();

        // because the code does not commit before exiting the using block, it is the same as explicitly calling Rollback() on the transaction object, and the two nodes will not be created.
        // transaction.Rollback(); // behavior is the same as not calling it
    }
}

Вторая деталь заключается в том, что graphClientобъект знает, что при вызове ExecuteWithoutResults()он находится внутри транзакции. Это достигается с помощью того же механизма, который используется Microsoft, System.Transactionsто есть внешней транзакции или объекта транзакции, хранящегося на уровне потока (используя a ThreadStaticAttribute). Это означает, что, хотя явного или неявного вызова ITransactionalGraphClient.EndTransaction()или нет ITransaction.Close(), все запросы Cypher, выполняемые graphClientв одном потоке, будут частью одной и той же транзакции.

Существует три способа запуска «блока транзакций» с помощью BeginTransaction(TransactionScopeOption):

    • TransactionScopeOption.Join: Это значение по умолчанию при использовании BeginTransaction (). Он инструктирует клиента присоединиться к существующей внешней транзакции, если она есть, в противном случае начинает новую транзакцию.
    • TransactionScopeOption.RequiresNew: Всегда запускает новую транзакцию, даже если уже есть другая внешняя транзакция.
    • TransactionScopeOption.SuppressУказывает, что следующие запросы не будут частью внешней транзакции (если она есть).

Важно отметить, что хотя TransactionScopeOption.Joinклиент и инструктирует присоединиться к внешней транзакции, он все равно должен рассматриваться как блок транзакции, то есть должен быть вызов Commit()до явного или неявного вызова ITransaction.Close().

Например:

public void NestedTransactions()
{
    ITransactionalGraphClient graphClient = new GraphClient(new Uri("http://path/to/your/db/data"));

    graphClient.Connect();

    using (ITransaction transaction = graphClient.BeginTransaction())
    {
        // create two nodes on different queries
        graphClient.Cypher.Create("(n:Node {id: 1})").ExecuteWithoutResults();

        using (ITransaction nestedTransaction = graphClient.BeginTransaction()) // by default joins the ambient transaction
        {
            graphClient.Cypher.Create("(n:Node {id: 2})").ExecuteWithoutResults();

            // if there is no call to commit, then when exiting this block and calling nestedTransaction.Close() the ambient transaction will be marked as "failed" and will be rollbacked,
            // even when the parent transaction does call Commit()
            nestedTransaction.Commit();
        }

        // commit the created nodes
        transaction.Commit();
    }
}

Кроме того, когда происходит вызов nestedTransaction.Commit(), нет фактической фиксации к Neo4j, а только помечает вложенный блок транзакции как «успешный». Коммит происходит по вызову родителя Commit(). Благодаря использованию ранее описанного механизма написание интеграционных тестов является чистым и не мешает ранее написанному коду, даже если ваша программа использует транзакции (если она не использует TransactionScopeOption.RequiresNew).

Давайте предположим, что у вас есть Entity; и EntityRepositoryклассы:

[DataContract]
public class Entity
{
    [DataMember]
    public Guid Id { get; set; }

    [DataMember]
    public string Name { get; set; }
}

public class EntityRepository
{
    private ITransactionalGraphClient _graphClient;

    public EntityRepository(ITransactionalGraphClient graphClient)
    {
        _graphClient = graphClient;
    }

    private string GetLabel()
    {
        return typeof (Entity).Name;
    }

    public Entity Add(Entity entity)
    {
        if (entity.Id == Guid.Empty)
        {
            entity.Id = Guid.NewGuid();
        }

        using (ITransaction transaction = _graphClient.BeginTransaction())
        {
            Entity createdEntity = _graphClient.Cypher
                .Create(string.Format("(e:{0} {{entity}})", GetLabel()))
                .WithParam("entity", entity)
                .Return(e => e.As())
                .Results
                .SingleOrDefault();

            transaction.Commit();

            return createdEntity;
        }
    }
}

Предыдущий код позволяет EntityRepositoryэкземпляру сохранять Entityобъект в Neo4j с помощью транзакций 4 . Теперь представьте, что вы хотите протестировать Add()метод и убедиться, что объект успешно сохранен в Neo4j. Один из способов сделать это — выполнить следующий тест:

[Test]
public void StoreEntity()
{
    // create an entity
    Guid entityId = Guid.NewGuid();
    Entity entity = new Entity
    {
        Id = entityId,
        Name = "Test"
    };

    // save it
    EntityRepository repository = new EntityRepository(new GraphClient(new Uri("http://path/to/your/db/data")));
    Entity createdEntity = repository.Add(entity);
    Assert.IsNotNull(createdEntity);
    Assert.AreEqual("Test", createdEntity.Name);
    Assert.AreEqual(entityId, createdEntity.Id);
}

Это нормально, но после многократного запуска вы получите график, полный тестовых узлов. Есть ли способ не создавать узлы и по-прежнему использовать Neo4j для интеграционных тестов? Вот где транзакции пригодятся для тестирования:

private EntityRepository _repository;
private ITransactionalGraphClient _graphClient;

[SetUp]
public void SetupTransactionContext()
{
    _graphClient.BeginTransaction();
}

[TearDown]
public void EndTransactionContext()
{
    // end the transaction as failure
    _graphClient.EndTransaction();
}

[TestFixtureSetUp]
public void SetupTests()
{
    _graphClient = new GraphClient(new Uri("http://path/to/your/db/data"));
    _graphClient.Connect();

    _repository = new EntityRepository(_graphClient);
}

Используя репозиторий уровня фикстуры, мы можем убедиться, что используемый GraphClientэкземпляр является тем, который удерживает транзакцию открытой, и используя методы SetUp и TearDown, мы можем запускать и откатывать транзакции.

Обратите внимание, что транзакция, используемая внутри Add()метода, по умолчанию присоединится к внешней транзакции, уже созданной внутри, SetupTransactionContext()и, хотя она имеет вызов для фиксации, «родительская» транзакция этого не делает. Поэтому любые созданные или измененные данные будут откатываться, и база данных возвращается в исходное состояние.

Реализация транзакционного клиента была выполнена с System.Transactionsучетом интеграции с Microsoft . Это позволяет системам комбинировать транзакции SQL и транзакции Neo4j и даже использовать их вместе для тестирования:

[SetUp]
public void SetupTransactionContext()
{
    _graphClient.BeginTransaction();
}

[TearDown]
public void EndTransactionContext()
{
    // end the transaction as failure
    _graphClient.EndTransaction();
}

[TestFixtureSetUp]
public void SetupTests()
{
    _graphClient = new GraphClient(new Uri("http://path/to/your/db/data"));
    _graphClient.Connect();

    _repository = new EntityRepository(_graphClient);
}

[Test]
public void CombinedTransaction()
{
    // start a System.Transactions scope
    using (TransactionScope scope = new TransactionScope())
    {
        // neo4j related code
        Guid entityId = Guid.NewGuid();
        Entity entity = new Entity
        {
            Id = entityId,
            Name = "Test"
        };

        Entity createdEntity = _repository.Add(entity);

        // write your SQL code here

        // do not call scope.Complete()
    }
}

Недостатки

Не все идеально, и я нашел некоторые проблемы, которые могут быть более раздражающими, чем showtoppers:

    • Вам нужен «глобальный»  GraphClient экземпляр. Обычно это неплохая идея (так как  Connect()вызов довольно дорогой), и его легко можно обработать  Dependency Injection, однако, если ваш код создает экземпляр этой сущности во всем вашем проекте, то использование методов, описанных в этой статье, может повлечь за собой изменение вашего кодовая.
    • Когда вы выполняете откат созданных узлов или отношений, идентификаторы не перерабатываются (или, по крайней мере, некоторое время), это означает, что файлы, используемые Neo4j для отслеживания используемых идентификаторов, продолжают расти, хотя они полны неиспользуемых идентификаторов, и через некоторое время (в масштабе  миллионов  запусков) производительность вашей графической базы данных снижается, и, следовательно, выполнение тестов займет больше времени. Решение действительно простое: используйте специальную базу данных для тестирования, которую вы можете восстановить (или удалить и воссоздать) до предыдущей точки, когда производительность становится действительно утомительной.

Выводы

Благодаря всей большой работе Тотама Одди и его команды в Readify интеграция транзакций в библиотеку Neo4jClient теперь позволяет разработчикам по-прежнему использовать простоту свободного API для Cypher, а также использовать новые возможности Neo4j в. NET сообщество. Вы прочитали один пример в этой статье, предоставляя возможности интеграционных тестов; но это также убеждает компании, которые могут инвестировать в продукт, использующий Neo4j, обращая внимание на поддержку, исходящую от сообщества открытого исходного кода.

Найдите код, используемый для примеров интеграционных тестов  здесь .

Ссылки

1  В отличие от модульных тестов, интеграционные тесты обычно включают механизм хранения, используемый в вашей программе или в вашей модели.

2  Самая последняя версия в NuGet уже включает реализацию транзакции.

3  В объявлениях интерфейсов есть другие методы и свойства. Однако для простоты я показываю только те, которые я объясняю в этой статье.

4  Транзакция используется здесь для иллюстрации использования, хотя очевидно, что это не требуется для такого простого сценария.

Первоначально написано  Артуро Севилья , старшим инженером-программистом InnovoCommerce.com, и опубликовано в блоге Neo4j.