다음을 통해 공유


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

이 페이지에서는 애플리케이션이 프로덕션에서 실행되는 데이터베이스 시스템을 포함하는 자동화된 테스트를 작성하는 기술에 대해 설명합니다. 대체 테스트 방법이 존재합니다. 여기서 프로덕션 데이터베이스 시스템은 테스트 더블로 교체됩니다. 자세한 내용은 테스트 개요 페이지를 참조하세요. 프로덕션(예: Sqlite)에 사용되는 것과 다른 데이터베이스에 대한 테스트는 여기서 다루지 않습니다. 다른 데이터베이스는 테스트 이중으로 사용되므로 여기서는 다루지 않습니다. 이 방법은 프로덕션 데이터베이스 시스템 없이 테스트에 적용됩니다.

실제 데이터베이스를 포함하는 테스트의 주요 장애물은 적절한 테스트 격리를 보장하여 병렬(또는 직렬로 실행)하는 테스트가 서로 간섭하지 않도록 하는 것입니다. 아래의 전체 샘플 코드는 여기에서 볼 수 있습니다.

팁 (조언)

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

데이터베이스 시스템 설정

요즘 대부분의 데이터베이스 시스템은 CI 환경과 개발자 컴퓨터 모두에서 쉽게 설치할 수 있습니다. 일반 설치 메커니즘을 통해 데이터베이스를 설치할 수 있을 정도로 쉬운 경우가 많지만, 대부분의 주요 데이터베이스에서 즉시 사용할 수 있는 Docker 이미지를 사용할 수 있으며 CI에서 특히 쉽게 설치할 수 있습니다. 개발자 환경인 GitHub 작업 영역의 경우 Dev Container 는 데이터베이스를 포함하여 필요한 모든 서비스 및 종속성을 설정할 수 있습니다. 이렇게 하려면 설치에 초기 투자가 필요하지만, 작업이 완료되면 작업 테스트 환경이 있고 더 중요한 작업에 집중할 수 있습니다.

특정 경우에 데이터베이스에는 테스트에 유용할 수 있는 특수 버전 또는 버전이 있습니다. SQL Server를 사용하는 경우 LocalDB 를 사용하여 거의 설치되지 않은 상태에서 로컬로 테스트를 실행하고, 요청 시 데이터베이스 인스턴스를 회전하고, 덜 강력한 개발자 컴퓨터에 리소스를 저장할 수 있습니다. 그러나 LocalDB는 다음과 같은 문제가 없는 것은 아닙니다.

  • SQL Server Developer Edition에서 수행하는 모든 작업을 지원하지는 않습니다.
  • Windows에서만 사용할 수 있습니다.
  • 서비스가 시작될 때 첫 번째 테스트 실행 시 지연이 발생할 수 있습니다.

일반적으로 전체 SQL Server 기능 집합을 제공하고 일반적으로 매우 쉽게 수행할 수 있으므로 LocalDB 대신 SQL Server Developer Edition을 설치하는 것이 좋습니다.

클라우드 데이터베이스를 사용하는 경우 일반적으로 속도를 개선하고 비용을 절감하기 위해 로컬 버전의 데이터베이스에 대해 테스트하는 것이 적절합니다. 예를 들어 프로덕션 환경에서 SQL Azure를 사용하는 경우 로컬에 설치된 SQL Server에 대해 테스트할 수 있습니다. 두 서버는 매우 유사합니다(프로덕션으로 전환하기 전에 SQL Azure 자체에 대해 테스트를 실행하는 것이 여전히 현명하지만). Azure Cosmos DB를 사용하는 경우 Azure Cosmos DB 에뮬레이터 는 로컬로 개발하고 테스트를 실행하는 데 유용한 도구입니다.

테스트 데이터베이스 만들기, 시드 및 관리

데이터베이스가 설치되면 테스트에서 데이터베이스 사용을 시작할 준비가 된 것입니다. 대부분의 간단한 경우 테스트 도구 모음에는 여러 테스트 클래스에서 여러 테스트 간에 공유되는 단일 데이터베이스가 있으므로 테스트 실행의 수명 동안 데이터베이스를 정확히 한 번 만들고 시드하도록 하는 몇 가지 논리가 필요합니다.

Xunit을 사용하는 경우 데이터베이스를 나타내고 여러 테스트 실행에서 공유되는 클래스 설비를 통해 이 작업을 수행할 수 있습니다.

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    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();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

위의 비품이 인스턴스화되면 EnsureDeleted() 데이터베이스를 삭제한 다음 EnsureCreated() (이전 실행에서 존재하는 경우) 최신 모델 구성을 사용하여 만듭니다(이러한 API에 대한 문서 참조). 데이터베이스가 생성되면, 장치는 테스트에서 사용할 몇 가지 데이터를 데이터베이스에 추가합니다. 새 테스트를 위해 나중에 변경하면 기존 테스트가 실패할 수 있으므로 시드 데이터에 대해 생각하는 데 약간의 시간을 할애할 가치가 있습니다.

테스트 클래스에서 fixture를 사용하려면, 간단히 fixture 유형을 구현하면 xUnit이 생성자에 이를 삽입해 줍니다.

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

이제 테스트 클래스에는 테스트가 완벽하게 작동하는 컨텍스트 인스턴스를 만드는 데 사용할 수 있는 Fixture 속성이 추가되었습니다.

[Fact]
public async Task GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = (await controller.GetBlog("Blog2")).Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

마지막으로, 위의 설비 생성 논리에서 일부 잠금을 발견했을 수 있습니다. 비품이 단일 테스트 클래스에서만 사용되는 경우 xUnit에 의해 정확히 한 번 인스턴스화됩니다. 그러나 여러 테스트 클래스에서 동일한 데이터베이스 고정을 사용하는 것이 일반적입니다. xUnit은 컬렉션 비품을 제공하지만 이 메커니즘은 테스트 클래스가 병렬로 실행되지 않도록 방지하므로 테스트 성능에 중요합니다. xUnit 클래스 설비를 사용하여 안전하게 관리하려면 데이터베이스 만들기 및 시드에 대한 간단한 잠금을 사용하고 정적 플래그를 사용하여 두 번 수행할 필요가 없도록 합니다.

데이터를 수정하는 테스트

위의 예제에서는 테스트 격리 관점에서 쉽게 사용할 수 있는 읽기 전용 테스트를 보여 줬습니다. 수정되는 항목이 없으므로 테스트 간섭은 불가능합니다. 반대로 데이터를 수정하는 테스트는 서로 간섭할 수 있으므로 더 문제가 됩니다. 쓰기 테스트를 격리하는 일반적인 기술 중 하나는 트랜잭션에서 테스트를 래핑하고 테스트가 끝날 때 해당 트랜잭션을 롤백하도록 하는 것입니다. 실제로 데이터베이스에 커밋된 항목이 없으므로 다른 테스트에는 수정 사항이 표시되지 않으며 간섭은 방지됩니다.

다음은 데이터베이스에 블로그를 추가하는 컨트롤러 메서드입니다.

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

다음을 사용하여 이 메서드를 테스트할 수 있습니다.

[Fact]
public async Task AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    await controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

위의 테스트 코드에 대한 몇 가지 참고 사항:

  • 아래 변경 내용이 데이터베이스에 커밋되지 않고 다른 테스트를 방해하지 않도록 트랜잭션을 시작합니다. 트랜잭션은 커밋되지 않으므로 컨텍스트 인스턴스가 삭제될 때 테스트가 끝날 때 암시적으로 롤백됩니다.
  • 원하는 업데이트를 수행한 후 컨텍스트 인스턴스의 변경 추적기를 ChangeTracker.Clear지워서 아래 데이터베이스에서 블로그를 실제로 로드하는지 확인합니다. 대신 두 컨텍스트 인스턴스를 사용할 수 있지만 두 인스턴스에서 동일한 트랜잭션이 사용되는지 확인해야 합니다.
  • 테스트가 이미 트랜잭션에 있는 컨텍스트 인스턴스를 수신하고 업데이트할 준비가 되도록 비품 CreateContext에서 트랜잭션을 시작할 수도 있습니다. 이렇게 하면 트랜잭션이 실수로 잊혀지는 경우를 방지하여 테스트 간섭으로 이어져 디버그하기 어려울 수 있습니다. 또한 다른 테스트 클래스에서 읽기 전용 및 쓰기 테스트를 구분할 수도 있습니다.

트랜잭션을 명시적으로 관리하는 테스트

추가적인 난이도를 나타내는 테스트의 최종 범주는 데이터를 수정하고 트랜잭션을 명시적으로 관리하는 테스트입니다. 데이터베이스는 일반적으로 중첩 트랜잭션을 지원하지 않으므로 실제 제품 코드에서 사용해야 하므로 위와 같이 격리를 위해 트랜잭션을 사용할 수 없습니다. 이러한 테스트는 좀 더 드문 경향이 있지만 특수한 방식으로 처리해야 합니다. 각 테스트 후에는 데이터베이스를 원래 상태로 정리해야 하며, 이러한 테스트가 서로 간섭하지 않도록 병렬 처리를 사용하지 않도록 설정해야 합니다.

다음 컨트롤러 메서드를 예로 살펴보겠습니다.

[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

    var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

어떤 이유로 이 메서드를 사용하려면 직렬화할 수 있는 트랜잭션이 필요하다고 가정해 보겠습니다(일반적으로는 그렇지 않음). 따라서 트랜잭션을 사용하여 테스트 격리를 보장할 수 없습니다. 테스트는 실제로 데이터베이스에 변경 내용을 커밋하므로, 위에 이미 표시된 다른 테스트를 방해하지 않기 위해 별도의 자체 데이터베이스를 사용하는 추가적인 픽스처를 정의하겠습니다.

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

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

이 설정은 앞서 사용한 것과 유사하지만, 특히 Cleanup 메서드를 포함합니다. 이는 데이터베이스가 시작 상태로 리셋되도록 모든 테스트 후에 호출할 것입니다.

이 비품이 단일 테스트 클래스에서만 사용되는 경우 위와 같이 클래스 비품으로 참조할 수 있습니다. xUnit은 동일한 클래스 내에서 테스트를 병렬 처리하지 않습니다( xUnit 문서의 테스트 컬렉션 및 병렬 처리에 대한 자세한 내용 참조). 그러나 여러 클래스 간에 이 고정을 공유하려는 경우 간섭을 방지하기 위해 이러한 클래스가 병렬로 실행되지 않도록 해야 합니다. 이를 위해 xUnit 컬렉션 비품으로 사용하고, 클래스 비품으로 사용하지 않습니다.

먼저, 설비를 참조하고 이를 필요로 하는 모든 트랜잭션 테스트 클래스에서 사용되는 테스트 컬렉션을 정의합니다.

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

이제 테스트 클래스에서 테스트 컬렉션을 참조하고 이전과 같이 생성자의 설비를 수락합니다.

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

마지막으로 테스트 클래스를 삭제할 수 있도록 하여 각 테스트 후에 fixture의 Cleanup 메서드를 호출하도록 정렬합니다.

public void Dispose()
    => Fixture.Cleanup();

xUnit은 컬렉션 고정을 한 번만 인스턴스화하기 때문에 위에서 했던 것처럼 데이터베이스 만들기 및 시드에 대한 잠금을 사용할 필요가 없습니다.

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

팁 (조언)

데이터베이스를 수정하는 테스트가 있는 테스트 클래스가 여러 개 있는 경우 각각 자체 데이터베이스를 참조하는 서로 다른 설비를 사용하여 병렬로 실행할 수 있습니다. 많은 테스트 데이터베이스를 만들고 사용하는 것은 문제가 되지 않으며 도움이 될 때마다 수행해야 합니다.

효율적인 데이터베이스 만들기

위의 EnsureDeleted()EnsureCreated() 샘플에서는 테스트를 실행하기 전에 up-to-date 테스트 데이터베이스를 사용했습니다. 이러한 작업은 특정 데이터베이스에서 약간 느려질 수 있으며, 코드 변경 내용을 반복하고 테스트를 반복해서 다시 실행하면 문제가 될 수 있습니다. 이 경우 fixture의 생성자에서 EnsureDeleted를 일시적으로 주석 처리할 수 있습니다. 이렇게 하면 테스트 실행 동안 동일한 데이터베이스를 다시 사용할 수 있습니다.

이 방법의 단점은 EF Core 모델을 변경하는 경우 데이터베이스 스키마가 최신 상태가 되지 않으며 테스트가 실패할 수 있다는 것입니다. 따라서 개발 주기 동안에만 이 작업을 일시적으로 수행하는 것이 좋습니다.

효율적인 데이터베이스 정리

위에서는 변경 내용이 실제로 데이터베이스에 커밋될 때 간섭을 방지하기 위해 모든 테스트 간에 데이터베이스를 정리해야 한다는 것을 확인했습니다. 위의 트랜잭션 테스트 샘플에서 EF Core API를 사용하여 테이블의 내용을 삭제하여 이 작업을 수행했습니다.

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

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

일반적으로 테이블을 지우는 가장 효율적인 방법은 아닙니다. 테스트 속도가 중요한 경우 원시 SQL을 사용하여 테이블을 삭제하는 것이 좋습니다.

DELETE FROM [Blogs];

재스파운 패키지를 사용하여 데이터베이스를 효율적으로 제거하는 것도 고려할 수 있습니다. 또한 지울 테이블을 지정할 필요가 없으므로 테이블에 모델이 추가될 때 정리 코드를 업데이트할 필요가 없습니다.

요약

  • 실제 데이터베이스에 대해 테스트할 때는 다음 테스트 범주를 구분해야 합니다.
    • 읽기 전용 테스트는 비교적 간단하며 격리에 대해 걱정할 필요 없이 항상 동일한 데이터베이스에 대해 병렬로 실행할 수 있습니다.
    • 쓰기 테스트는 더 문제가 되지만 트랜잭션을 사용하여 제대로 격리되었는지 확인할 수 있습니다.
    • 트랜잭션 테스트는 가장 문제가 되며, 데이터베이스를 원래 상태로 다시 설정하는 논리와 병렬 처리를 사용하지 않도록 설정해야 합니다.
  • 이러한 테스트 범주를 별도의 클래스로 분리하면 테스트 간의 혼동과 우발적인 간섭을 방지할 수 있습니다.
  • 시드된 테스트 데이터를 미리 생각하고 시드 데이터가 변경될 경우 너무 자주 중단되지 않는 방식으로 테스트를 작성해 보세요.
  • 여러 데이터베이스를 사용하여 데이터베이스를 수정하는 테스트를 병렬 처리하고 다른 시드 데이터 구성을 허용할 수도 있습니다.
  • 테스트 속도가 중요한 경우 테스트 데이터베이스를 만들고 실행 간에 데이터를 정리하는 보다 효율적인 기술을 살펴볼 수 있습니다.
  • 항상 테스트 병렬 처리 및 격리를 염두에 두어야 합니다.