Пример тестирования EF Core

Совет

код в этом документе можно найти на GitHub как свебапи/"data-код =" external ">готов к запуску . Обратите внимание, что некоторые из этих тестов должны завершаться ошибкой. Причины этого описаны ниже.

В этом документе рассматривается пример для тестирования кода, который использует EF Core.

Приложение

Образец >"Data-свебапи =" External " содержит два проекта:

  • Itemсвебапи: очень простой Item с одним контроллером
  • Тесты: тестовый проект xUnit для тестирования контроллера

Модель и бизнес-правила

Модель, которая включает этот API, имеет два типа сущностей: Item s и Tag s.

  • Itemу s есть имя с учетом регистра и коллекция Tag s.
  • Каждый Tag имеет метку и число, представляющее количество применений к Item .
  • Каждый Item должен иметь только один Tag с заданной меткой.
    • Если элемент помечен одной и той же меткой более одного раза, то число в существующем теге с этой меткой увеличивается вместо создания нового тега.
  • При удалении удаляется Item все связанные Tag с ним.

ItemТип сущности

ItemТип сущности:

Элемент  EntityType ">открытый класс Item {Private ReadOnly int _id; частный список только для чтения <Tag> _ tags = новый список <Tag> (); частный Item (идентификатор int, строковое имя) {_id = ID; Имя = имя; } Public Item (имя строки) {имя = имя;} открытый Tag Add Tag (метка строки) {var Tag = _ tags . FirstOrDefault (t = > t. Label = = метка); если (Tag = = null) {тег = New Tag (метка); _ tags . Добавить (тег); тегами. Число + +; возвращаемый тег; } общедоступная строка с именем {Get;} Public иреадонлилист <Tag>Tag s = > _ tags ;}

И его конфигурация в DbContext.OnModelCreating :

Элемент  ">modelBuilder. Entity <Item> (b = > {b. свойство (" _id "); b. HasKey (" _id "); b. Property (e = > e.Name); b. хасмани (e = > e. Tag с). Висоне (). Обязательный (); });

Обратите внимание, что тип сущности ограничивается способом, который можно использовать для отражения модели предметной области и бизнес-правил. В частности:

  • Первичный ключ сопоставляется непосредственно с _id полем и не предоставляется публично
    • EF обнаруживает и использует закрытый конструктор, принимающий значение и имя первичного ключа.
  • NameСвойство доступно только для чтения и задается только в конструкторе.
  • TagIReadOnlyList<Tag>для предотвращения произвольного изменения предоставляется как объект.
    • EF связывает Tags свойство с _tags резервным полем, сопоставляя их имена.
    • AddTagМетод принимает метку тега и реализует бизнес-правило, описанное выше. То есть, тег добавляется только для новых меток. В противном случае значение счетчика в существующей метке увеличивается.
  • TagsСвойство навигации настроено для связи "многие к одному"
    • Нет необходимости в свойстве навигации из TagItem , поэтому оно не включено.
    • Кроме того, не Tag определяет свойство внешнего ключа. Вместо этого EF будет создавать свойство в теневом состоянии и управлять им.

TagТип сущности

TagТип сущности:

Tag  EntityType ">открытый класс Tag {Private readonly int _id; Private Tag (идентификатор int, строка метки) {_id = ID; Метка = Метка; } Public Tag (метка строки) = > Метка = метка; Метка общедоступной строки {Get;} число открытых целочисленных значений {Get; Set;}} 

И его конфигурация в DbContext.OnModelCreating :

Тег  ">modelBuilder. Entity <Tag> (b = > {b. свойство (" _id "); b. HasKey (" _id "); b. свойство (e = > e. Label);});

Аналогично ItemTag скрывает его первичный ключ и делает Label свойство доступным только для чтения.

Item сконтроллер

Контроллер веб-API довольно прост. Он получает DbContext из контейнера внедрения зависимостей посредством внедрения конструктора:

private readonly ItemsContext _context;

public ItemsController(ItemsContext context)
    => _context = context;

Он имеет методы для получения всех Item s или Item с заданным именем:

[HttpGet]
public IEnumerable<Item> Get()
    => _context.Set<Item>().Include(e => e.Tags).OrderBy(e => e.Name);

[HttpGet]
public Item Get(string itemName)
    => _context.Set<Item>().Include(e => e.Tags).FirstOrDefault(e => e.Name == itemName);

В нем имеется метод для добавления нового Item :

Item  "> [HttpPost] Public ActionResult <Item> POST Item (строка ItemName) {var Item = _context. Добавить (New Item (ItemName)). Объекта _context. SaveChanges (); возвращаемый элемент; }

Метод для добавления метки к тегу Item с меткой:

Тег  "> [HttpPost] Public ActionResult <Tag> POST Tag (строка ItemName, строка таглабел) {var Tag = _context. Set <Item> (). Include (e = > e). Tag с). Single (e = > e.Name = = ItemName). Добавьте Tag (таглабел); _context. SaveChanges (); возвращаемый тег; }

И метод для удаления объекта Item и всех связанных с ним объектов Tag :

Item  "> [хттпделете (" {ItemName} ")] открытый ActionResult <Item> DELETE Item (строка ItemName) {var Item = _context. Set <Item> (). SingleOrDefault (e = > e.Name = = ItemName); если (элемент = = NULL) {return NotFound ();} _context. Удалить (элемент); _context. SaveChanges (); возвращаемый элемент; }

Большинство проверок и обработка ошибок были удалены для уменьшения перегруженности.

Тесты

Тесты организованы для выполнения с несколькими конфигурациями поставщика базы данных:

  • поставщик SQL Server, который является поставщиком, используемым приложением.
  • Поставщик SQLite
  • Поставщик SQLite, использующий базы данных SQLite в памяти
  • Поставщик базы данных EF в памяти

Это достигается путем размещения всех тестов в базовом классе и последующего наследования от него для тестирования с каждым поставщиком.

Совет

если LocalDB не используется, необходимо изменить строку подключения SQL Server. Рекомендации по использованию SQLite для тестирования в памяти см. в разделе тестирование с помощью Sqlite .

Ожидается, что следующие два теста завершаются ошибкой:

  • Can_remove_item_and_all_associated_tags При работе с поставщиком базы данных EF в памяти
  • Can_add_item_differing_only_by_caseпри работе с поставщиком SQL Server

Более подробно эти сведения рассматриваются ниже.

Настройка и заполнение базы данных

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

Совет

Этот пример повторно создает базу данных для каждого теста. Это хорошо подходит для тестирования SQLite и EF в памяти, но может включать значительные издержки, связанные с другими системами баз данных, включая SQL Server. Подходы к сокращению этих затрат рассматриваются в разделе совместное использование баз данных в разных тестах.

При выполнении каждого теста:

  • DbContextOptions настроены для используемого поставщика и передаются конструктору базового класса
    • Эти параметры хранятся в свойстве и используются во всех тестах для создания экземпляров DbContext.
  • Для создания и заполнения базы данных вызывается метод SEED
    • Метод SEED гарантирует, что база данных будет очищена, удалив ее, а затем повторно создав ее.
    • Некоторые хорошо известные тестовые сущности создаются и сохраняются в базе данных.
protected ItemsControllerTest(DbContextOptions<ItemsContext> contextOptions)
{
    ContextOptions = contextOptions;

    Seed();
}

protected DbContextOptions<ItemsContext> ContextOptions { get; }

private void Seed()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        var one = new Item("ItemOne");
        one.AddTag("Tag11");
        one.AddTag("Tag12");
        one.AddTag("Tag13");

        var two = new Item("ItemTwo");

        var three = new Item("ItemThree");
        three.AddTag("Tag31");
        three.AddTag("Tag31");
        three.AddTag("Tag31");
        three.AddTag("Tag32");
        three.AddTag("Tag32");

        context.AddRange(one, two, three);

        context.SaveChanges();
    }
}

Затем каждый конкретный тестовый класс наследуется от этого. Пример:

Item  сконтроллертест ">открытый класс SQLite Item сконтроллертест: Item Сконтроллертест {Public SQLite Item сконтроллертест (): Base (New дбконтекстоптионсбуилдер <Item sContext > (). Усесклите ("имя_файла = Test. DB"). Параметры) {}}

Структура теста

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

Затем каждый тест выполняет тестируемый метод на контроллере и утверждает результаты ожидаемым образом. Пример:

 s "> [факт] Public void Can_get_ items () {using (var context = New Item сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var items = Controller. Get (). ToList (); Assert. Equals (3, items . Count); Assert. Equals (" Item One", items [0]. Имя); Assert. Equals (" Item три", items [1]. Имя); Assert. Equals (" Item два", items [2]. Имя); } } 

Обратите внимание, что для заполнения базы данных и выполнения тестов используются различные экземпляры DbContext. Это гарантирует, что тест не использует (или не переходит) сущности, которые отслеживает контекст при заполнении. Он также лучше соответствует тому, что происходит в веб-приложениях и службах.

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

Item  "> [факт] Public void Can_add_item () {using (var context = New Item Сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var Item = Controller. Post Item (" Item четыре"). Значений Assert. Equals (" Item четыре", элемент. Имя); } с помощью (var context = New Item сконтекст (контекстоптионс)) {var Item = context. Set <Item> (). Single (e = > e.Name = = " Item четыре"); Assert. Equals (" Item четыре", элемент. Имя); Assert. Equals (0, Item. Tag s. Count); } }

Два еще более важных тестирования охватывают бизнес-логику, связанную с добавлением tags .

Tag  "> [факт] открытый void Can_add_tag () {using (var context = New Item Сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var Tag = Controller. Post Tag (" Item два", " Tag 21"). Значений Assert. Equals (" Tag 21", тег. Метка); Assert. Equals (1, Tag. Count); } с помощью (var context = New Item сконтекст (контекстоптионс)) {var Item = context. Set <Item> (). Include (e = > e). Tag с). Single (e = > e.Name = = " Item два"); Assert. Equals (1, Item. Tag s. Count); Assert. Equals (" Tag 21", элемент. Tag s [0]. Метка); Assert. Equals (1, Item. Tag s [0]. Count); } }
Количество тегов "> [факт] открытый void Can_add_tag_when_already_existing_tag () {using (var context = New Item Сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var Tag = Controller. Post Tag (" Item три", " Tag 32"). Значений Assert. Equals (" Tag 32", тег. Метка); Assert. Equals (3, Tag. Count); } с помощью (var context = New Item сконтекст (контекстоптионс)) {var Item = context. Set <Item> (). Include (e = > e). Tag с). Single (e = > e.Name = = " Item три"); Assert. Equals (2, Item. Tag s. Count); Assert. Equals (" Tag 31", элемент. Tag s [0]. Метка); Assert. Equals (3, Item. Tag s [0]. Count); Assert. Equals (" Tag 32", элемент. Tag s [1]. Метка); Assert. Equals (3, Item. Tag s [1]. Count); } }

Проблемы с использованием разных поставщиков баз данных

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

Тест проходит успешно при нарушении приложения

Одним из требований к нашему приложению является то, что " Item s" имеет имя с учетом регистра и коллекцию Tag s ". Это довольно просто для тестирования:

Item  без "> [факт] Public void Can_add_item_differing_only_by_case () {using (var context = New Item Сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var Item = Controller. Post Item ("итемтво"). Значений Assert. Equals ("итемтво", Item. Имя); } с помощью (var context = New Item сконтекст (контекстоптионс)) {var Item = context. Set <Item> (). Single (e = > e.Name = = "итемтво"); Assert. Equals (0, Item. Tag s. Count); } }

Выполнение этого теста для базы данных EF в памяти означает, что все правильно. При использовании SQLite все еще выглядит нормально. Но тест завершается ошибкой при запуске SQL Server!

System.InvalidOperationException : Sequence contains more than one element
   at System.Linq.ThrowHelper.ThrowMoreThanOneElementException()
   at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression)
   at System.Linq.Queryable.Single[TSource](IQueryable`1 source, Expression`1 predicate)
   at Tests.ItemsControllerTest.Can_add_item_differing_only_by_case()

Это происходит потому, что по умолчанию учитывается регистр в базе данных EF в памяти и в базе данных SQLite. SQL Server, с другой стороны, не учитывает регистр.

EF Core, по дизайну, не изменяет эти поведения, поскольку принудительное изменение чувствительности к регистру может существенно повлиять на производительность.

Когда мы понимаем, что это проблема, можно исправить приложение и компенсировать тесты. Однако в этом случае эта ошибка может быть пропущена, если только тестирование выполняется с использованием в базе данных EF в памяти или поставщиков SQLite.

Проверка завершается ошибкой, если приложение является правильным

Другим требованием для нашего приложения является то, что "Удаление Item должно удалить все связанные Tag с ним". Опять же, легко тестировать:

Item  "> [факт] Public void Can_remove_item_and_all_associated_ tags () {using (var context = New Item сконтекст (контекстоптионс)) {var Controller = New Item сконтроллер (контекст); var Item = Controller. Удалить Item (" Item три"). Значений Assert. Equals (" Item три", элемент. Имя); } с помощью (var context = New Item сконтекст (контекстоптионс)) {assertion. false (context. Set <Item> (). Any (e = > e.Name = = " Item три")); Assert. false (context. Set <Tag> (). Any (e = > e. Label. StartsWith (" Tag 3")));}}

этот тест передается в SQL Server и SQLite, но завершается сбоем с базой данных EF в памяти!

Assert.False() Failure
Expected: False
Actual:   True
   at Tests.ItemsControllerTest.Can_remove_item_and_all_associated_tags()

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