Pull to refresh

Тестирование с базой данных в .NET

Reading time 7 min
Views 23K

Обычным подходом в .NET к тестированию приложений работающих с базой данных является внедрение зависимостей (Dependency Injection). Предлагается отделить код работающий с базой, от основной логики путем создания абстракции, которую в дальнейшем можно подменить в тестах. Это очень мощный и гибкий подход, который тем не менее имеет некоторые недостатки — увеличение сложности, разделение логики, взрывной рост количества типов. Подробнее в предыдущей статье Что-то не то с тестированием в .NET (Java и т.д.) или в Wiki/Dependency Injection.


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



Пример


Как показала предыдущая статья — пример очень важен. Если он неудачный, то критикуется сам пример, а не подход. Здесь я уделил ему больше внимания, но он конечно тоже не идеален:

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

Для этого введем следующий метод (его и нужно будет протестировать):

public class ReminesService 
{
    RemineItem[] GetReminesFor(Storage storage, DateTime time) { ... }
}

В статье не будет реализации этого метода, но он есть в репозитории на гитхабе.

Тестовая база данных


Нам понадобится база данных для тестирования. Для простых проектов можно использовать SQLite, это неплохой компромисс между скоростью тестов и их надежностью. Для более сложных случаев лучше использовать такую же БД, что и при разработке. В большинстве случаев это не проблема — MySql и PostgreSql легковесные, для SQLServer есть режим LocalDb.

Если вы работаете с SQLServer, удобно воспользоваться LocalDb режимом для тестовой базы — он намного легче и быстрее полной базы, при этом полностью функционален. Для этого нужно сконфигурировать App.config в тестовом проекте:

Конфигурация для SQLServer LocalDb
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
      <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
    </configSections>
    <entityFramework>
      <defaultConnectionFactory type="System.Data.Entity.Infrastructure.LocalDbConnectionFactory, EntityFramework">
        <parameters>
          <parameter value="MSSQLLocalDB" />
        </parameters>
        </defaultConnectionFactory>
      <providers>
        <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
      </providers>
    </entityFramework>
</configuration>


Фреймворк


Так как данный подход очень мало распространен в .NET — почти нет никаких готовых библиотек для его реализации. Поэтому я оформил наработки в этой области в небольшую библиотеку DbTest. Вы можете посмотреть исходники и примеры на гитхаб или установить в проект через nuget. Проект в предварительной версии и может меняться API — так что будьте осторожны.

Начальные данные


В реальной системе много отношений между моделями, чтобы вставить хотя бы одну строку в целевую таблицу необходимо заполнить множество связанных таблиц. Например, товар (Good) может ссылаться на производителя (Manufacturer), который в свою очередь ссылается на страну (Country).

Чтобы упростить дальнейшее создание тестовых сценариев, необходимо создать минимальный набор общих для системы данных.

Чтобы было немного веселее, давайте в качестве товаров возьмем бутылки с виски. Начнем с модели, у которой нет зависимостей — страна производителя (Country):

public class Countries : IModelFixture<Country>
{
    public string TableName => "Countries";

    public static Country Scotland => new Country
    {
        Id = 1,
        Name = "Scotland",
        IsDeleted = false
    };

    public static Country USA => new Country
    {
        Id = 2,
        Name = "USA",
        IsDeleted = false
    };
}

Чтобы фреймворк понял, что это описание начальных данных, класс должен реализовывать интерфейс IModelFixture<T>. Экземпляры моделей объявляются статическими, чтобы обеспечить к ним доступ из других фикстур и тестов. Вы должны явно указывать первичные ключи (Id) и следить за их уникальностью в рамках одной модели.

Теперь можно создавать производителей:

class Manufacturers : IModelFixture<Manufacturer>
{
    public string TableName => "Manufacturers";

    public static Manufacturer BrownForman => new Manufacturer
    {
        Id = 1,
        Name = "Brown-Forman",
        CountryId = Countries.USA.Id,
        IsDeleted = false
    };

    public static Manufacturer TheEdringtonGroup => new Manufacturer
    {
        Id = 2,
        Name = "The Edrington Group",
        CountryId = Countries.Scotland.Id,
        IsDeleted = false
    };
}

И товары:

public class Goods : IModelFixture<Good>
{
    public string TableName => "Goods";

    public static Good JackDaniels => new Good
    {
        Id = 1,
        Name = "Jack Daniels, 0.5l",
        ManufacturerId = Manufacturers.BrownForman.Id,
        IsDeleted = false
    };

    public static Good FamousGrouseFinest => new Good
    {
        Id = 2,
        Name = "The Famous Grouse Finest, 0.5l",
        ManufacturerId = Manufacturers.TheEdringtonGroup.Id,
        IsDeleted = false
    };
}

Обратите внимание на внешние ключи — они не указываются явно, а ссылаются на другую фикстуру.

Такой подход имеет множество преимуществ перед sql-файлами или json файлами фикстур:

  • при создании начальных данных студия подсказывает какие поля есть в классе, контролирует их типы
  • легко связать модели между собой, таким же образом можно использовать начальные данные в самих тестах
  • при развитии системы и изменениях в моделях — компилятор проверит явные ошибки в типах в фикстурах

Важно! У этого подхода есть недостаток — при каждом обращении к статическому свойству создается экземпляр модели и всех зависимых от него моделей (и их зависимостей тоже). Если возникают проблемы с производительностью или циклическими ссылками, то можно исправить это с помощью ленивой инициализации Lazy<T>.

private static Good _famousGrouseFinest = new Lazy<Good>(() => new Good
{
    Id = 2,
    Name = "The Famous Grouse Finest, 0.5l",
    ManufacturerId = Manufacturers.TheEdringtonGroup.Id,
    IsDeleted = false
};
public static Good FamousGrouseFinest => _famousGrouseFinest.Value;

Подготовка окружения


Тестовое окружение в первую очередь это база данных, также это могут быть синглтоны и статические переменные (например, в asp.net можно установить HttpContext). Лучше собрать все эти операции в одном месте и запускать перед каждым тестом. Мы назвали у себя такое место — World. Чтобы подготовить базу данных — нужно вызвать метод ResetWithFixtures и передать туда список начальных фикстур.

static class World
{
    public static void InitDatabase()
    {
        using (var context = new MyContext())
        {
            var dbTest = new EFTestDatabase<MyContext>(context);

            dbTest.ResetWithFixtures(
                new Countries(),
                new Manufacturers(),
                new Goods()
            );
        }
    }

    public static void InitContextWithUser()
    {
        HttpContext.Current = new HttpContext(
            new HttpRequest("", "http://your-domain.com", ""),
            new HttpResponse(new StringWriter())
        );
        HttpContext.Current.User = new GenericPrincipal(
            new GenericIdentity("root"),
            new string[0]
            );
    }
}

Возможность задать статические переменные и синглтоны особенно важна при тестировании legacy кода, где не так-то просто поменять архитектуру — но есть острая необходимость в тестировании. Разделение настройку окружения на несколько методов позволяет подготавливать окружение индивидуального для каждого теста. Например, в unit тестах не используется база и нет смысла очищать для них базу. Или у вас может быть необходимость подготовить различное окружение для разных состояний системы (авторизованный и неавторизованный пользователь).

Создание тестового сценария


В тестах приходится делать много подготовительной работы, Arrange фаза теста самая ответственная и сложная. Поэтому желательно создавать хелперы, которые упростят этот процесс, сделают код более простым для чтения. Одним из удобных механизмов, может быть создание ModelBuilder, который создает сущности, сохраняет их в БД и возвращает экземпляры для дальнейшего использования:

public class ModelBuilder
{
    public MoveDocument CreateDocument(string time, Storage source, Storage dest)
    {
        var document = new MoveDocument
        {
            Number = "#",

            SourceStorageId = source.Id,
            DestStorageId = dest.Id,

            Time = ParseTime(time),
            IsDeleted = false
        };

        using (var db = new MyContext())
        {
            db.MoveDocuments.Add(document);
            db.SaveChanges();
        }

        return document;
    }

    public MoveDocumentItem AddGood(MoveDocument document, Good good, decimal count)
    {
        var item = new MoveDocumentItem
        {
            MoveDocumentId = document.Id,
            GoodId = good.Id,
            Count = count
        };

        using (var db = new MyContext())
        {
            db.MoveDocumentItems.Add(item);
            db.SaveChanges();
        }

        return item;
    }
}

Тестируем


Пришло время собрать все вместе и посмотреть что получилось:

[SetUp]
public void SetUp()
{
    World.InitDatabase(); // подготавливаем базу к каждому тесту
}

[Test]
public void CalculateRemainsForMoveDocuments()
{
    /// ARRANGE - создаем тестовую ситуацию
    var builder = new ModelBuilder();           

    // Приход товаров на удаленный склад
    var doc1 = builder.CreateDocument("15.01.2016 10:00:00", Storages.MainStorage, Storages.RemoteStorage);
    builder.AddGood(doc1, Goods.JackDaniels, 10);
    builder.AddGood(doc1, Goods.FamousGrouseFinest, 15);
           
    // Расход товаров с удаленного склада
    var doc2 = builder.CreateDocument("16.01.2016 20:00:00", Storages.RemoteStorage, Storages.MainStorage);
    builder.AddGood(doc2, Goods.FamousGrouseFinest, 7);

    /// ACT - вызываем тестируемую функцию
    var remains = RemainsService.GetRemainFor(Storages.RemoteStorage, new DateTime(2016, 02, 01));

    /// ASSERT - проверяем результат
    Assert.AreEqual(2,  remains.Count);
    Assert.AreEqual(10, remains.Single(x => x.GoodId == Goods.JackDaniels.Id).Count);
    Assert.AreEqual(8,  remains.Single(x => x.GoodId == Goods.FamousGrouseFinest.Id).Count);
}

Обратите внимание на использование начальных фикстур в коде теста
Storages.MainStorage, Goods.JackDaniels, Goods.FamousGrouseFinest и т.д.

Очень удобно, что под рукой есть все объекты, которые уже есть в базе данных и их можно использовать в любой фазе теста.

Резюме


Данный подход незаслуженно обходится стороной в мире строго-типизированных языков, при этом он очень широко распространен в динамических языках. Это не серебренная пуля и не замена для DI, но это очень удобный и уместный во многих случаях подход.

По сравнению с DI, тестирование с настоящей базой имеет следующие преимущества:

  • Меньшее влияние тестов на архитектуру
  • Меньше слоев абстракции — меньше сложность и упрощается чтение кода
  • Больше доверия к тестам, которые на самом деле читают и вставляют данные в базу
  • Быстрее в написании и проще в поддержке

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

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

Полезные ссылки


DbTest (репозиторий с тестовым фреймворком и примерами из статьи)
Smocks (мок для статических системных методов)
Tags:
Hubs:
+15
Comments 92
Comments Comments 92

Articles