EF Core 테스트 샘플EF Core testing sample

이 문서의 코드는 GitHub에서 실행 가능한 샘플로 찾을 수 있습니다.The code in this document can be found on GitHub as a runnable sample. 이러한 테스트 중 일부는 실패할 것으로 예상 됩니다.Note that some of these tests are expected to fail. 이에 대 한 이유는 아래에 설명 되어 있습니다.The reasons for this are explained below.

이 문서에서는 EF Core를 사용 하는 코드를 테스트 하기 위한 샘플을 안내 합니다.This doc walks through a sample for testing code that uses EF Core.

애플리케이션The application

샘플 에는 두 개의 프로젝트가 포함 되어 있습니다.The sample contains two projects:

모델 및 비즈니스 규칙The model and business rules

이 API를 지 원하는 모델에는 및 라는 두 개의 엔터티 형식이 있습니다. Items TagsThe model backing this API has two entity types: Items and Tags.

  • Items 대/소문자를 구분 하 고의 컬렉션을 포함 Tags 합니다.Items have a case-sensitive name and a collection of Tags.
  • 각에 Tag 는에 적용 된 횟수를 나타내는 레이블과 개수가 있습니다 Item .Each Tag has a label and a count representing the number of times it has been applied to the Item.
  • 각에는 Item 지정 된 Tag 레이블을 가진 하나만 있어야 합니다.Each Item should only have one Tag with a given label.
    • 항목에 동일한 레이블로 두 번 이상 태그가 지정 된 경우 새 태그가 생성 되는 대신 해당 레이블이 있는 기존 태그의 개수가 증가 합니다.If an item is tagged with the same label more than once, then the count on the existing tag with that label is incremented instead of a new tag being created.
  • 을 삭제 하면 Item 연결 된 모든를 삭제 해야 합니다 Tags .Deleting an Item should delete all associated Tags.

Item엔터티 형식The Item entity type

Item엔터티 형식:The Item entity type:

public class Item
{
    private readonly int _id;
    private readonly List<Tag> _tags = new List<Tag>();

    private Item(int id, string name)
    {
        _id = id;
        Name = name;
    }

    public Item(string name)
    {
        Name = name;
    }

    public Tag AddTag(string label)
    {
        var tag = _tags.FirstOrDefault(t => t.Label == label);

        if (tag == null)
        {
            tag = new Tag(label);
            _tags.Add(tag);
        }
        
        tag.Count++;

        return tag;
    }

    public string Name { get; }
    
    public IReadOnlyList<Tag> Tags => _tags;
}

및의 구성 DbContext.OnModelCreating :And its configuration in DbContext.OnModelCreating:

modelBuilder.Entity<Item>(
    b =>
        {
            b.Property("_id");
            b.HasKey("_id");
            b.Property(e => e.Name);
            b.HasMany(e => e.Tags).WithOne().IsRequired();
        });

엔터티 형식은 도메인 모델 및 비즈니스 규칙을 반영 하는 데 사용할 수 있는 방식으로 제한 됩니다.Notice that entity type constrains the way it can be used to reflect the domain model and business rules. 특히 다음 사항에 주의하십시오.In particular:

  • 기본 키가 필드에 직접 매핑되고 _id 공개적으로 노출 되지 않습니다.The primary key is mapped directly to the _id field and not exposed publicly
    • EF는 기본 키 값과 이름을 허용 하는 개인 생성자를 검색 하 고 사용 합니다.EF detects and uses the private constructor accepting the primary key value and name.
  • Name속성은 읽기 전용 이며 생성자 에서만 설정 됩니다.The Name property is read-only and set only in the constructor.
  • Tags 는 IReadOnlyList<Tag> 임의의 수정을 방지 하기 위해로 노출 됩니다.Tags are exposed as a IReadOnlyList<Tag> to prevent arbitrary modification.
    • EF는 Tags _tags 이름을 일치 시켜 지원 필드와 속성을 연결 합니다.EF associates the Tags property with the _tags backing field by matching their names.
    • AddTag메서드는 태그 레이블을 사용 하 고 위에 설명 된 비즈니스 규칙을 구현 합니다.The AddTag method takes a tag label and implements the business rule described above. 즉, 새 레이블에 대해서만 태그가 추가 됩니다.That is, a tag is only added for new labels. 그렇지 않으면 기존 레이블의 수가 증가 합니다.Otherwise the count on an existing label is incremented.
  • Tags다 대 일 관계에 대해 탐색 속성이 구성 되었습니다.The Tags navigation property is configured for a many-to-one relationship
    • 에서로의 탐색 속성은 필요 하지 않으므로 Tag Item 포함 되지 않습니다.There is no need for a navigation property from Tag to Item, so it is not included.
    • 또한는 Tag 외래 키 속성을 정의 하지 않습니다.Also, Tag does not define a foreign key property. 대신 EF는 섀도 상태에서 속성을 만들고 관리 합니다.Instead, EF will create and manage a property in shadow-state.

Tag엔터티 형식The Tag entity type

Tag엔터티 형식:The Tag entity type:

public class Tag
{
    private readonly int _id;

    private Tag(int id, string label)
    {
        _id = id;
        Label = label;
    }

    public Tag(string label) => Label = label;

    public string Label { get; }
    
    public int Count { get; set; }
}

및의 구성 DbContext.OnModelCreating :And its configuration in DbContext.OnModelCreating:

modelBuilder.Entity<Tag>(
    b =>
        {
            b.Property("_id");
            b.HasKey("_id");
            b.Property(e => e.Label);
        });

와 마찬가지로 Item Tag 은 해당 기본 키를 숨기고 속성을 읽기 전용으로 만듭니다 Label .Similarly to Item, Tag hides its primary key and makes the Label property read-only.

Items컨트롤러The ItemsController

Web API 컨트롤러는 매우 기본적입니다.The Web API controller is pretty basic. DbContext생성자 주입을 통해 종속성 주입 컨테이너에서을 가져옵니다.It gets a DbContext from the dependency injection container through constructor injection:

private readonly ItemsContext _context;

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

Items지정 된 이름을 사용 하 여 또는를 모두 가져오는 메서드가 있습니다 Item .It has methods to get all Items or an Item with a given name:

[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 .It has a method to add a new Item:

[HttpPost]
public ActionResult<Item> PostItem(string itemName)
{
    var item = _context.Add(new Item(itemName)).Entity;

    _context.SaveChanges();
    
    return item;
}

레이블에 태그를 적용할 방법 Item :A method to tag an Item with a label:

[HttpPost]
public ActionResult<Tag> PostTag(string itemName, string tagLabel)
{
    var tag = _context
        .Set<Item>()
        .Include(e => e.Tags)
        .Single(e => e.Name == itemName)
        .AddTag(tagLabel);

    _context.SaveChanges();
    
    return tag;
}

및 모두 연결 된 및를 삭제 하는 메서드는 Item Tags 다음과 같습니다.And a method to delete an Item and all associated Tags:

[HttpDelete("{itemName}")]
public ActionResult<Item> DeleteItem(string itemName)
{
    var item = _context
        .Set<Item>()
        .SingleOrDefault(e => e.Name == itemName);

    if (item == null)
    {
        return NotFound();
    }

    _context.Remove(item);
    _context.SaveChanges();

    return item;
}

대부분의 유효성 검사 및 오류 처리는 혼란을 줄이기 위해 제거 되었습니다.Most validation and error handling have been removed to reduce clutter.

테스트The Tests

테스트는 여러 데이터베이스 공급자 구성을 사용 하 여 실행 되도록 구성 됩니다.The tests are organized to run with multiple database provider configurations:

  • 응용 프로그램에서 사용 하는 공급자 인 SQL Server 공급자The SQL Server provider, which is the provider used by the application
  • SQLite 공급자The SQLite provider
  • 메모리 내 SQLite 데이터베이스를 사용 하는 SQLite 공급자The SQLite provider using in-memory SQLite databases
  • EF 메모리 내 데이터베이스 공급자The EF in-memory database provider

이렇게 하려면 모든 테스트를 기본 클래스에 배치한 다음이에서 상속 하 여 각 공급자를 사용 하 여 테스트 합니다.This is achieved by putting all the tests in a base class, then inheriting from this to test with each provider.

LocalDB를 사용 하지 않는 경우 SQL Server 연결 문자열을 변경 해야 합니다.You will need to change the SQL Server connection string if you're not using LocalDB. 메모리 내 테스트에 SQLite를 사용 하는 방법에 대 한 지침은 sqlite로 테스트 를 참조 하세요.See Testing with SQLite for guidance on using SQLite for in-memory testing.

다음 두 가지 테스트가 실패할 것으로 예상 됩니다.The following two tests are expected to fail:

  • Can_remove_item_and_all_associated_tags EF 메모리 내 데이터베이스 공급자를 사용 하 여 실행 하는 경우Can_remove_item_and_all_associated_tags when running with the EF in-memory database provider
  • Can_add_item_differing_only_by_case SQL Server 공급자를 사용 하 여 실행 하는 경우Can_add_item_differing_only_by_case when running with the SQL Server provider

이에 대해서는 아래에서 자세히 설명 합니다.This is covered in more detail below.

데이터베이스 설정 및 시드Setting up and seeding the database

대부분의 테스트 프레임 워크와 마찬가지로 XUnit은 각 테스트 실행에 대 한 새 테스트 클래스 인스턴스를 만듭니다.XUnit, like most testing frameworks, will create a new test class instance for each test run. 또한 XUnit은 지정 된 테스트 클래스 내에서 테스트를 병렬로 실행 하지 않습니다.Also, XUnit will not run tests within a given test class in parallel. 즉, 테스트 생성자에서 데이터베이스를 설정 하 고 구성할 수 있으며 각 테스트에 대해 잘 알려진 상태가 됩니다.This means that we can setup and configure the database in the test constructor and it will be in a well-known state for each test.

이 샘플은 각 테스트에 대해 데이터베이스를 다시 만듭니다.This sample recreates the database for each test. 이는 SQLite 및 EF 메모리 내 데이터베이스 테스트에서 잘 작동 하지만 SQL Server를 비롯 한 다른 데이터베이스 시스템에 상당한 오버 헤드가 발생할 수 있습니다.This works well for SQLite and EF in-memory database testing but can involve significant overhead with other database systems, including SQL Server. 이러한 오버 헤드를 줄이는 방법은 테스트에서 데이터베이스 공유에서 다룹니다.Approaches for reducing this overhead are covered in Sharing databases across tests.

각 테스트가 실행 되는 경우:When each test is run:

  • DbContextOptions가 사용 중인 공급자에 대해 구성 되 고 기본 클래스 생성자에 전달 됩니다.DbContextOptions are configured for the provider in use and passed to the base class constructor
    • 이러한 옵션은 속성에 저장 되 고 DbContext 인스턴스를 만들기 위한 테스트 전체에서 사용 됩니다.These options are stored in a property and used throughout the tests for creating DbContext instances
  • 시드 메서드를 호출 하 여 데이터베이스를 만들고 초기값을 만듭니다.A Seed method is called to create and seed the database
    • Seed 메서드를 사용 하면 데이터베이스를 삭제 한 다음 다시 만들어 데이터베이스를 정리 합니다.The Seed method ensures the database is clean by deleting it and then re-creating it
    • 잘 알려진 일부 테스트 엔터티가 생성 되어 데이터베이스에 저장 됩니다.Some well-known test entities are created and saved to the database
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();
    }
}

그런 다음 각 구체적 테스트 클래스가이에서 상속 됩니다.Each concrete test class then inherits from this. 예를 들면 다음과 같습니다.For example:

public class SqliteItemsControllerTest : ItemsControllerTest
{
    public SqliteItemsControllerTest()
        : base(
            new DbContextOptionsBuilder<ItemsContext>()
                .UseSqlite("Filename=Test.db")
                .Options)
    {
    }
}

테스트 구조Test structure

응용 프로그램에서 종속성 주입을 사용 하는 경우에도 테스트는 그렇지 않습니다.Even though the application uses dependency injection, the tests do not. 여기에서 종속성 주입을 사용 하는 것이 좋지만 필요한 추가 코드의 값은 거의 없습니다.It would be fine to use dependency injection here, but the additional code it requires has little value. 대신를 사용 하 여 DbContext를 만든 다음이를 new 컨트롤러에 대 한 종속성으로 직접 전달 합니다.Instead, a DbContext is created using new and then directly passed as the dependency to the controller.

그런 다음 각 테스트는 컨트롤러에서 테스트 중인 메서드를 실행 하 고 결과를 예상 대로 어설션 합니다.Each test then executes the method under test on the controller and asserts the results are as expected. 예를 들면 다음과 같습니다.For example:

[Fact]
public void Can_get_items()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var items = controller.Get().ToList();
        
        Assert.Equal(3, items.Count);
        Assert.Equal("ItemOne", items[0].Name);
        Assert.Equal("ItemThree", items[1].Name);
        Assert.Equal("ItemTwo", items[2].Name);
    }
}

서로 다른 DbContext 인스턴스를 사용 하 여 데이터베이스를 시드해야 하 고 테스트를 실행 합니다.Notice that different DbContext instances are used to seed the database and run the tests. 이렇게 하면 테스트에서 시드 시 컨텍스트에 의해 추적 된 엔터티를 사용 하거나 사용 하지 않습니다.This ensures that the test is not using (or tripping over) entities tracked by the context when seeding. 웹 앱 및 서비스에서 수행 하는 작업에도 더 적합 합니다.It also better matches what happens in web apps and services.

데이터베이스를 다시 작성 하는 테스트는 비슷한 이유로 테스트에 두 번째 DbContext 인스턴스를 만듭니다.Tests that mutate the database create a second DbContext instance in the test for similar reasons. 즉, 새로 정리 된 컨텍스트를 만든 다음 데이터베이스에서 해당 데이터를 읽고 변경 내용이 데이터베이스에 저장 되도록 합니다.That is, creating a new, clean, context and then reading into it from the database to ensure that the changes were saved to the database. 예를 들면 다음과 같습니다.For example:

[Fact]
public void Can_add_item()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.PostItem("ItemFour").Value;
        
        Assert.Equal("ItemFour", item.Name);
    }
    
    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Single(e => e.Name == "ItemFour");
        
        Assert.Equal("ItemFour", item.Name);
        Assert.Equal(0, item.Tags.Count);
    }
}

약간 더 관련 된 두 가지 테스트는 추가에 대 한 비즈니스 논리를 포함 tags 합니다.Two slightly more involved tests cover the business logic around adding tags.

[Fact]
public void Can_add_tag()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var tag = controller.PostTag("ItemTwo", "Tag21").Value;
        
        Assert.Equal("Tag21", tag.Label);
        Assert.Equal(1, tag.Count);
    }
    
    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemTwo");
        
        Assert.Equal(1, item.Tags.Count);
        Assert.Equal("Tag21", item.Tags[0].Label);
        Assert.Equal(1, item.Tags[0].Count);
    }
}
[Fact]
public void Can_add_tag_when_already_existing_tag()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var tag = controller.PostTag("ItemThree", "Tag32").Value;
        
        Assert.Equal("Tag32", tag.Label);
        Assert.Equal(3, tag.Count);
    }
    
    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemThree");
        
        Assert.Equal(2, item.Tags.Count);
        Assert.Equal("Tag31", item.Tags[0].Label);
        Assert.Equal(3, item.Tags[0].Count);
        Assert.Equal("Tag32", item.Tags[1].Label);
        Assert.Equal(3, item.Tags[1].Count);
    }
}

다른 데이터베이스 공급자를 사용 하는 문제Issues using different database providers

프로덕션 응용 프로그램에서 사용 되는 것과 다른 데이터베이스 시스템을 사용 하 여 테스트 하면 문제가 발생할 수 있습니다.Testing with a different database system than is used in the production application can lead to problems. 이러한 내용은 EF Core를 사용 하는 테스트 코드의 개념적 수준에서 설명 합니다.These are covered at the conceptual level in Testing code that uses EF Core.
다음 섹션에서는이 샘플의 테스트에서 보여 주는 이러한 문제에 대 한 두 가지 예를 다룹니다.The sections below cover two examples of such issues demonstrated by the tests in this sample.

응용 프로그램이 끊어지면 테스트가 통과 합니다.Test passes when the application is broken

응용 프로그램의 요구 사항 중 하나는 " Items 대/소문자를 구분 하 고의 컬렉션을 포함 Tags 합니다."입니다.One of the requirements for our application is that "Items have a case-sensitive name and a collection of Tags." 이것은 매우 간단 하 게 테스트할 수 있습니다.This is pretty simple to test:

[Fact]
public void Can_add_item_differing_only_by_case()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.PostItem("itemtwo").Value;
        
        Assert.Equal("itemtwo", item.Name);
    }
    
    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Single(e => e.Name == "itemtwo");
        
        Assert.Equal(0, item.Tags.Count);
    }
}

EF 메모리 내 데이터베이스에 대해이 테스트를 실행 하면 모든 것이 적절 하다는 것을 알 수 있습니다.Running this test against the EF in-memory database indicates that everything is fine. SQLite를 사용 하는 경우에도 여전히 잘 보입니다.Everything still looks fine when using SQLite. 하지만 SQL Server에 대해 실행 하면 테스트가 실패 합니다.But the test fails when run against 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 데이터베이스는 기본적으로 대/소문자를 구분 하기 때문입니다.This is because both the EF in-memory database and the SQLite database are case-sensitive by default. 반면 SQL Server는 대/소문자를 구분 하지 않습니다.SQL Server, on the other hand, is case-insensitive!

대/소문자 구분을 강제로 변경 하면 성능에 큰 영향을 줄 수 있으므로 의도적으로 EF Core는 이러한 동작을 변경 하지 않습니다.EF Core, by design, does not change these behaviors because forcing a change in case-sensitivity can have a big performance impact.

이 문제가 발생 하면 응용 프로그램을 수정 하 고 테스트를 보정할 수 있습니다.Once we know this is a problem we can fix the application and compensate in tests. 그러나 EF 메모리 내 데이터베이스 또는 SQLite 공급자만 테스트 하는 경우에는이 버그가 누락 될 수 있다는 점이 여기에 해당 합니다.However, the point here is that this bug could be missed if only testing with the EF in-memory database or SQLite providers.

응용 프로그램이 올바르면 테스트가 실패 합니다.Test fails when the application is correct

응용 프로그램에 대 한 또 다른 요구 사항은 "삭제를 Item 모두 삭제 해야 Tags 합니다." 라는 것입니다.Another of the requirements for our application is that "deleting an Item should delete all associated Tags." 다시, 테스트 하기 쉽습니다.Again, easy to test:

[Fact]
public void Can_remove_item_and_all_associated_tags()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.DeleteItem("ItemThree").Value;
        
        Assert.Equal("ItemThree", item.Name);
    }
    
    using (var context = new ItemsContext(ContextOptions))
    {
        Assert.False(context.Set<Item>().Any(e => e.Name == "ItemThree"));
        Assert.False(context.Set<Tag>().Any(e => e.Label.StartsWith("Tag3")));
    }
}

이 테스트는 SQL Server 및 SQLite에서 전달 되지만 EF 메모리 내 데이터베이스에는 실패 합니다.This test passes on SQL Server and SQLite, but fails with the EF in-memory database!

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

이 경우 SQL Server에서 하위 삭제를 지원 하기 때문에 응용 프로그램이 올바르게 작동 하 고 있는 것입니다.In this case, the application is working correctly because SQL Server supports cascade deletes. SQLite는 대부분의 관계형 데이터베이스와 마찬가지로 하위 삭제도 지원 하므로 SQLite에서이 작업을 테스트 합니다.SQLite also supports cascade deletes, as do most relational databases, so testing this on SQLite works. 반면에 EF 메모리 내 데이터베이스는 계단식 삭제를 지원 하지않습니다.On the other hand, the EF in-memory database does not support cascade deletes. 즉, 응용 프로그램의이 부분은 EF 메모리 내 데이터베이스 공급자를 사용 하 여 테스트할 수 없습니다.This means that this part of the application cannot be tested with the EF in-memory database provider.