프로덕션 데이터베이스 시스템에 없이 테스트

이 페이지에서는 데이터베이스를 테스트 더블로 교환하여 애플리케이션이 프로덕션에서 실행되는 데이터베이스 시스템을 포함하지 않는 자동화된 테스트를 작성하는 기술에 대해 설명합니다. 이 작업을 수행하기 위한 다양한 유형의 테스트 이중 및 접근 방식이 있으며, 다양한 옵션을 완전히 이해하려면 테스트 전략 선택을 철저히 읽는 것이 좋습니다. 마지막으로 프로덕션 데이터베이스 시스템에 대해 테스트할 수도 있습니다. 프로덕션 데이터베이스 시스템에 대한 테스트에 설명되어 있습니다.

이 페이지에서는 xUnit 기술을 보여 주지만 NUnit을 비롯한 다른 테스트 프레임워크에도 비슷한 개념이 있습니다.

리포지토리 패턴

프로덕션 데이터베이스 시스템을 포함하지 않고 테스트를 작성하기로 결정한 경우 권장되는 방법은 리포지토리 패턴입니다. 이에 대한 자세한 배경은 이 섹션을 참조하세요. 리포지토리 패턴을 구현하는 첫 번째 단계는 EF Core LINQ 쿼리를 별도의 계층으로 추출하는 것이며, 나중에 스텁 또는 모의 작업을 수행합니다. 다음은 로깅 시스템에 대한 리포지토리 인터페이스의 예입니다.

public interface IBloggingRepository
{
    Blog GetBlogByName(string name);

    IEnumerable<Blog> GetAllBlogs();

    void AddBlog(Blog blog);

    void SaveChanges();
}

... 프로덕션 사용을 위한 부분 샘플 구현은 다음과 같습니다.

public class BloggingRepository : IBloggingRepository
{
    private readonly BloggingContext _context;

    public BloggingRepository(BloggingContext context)
        => _context = context;

    public Blog GetBlogByName(string name)
        => _context.Blogs.FirstOrDefault(b => b.Name == name);

    // Other code...
}

리포지토리는 단순히 EF Core 컨텍스트를 래핑하고 데이터베이스 쿼리 및 업데이트를 실행하는 메서드를 노출합니다. 주의해야 할 핵심은 GetAllBlogs 메서드가 IQueryable<Blog>가 아닌 IEnumerable<Blog>를 반환한다는 것입니다. 후자를 반환하면 결과를 통해 쿼리 연산자를 계속 구성할 수 있으므로 EF Core가 쿼리 번역에 계속 관여해야 합니다. 이는 처음에 리포지토리를 갖는 목적을 무산시킬 수 있습니다. IEnumerable<Blog>를 사용하면 리포지토리가 반환하는 항목을 쉽게 스텁하거나 모의할 수 있습니다.

ASP.NET Core 애플리케이션의 경우 애플리케이션의 ConfigureServices에 다음을 추가하여 종속성 주입에서 리포지토리를 서비스로 등록해야 합니다.

services.AddScoped<IBloggingRepository, BloggingRepository>();

마지막으로 컨트롤러는 EF Core 컨텍스트 대신 리포지토리 서비스에 삽입되고 해당 컨텍스트에서 메서드를 실행합니다.

private readonly IBloggingRepository _repository;

public BloggingControllerWithRepository(IBloggingRepository repository)
    => _repository = repository;

[HttpGet]
public Blog GetBlog(string name)
    => _repository.GetBlogByName(name);

이 시점에서 애플리케이션은 리포지토리 패턴에 따라 설계됩니다. 데이터 액세스 계층(EF Core)과의 유일한 접촉 지점은 이제 애플리케이션 코드와 실제 데이터베이스 쿼리 간의 중재자 역할을 하는 리포지토리 계층을 통해 수행됩니다. 이제 리포지토리를 스텁하거나 즐겨 찾는 모의 라이브러리로 모의하여 테스트를 작성할 수 있습니다. 다음은 인기 있는 Moq 라이브러리를 사용하는 모의 기반 테스트의 예입니다.

[Fact]
public void GetBlog()
{
    // Arrange
    var repositoryMock = new Mock<IBloggingRepository>();
    repositoryMock
        .Setup(r => r.GetBlogByName("Blog2"))
        .Returns(new Blog { Name = "Blog2", Url = "http://blog2.com" });

    var controller = new BloggingControllerWithRepository(repositoryMock.Object);

    // Act
    var blog = controller.GetBlog("Blog2");

    // Assert
    repositoryMock.Verify(r => r.GetBlogByName("Blog2"));
    Assert.Equal("http://blog2.com", blog.Url);
}

전체 샘플 코드는 여기에서 볼 수 있습니다.

SQLite 메모리 내

SQLite는 프로덕션 데이터베이스 시스템(예: SQL Server) 대신 테스트 도구 모음에 대한 EF Core 공급자로 쉽게 구성할 수 있습니다. 자세한 내용은 SQLite 공급자 문서를 참조하세요. 그러나 테스트 간에 쉽게 격리되고 실제 SQLite 파일을 처리할 필요가 없으므로 테스트할 때 SQLite의 메모리 내 데이터베이스 기능을 사용하는 것이 좋습니다.

메모리 내 SQLite를 사용하려면 하위 수준 연결이 열릴 때마다 새 데이터베이스가 생성되고 해당 연결이 닫혀 있을 때 삭제된다는 것을 이해하는 것이 중요합니다. 일반적으로 EF Core의 DbContext는 쿼리가 실행될 때마다 필요에 따라 데이터베이스 연결을 열고 닫아 불필요하게 긴 시간 동안 연결을 유지하지 않습니다. 그러나 메모리 내 SQLite를 사용하면 매번 데이터베이스가 다시 설정됩니다. 따라서 해결 방법으로 EF Core에 전달하기 전에 연결을 열고 테스트가 완료된 경우에만 연결을 닫도록 정렬합니다.

    public SqliteInMemoryBloggingControllerTest()
    {
        // Create and open a connection. This creates the SQLite in-memory database, which will persist until the connection is closed
        // at the end of the test (see Dispose below).
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        // These options will be used by the context instances in this test suite, including the connection opened above.
        _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        // Create the schema and seed some data
        using var context = new BloggingContext(_contextOptions);

        if (context.Database.EnsureCreated())
        {
            using var viewCommand = context.Database.GetDbConnection().CreateCommand();
            viewCommand.CommandText = @"
CREATE VIEW AllResources AS
SELECT Url
FROM Blogs;";
            viewCommand.ExecuteNonQuery();
        }

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }

    BloggingContext CreateContext() => new BloggingContext(_contextOptions);

    public void Dispose() => _connection.Dispose();

이제 테스트는 생성자에서 설정한 연결을 사용하여 컨텍스트를 반환하는 CreateContext를 호출하여 시드된 데이터가 있는 클린 데이터베이스가 있는지 확인할 수 있습니다.

SQLite 메모리 내 테스트에 대한 전체 샘플 코드는 여기에서 볼 수 있습니다.

메모리 내 공급자

테스트 개요 페이지에서 설명한 것처럼 메모리 내 공급자를 테스트에 사용하는 것은 권장되지 않습니다. 대신 SQLite를 사용하거나 리포지토리 패턴을 구현하는 것이 좋습니다. 메모리 내 데이터베이스를 사용하기로 결정한 경우 각 테스트 전에 새 메모리 내 데이터베이스를 설정하고 시드하는 일반적인 테스트 클래스 생성자는 다음과 같습니다.

public InMemoryBloggingControllerTest()
{
    _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
        .UseInMemoryDatabase("BloggingControllerTest")
        .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
        .Options;

    using var context = new BloggingContext(_contextOptions);

    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();

    context.AddRange(
        new Blog { Name = "Blog1", Url = "http://blog1.com" },
        new Blog { Name = "Blog2", Url = "http://blog2.com" });

    context.SaveChanges();
}

메모리 내 테스트에 대한 전체 샘플 코드는 여기에서 볼 수 있습니다.

메모리 내 데이터베이스 이름 지정

메모리 내 데이터베이스는 간단한 문자열 이름으로 식별되며 동일한 이름을 제공하여 동일한 데이터베이스에 여러 번 연결할 수 있습니다(위의 샘플이 각 테스트 전에 EnsureDeleted를 호출해야 하는 이유). 그러나 메모리 내 데이터베이스는 컨텍스트의 내부 서비스 공급자에 루팅됩니다. 대부분의 경우 컨텍스트는 동일한 서비스 공급자를 공유하지만 다양한 옵션을 사용하여 컨텍스트를 구성하면 새 내부 서비스 공급자의 사용을 트리거할 수 있습니다. 이 경우 메모리 내 데이터베이스를 공유해야 하는 모든 컨텍스트에 대해 UseInMemoryDatabase에 동일한 InMemoryDatabaseRoot의 인스턴스를 명시적으로 전달합니다(일반적으로 정적 InMemoryDatabaseRoot 필드를 사용하여 수행됨).

트랜잭션

기본적으로 트랜잭션이 시작되면 트랜잭션이 지원되지 않으므로 메모리 내 공급자가 예외를 throw합니다. 위의 샘플에서와 같이 InMemoryEventId.TransactionIgnoredWarning을 무시하도록 EF Core를 구성하여 트랜잭션을 자동으로 무시하려고 할 수 있습니다. 그러나 코드가 실제로 트랜잭션 의미 체계를 사용하는 경우(예: 실제로 변경 내용을 롤백하는 롤백에 따라 다름) 테스트가 작동하지 않습니다.

보기

메모리 내 공급자는 ToInMemoryQuery를 사용하여 LINQ 쿼리를 통해 뷰를 정의할 수 있습니다.

modelBuilder.Entity<UrlResource>()
    .ToInMemoryQuery(() => context.Blogs.Select(b => new UrlResource { Url = b.Url }));