ASP.NET Core의 통합 테스트

작성자: Javier Calvarro Nelson, Steve SmithJos van der Til

통합 테스트는 앱의 구성 요소가 데이터베이스, 파일 시스템 및 네트워크를 비롯하여 앱의 지원 인프라를 포함하는 수준에서 올바르게 작동하는지 확인합니다. ASP.NET Core는 테스트 웹 호스트 및 메모리 내 테스트 서버에서 단위 테스트 프레임워크를 사용하는 통합 테스트를 지원합니다.

이 항목에서는 단위 테스트에 대한 기본적인 지식이 있다고 가정합니다. 테스트 개념을 잘 모르는 경우 ..NET Core 및 .NET Standard의 단위 테스트 항목 및 연결된 콘텐츠를 참조하세요.

예제 코드 살펴보기 및 다운로드 (다운로드 방법)

샘플 앱은 Razor Pages 앱이며 Razor Pages를 기본적으로 이해하고 있다고 가정합니다. Razor Pages에 익숙하지 않은 경우 다음 항목을 참조하세요.

참고

SPA 테스트의 경우 브라우저를 자동화할 수 있는 Playwright for .NET과 같은 도구를 사용하는 것이 좋습니다.

통합 테스트 소개

통합 테스트는 단위 테스트보다 광범위한 수준에서 앱의 구성 요소를 평가합니다. 단위 테스트는 개별 클래스 메서드와 같은 격리된 소프트웨어 구성 요소를 테스트하는 데 사용됩니다. 통합 테스트는 두 개 이상의 앱 구성 요소가 함께 작동하여 요청을 완전히 처리하는 데 필요한 모든 구성 요소를 포함하여 예상되는 결과를 생성하는지 확인합니다.

이러한 광범위한 테스트는 다음과 같은 구성 요소를 포함하여 앱의 인프라 및 전체 프레임워크를 테스트하는 데 사용됩니다.

  • 데이터베이스
  • 파일 시스템
  • 네트워크 어플라이언스
  • 요청-응답 파이프라인

단위 테스트는 인프라 구성 요소 대신 가짜 또는 모의 개체로 알려져 있는 제작된 구성 요소를 사용합니다.

단위 테스트와 달리, 통합 테스트는 다음과 같습니다.

  • 앱이 프로덕션 환경에서 사용하는 실제 구성 요소를 사용합니다.
  • 더 많은 코드와 데이터 처리가 필요합니다.
  • 실행하는 데 시간이 더 오래 걸립니다.

따라서 통합 테스트 사용은 가장 중요한 인프라 시나리오로 제한합니다. 단위 테스트 또는 통합 테스트를 사용하여 동작을 테스트할 수 있는 경우 단위 테스트를 선택합니다.

통합 테스트를 논의할 때 테스트된 프로젝트를 주로 테스트 중인 시스템 또는 간단히 "SUT"라고 합니다. 이 항목 전체에서 테스트된 ASP.NET Core 앱을 나타내기 위해 "SUT"를 사용합니다.

데이터베이스 및 파일 시스템을 사용하는 데이터 및 파일 액세스의 가능한 모든 데이터 순열에 대해 통합 테스트를 작성하지는 마세요. 앱에서 데이터베이스 및 파일 시스템과 상호 작용하는 위치 수에 관계없이, 주요 읽기, 쓰기, 업데이트 및 삭제 통합 테스트 세트를 통해 일반적으로 데이터베이스 및 파일 시스템 구성 요소를 적절하게 테스트할 수 있습니다. 이러한 구성 요소와 상호 작용하는 메서드 논리의 루틴 테스트를 위해 단위 테스트를 사용합니다. 단위 테스트에서 인프라 가짜/모의 시험을 사용하면 테스트 실행 속도가 더 빨라집니다.

ASP.NET Core의 통합 테스트

ASP.NET Core의 통합 테스트에는 다음이 필요합니다.

  • 테스트 프로젝트는 테스트를 포함하고 실행하는 데 사용됩니다. 테스트 프로젝트에는 SUT에 대한 참조가 있습니다.
  • 테스트 프로젝트는 SUT에 대한 테스트 웹 호스트를 만들고 테스트 서버 클라이언트를 사용하여 SUT에서 요청 및 응답을 처리합니다.
  • Test Runner는 테스트를 실행하고 테스트 결과를 보고하는 데 사용됩니다.

통합 테스트는 일반적인 정렬, 실행어설션 테스트 단계를 포함하는 이벤트 시퀀스를 따릅니다.

  1. SUT의 웹 호스트가 구성되어 있습니다.
  2. 앱에 요청을 제출하는 테스트 서버 클라이언트가 만들어집니다.
  3. 정렬 테스트 단계가 실행됩니다. 테스트 앱은 요청을 준비합니다.
  4. 실행 테스트 단계가 실행됩니다. 클라이언트는 요청을 제출하고 응답을 받습니다.
  5. 어설션 테스트 단계가 실행됩니다. 실제 응답은 예상 응답에 따라 성공 또는 실패로 검증됩니다.
  6. 모든 테스트가 실행될 때까지 프로세스가 계속됩니다.
  7. 테스트 결과가 보고됩니다.

일반적으로 테스트 웹 호스트는 테스트 실행을 위해 앱의 일반 웹 호스트와는 다르게 구성됩니다. 예를 들어, 다른 데이터베이스 또는 다른 앱 설정을 테스트에 사용할 수 있습니다.

테스트 웹 호스트와 메모리 내 테스트 서버(TestServer)와 같은 인프라 구성 요소는 Microsoft.AspNetCore.Mvc.Testing 패키지에서 제공되거나 관리됩니다. 이 패키지를 사용하면 테스트 생성 및 실행이 간소화됩니다.

Microsoft.AspNetCore.Mvc.Testing 패키지는 다음 작업을 처리합니다.

  • SUT의 종속성 파일(.deps)을 테스트 프로젝트의 bin 디렉터리로 복사합니다.
  • 테스트를 실행하면 고정 파일 및 페이지/뷰를 찾을 수 있도록 콘텐츠 루트를 SUT의 프로젝트 루트로 설정합니다.
  • TestServer에서 SUT 부트스트랩을 간소화하기 위해 WebApplicationFactory 클래스를 제공합니다.

단위 테스트 설명서에서는 테스트를 실행하는 방법에 대한 자세한 지침과 테스트 및 테스트 클래스의 이름을 설정하는 방법에 대한 권장 사항을 제공하고 테스트 프로젝트 및 Test Runner를 설정하는 방법을 설명합니다.

참고

앱용 테스트 프로젝트를 만들 때 다른 프로젝트에 대한 통합 테스트에서 단위 테스트를 분리합니다. 이렇게 하면 인프라 테스트 구성 요소가 실수로 단위 테스트에 포함되지 않도록 할 수 있습니다. 또한 단위 및 통합 테스트를 분리하면 실행되는 테스트 세트를 제어할 수도 있습니다.

Razor Pages 앱과 MVC 앱의 테스트 구성 간에는 차이가 거의 없습니다. 유일한 차이점은 테스트의 이름을 지정하는 방법 뿐입니다. Razor Pages 앱에서 페이지 엔드포인트의 테스트 이름은 일반적으로 페이지 모델 클래스의 이름을 따서 지정합니다(예: 인덱스 페이지에 대한 구성 요소 통합을 테스트하려는 경우 IndexPageTests). MVC 앱에서 테스트는 일반적으로 컨트롤러 클래스로 구성되며 해당 이름은 테스트하는 컨트롤러의 이름을 따서 지정합니다(예: Home 컨트롤러에 대한 구성 요소 통합을 테스트하려는 경우 HomeControllerTests).

테스트 앱 필수 조건

테스트 프로젝트는 다음을 수행해야 합니다.

이러한 필수 구성 요소는 샘플 앱에서 볼 수 있습니다. tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj 파일을 검사합니다. 샘플 앱은 xUnit 테스트 프레임워크 및 AngleSharp 파서 라이브러리를 사용하므로 샘플 앱은 다음을 참조합니다.

xunit.runner.visualstudio 버전 2.4.2 이상을 사용하는 앱에서 테스트 프로젝트는 Microsoft.NET.Test.Sdk 패키지를 참조해야 합니다.

Entity Framework Core도 테스트에 사용됩니다. 앱 참조:

SUT 환경

SUT의 환경이 설정되지 않은 경우 환경은 기본적으로 Development입니다.

기본 WebApplicationFactory를 사용하는 기본 테스트

ASP.NET Core 6에 WebApplication이 도입되어 Startup 클래스가 필요하지 않게 되었습니다. Startup 클래스 없이 WebApplicationFactory를 사용하여 테스트하려면 ASP.NET Core 6 앱이 다음 작업 중 하나를 수행하여 암시적으로 정의된 Program 클래스를 테스트 프로젝트에 노출해야 합니다.

  • 웹앱의 내부 형식을 테스트 프로젝트에 노출합니다. 이 작업은 프로젝트 파일(.csproj)에서 수행할 수 있습니다.
    <ItemGroup>
         <InternalsVisibleTo Include="MyTestProject" />
    </ItemGroup>
    
  • partial 클래스 선언을 사용하여 Program 클래스를 public으로 만듭니다.
    var builder = WebApplication.CreateBuilder(args);
    // ... Configure services, routes, etc.
    app.Run();
    + public partial class Program { }
    

웹 애플리케이션을 변경한 후에는 테스트 프로젝트가 WebApplicationFactory에 대한 Program 클래스를 사용할 수 있습니다.

[Fact]
public async Task HelloWorldTest()
{
    var application = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            // ... Configure test services
        });

    var client = application.CreateClient();
    //...
}

WebApplicationFactory<TEntryPoint>는 통합 테스트에 대한 TestServer를 만드는 데 사용됩니다. TEntryPoint는 SUT의 진입점 클래스로, 일반적으로 Startup 클래스입니다.

테스트 클래스는 클래스 픽스쳐 인터페이스(IClassFixture)를 구현하여 클래스가 테스트를 포함함을 나타내고 클래스의 테스트에서 공유 개체 인스턴스를 제공합니다.

다음 테스트 클래스 BasicTests에서는 WebApplicationFactory를 사용하여 SUT를 부트스트랩하고 HttpClient를 테스트 메서드 Get_EndpointsReturnSuccessAndCorrectContentType에 제공합니다. 메서드는 응답 상태 코드가 성공(200-299 범위의 상태 코드)인지 Content-Type 헤더가 여러 앱 페이지에 대해 text/html; charset=utf-8인지를 확인합니다.

CreateClient()는 자동으로 리디렉션을 따르고 cookie를 처리하는 HttpClient의 인스턴스를 만듭니다.

public class BasicTests 
    : IClassFixture<WebApplicationFactory<RazorPagesProject.Startup>>
{
    private readonly WebApplicationFactory<RazorPagesProject.Startup> _factory;

    public BasicTests(WebApplicationFactory<RazorPagesProject.Startup> factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData("/")]
    [InlineData("/Index")]
    [InlineData("/About")]
    [InlineData("/Privacy")]
    [InlineData("/Contact")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
}

기본적으로 GDPR 동의 정책을 사용하도록 설정한 경우에는 필요하지 않은 cookie가 요청에서 유지되지 않습니다. TempData 공급자가 사용하는 쿠키와 같이 필수가 아닌 cookie를 유지하려면 테스트에서 필수로 표시합니다. cookie를 필수로 표시하는 방법에 대한 지침은 필수 cookie를 참조하세요.

WebApplicationFactory 사용자 지정

웹 호스트 구성은 하나 이상의 사용자 지정 팩터리를 만들기 위해 WebApplicationFactory에서 상속하여 테스트 클래스와는 별개로 만들 수 있습니다.

  1. WebApplicationFactory에서 상속하고 ConfigureWebHost를 재정의합니다. IWebHostBuilderConfigureServices를 사용하여 서비스 컬렉션을 구성할 수 있도록 합니다.

    public class CustomWebApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup: class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                        typeof(DbContextOptions<ApplicationDbContext>));
    
                services.Remove(descriptor);
    
                services.AddDbContext<ApplicationDbContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemoryDbForTesting");
                });
    
                var sp = services.BuildServiceProvider();
    
                using (var scope = sp.CreateScope())
                {
                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices.GetRequiredService<ApplicationDbContext>();
                    var logger = scopedServices
                        .GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
    
                    db.Database.EnsureCreated();
    
                    try
                    {
                        Utilities.InitializeDbForTests(db);
                    }
                    catch (Exception ex)
                    {
                        logger.LogError(ex, "An error occurred seeding the " +
                            "database with test messages. Error: {Message}", ex.Message);
                    }
                }
            });
        }
    }
    

    샘플 앱의 데이터베이스 시드는 InitializeDbForTests 메서드에서 수행됩니다. 이 메서드는 통합 테스트 샘플: 테스트 앱 조직 섹션에 설명되어 있습니다.

    SUT의 데이터베이스 컨텍스트는 Startup.ConfigureServices 메서드에 등록되어 있습니다. 테스트 앱의 builder.ConfigureServices 콜백은 앱의 Startup.ConfigureServices 코드가 실행된 에 실행됩니다. 실행 순서는 ASP.NET Core 3.0의 릴리스를 포함하는 일반 호스트의 호환성이 손상되는 변경에 해당합니다. 앱의 데이터베이스와는 다른 데이터베이스를 테스트에 사용하려면 앱의 데이터베이스 컨텍스트를 builder.ConfigureServices에서 바꾸어야 합니다.

    여전히 웹 호스트를 사용하는 SUT의 경우, SUT의 Startup.ConfigureServices 코드 이전에 테스트 앱의 builder.ConfigureServices 콜백이 실행됩니다. 테스트 앱의 builder.ConfigureTestServices 콜백은 이후에 실행됩니다.

    샘플 앱은 데이터베이스 컨텍스트에 대한 서비스 설명자를 찾고, 이 설명자를 사용하여 서비스 등록을 제거합니다. 그런 다음, 팩터리는 테스트를 위해 메모리 내 데이터베이스를 사용하는 새 ApplicationDbContext를 추가합니다.

    메모리 내 데이터베이스가 아닌 다른 데이터베이스에 연결하려면 UseInMemoryDatabase 호출을 변경하여 컨텍스트를 다른 데이터베이스에 연결합니다. SQL Server 테스트 데이터베이스를 사용하려면 다음을 수행합니다.

    • 프로젝트 파일에서 Microsoft.EntityFrameworkCore.SqlServer NuGet 패키지를 참조합니다.
    • 데이터베이스에 대한 연결 문자열을 사용하여 UseSqlServer를 호출합니다.
    services.AddDbContext<ApplicationDbContext>((options, context) => 
    {
        context.UseSqlServer(
            Configuration.GetConnectionString("TestingDbConnectionString"));
    });
    
  2. 테스트 클래스에서 사용자 지정 CustomWebApplicationFactory를 사용합니다. 다음 예제에서는 IndexPageTests 클래스에서 팩터리를 사용합니다.

    public class IndexPageTests : 
        IClassFixture<CustomWebApplicationFactory<RazorPagesProject.Startup>>
    {
        private readonly HttpClient _client;
        private readonly CustomWebApplicationFactory<RazorPagesProject.Startup> 
            _factory;
    
        public IndexPageTests(
            CustomWebApplicationFactory<RazorPagesProject.Startup> factory)
        {
            _factory = factory;
            _client = factory.CreateClient(new WebApplicationFactoryClientOptions
                {
                    AllowAutoRedirect = false
                });
        }
    

    샘플 앱 클라이언트는 HttpClient의 다음 리디렉션을 방지하도록 구성됩니다. 모의 인증 섹션 뒷부분에 설명된 것처럼 이를 통해 테스트는 앱의 첫 번째 응답 결과를 확인할 수 있습니다. 첫 번째 응답은 Location 헤더를 사용하는 이러한 테스트 대부분에서 리디렉션입니다.

  3. 일반적인 테스트는 HttpClient 및 도우미 메서드를 사용하여 요청 및 응답을 처리합니다.

    [Fact]
    public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
    {
        // Arrange
        var defaultPage = await _client.GetAsync("/");
        var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    
        //Act
        var response = await _client.SendAsync(
            (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
            (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']"));
    
        // Assert
        Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
        Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
        Assert.Equal("/", response.Headers.Location.OriginalString);
    }
    

SUT에 대한 POST 요청은 앱의 데이터 보호 위조 방지 시스템에서 자동으로 수행하는 위조 방지 검사를 충족해야 합니다. 테스트의 POST 요청을 정렬하기 위해 테스트 앱은 다음을 수행해야 합니다.

  1. 페이지에 대한 요청을 만듭니다.
  2. 위조 방지 cookie를 구문 분석하고 응답에서 유효성 검사 토큰을 요청합니다.
  3. 위조 방지 cookie 및 요청 유효성 검사 토큰을 사용하여 POST 요청을 수행합니다.

샘플 앱SendAsync 도우미 확장 메서드(Helpers/HttpClientExtensions.cs) 및 GetDocumentAsync 도우미 메서드(Helpers/HtmlHelpers.cs)는 AngleSharp 파서를 사용하여 다음 메서드를 사용한 위조 방지 확인을 처리합니다.

  • GetDocumentAsync: HttpResponseMessage를 수신하고 IHtmlDocument를 반환합니다. GetDocumentAsync는 원본 HttpResponseMessage에 따라 가상 응답을 준비하는 팩터리를 사용합니다. 자세한 내용은 AngleSharp 설명서를 참조하세요.
  • HttpClient에 대한 SendAsync 확장 메서드는 HttpRequestMessage를 작성하고 SendAsync(HttpRequestMessage)를 호출하여 요청을 SUT에 제출합니다. SendAsync에 대한 오버로드는 HTML 양식(IHtmlFormElement) 및 다음을 허용합니다.
    • 양식(IHtmlElement)의 제출 단추
    • 양식 값 컬렉션(IEnumerable<KeyValuePair<string, string>>)
    • 제출 단추(IHtmlElement) 및 양식 값(IEnumerable<KeyValuePair<string, string>>)

참고

AngleSharp는 이 항목 및 샘플 앱에서 데모용으로 사용되는 타사 구문 분석 라이브러리입니다. AngleSharp는 ASP.NET Core 앱의 통합 테스트에 대해 지원되지 않거나 필요하지 않습니다. HAP(Html Agility Pack)와 같은 기타 파서를 사용할 수 있습니다. 또 다른 방법은 위조 방지 시스템의 요청 확인 토큰과 위조 방지 cookie를 직접 처리하는 코드를 작성하는 것입니다.

참고

EF-Core 메모리 내 데이터베이스 공급자는 제한적이고 기본적인 테스트에 사용할 수 있지만 SQLite 공급자는 메모리 내 테스트에 권장되는 선택입니다.

WithWebHostBuilder를 사용하여 클라이언트 사용자 지정

테스트 메서드 내에서 추가 구성이 필요한 경우 WithWebHostBuilder는 구성에서 추가로 사용자 지정되는 IWebHostBuilder를 사용하여 새 WebApplicationFactory를 만듭니다.

샘플 앱Post_DeleteMessageHandler_ReturnsRedirectToRoot 테스트 메서드는 WithWebHostBuilder의 사용을 보여 줍니다. 이 테스트는 SUT에서 양식 제출을 트리거하여 데이터베이스에서 레코드 삭제를 수행합니다.

IndexPageTests 클래스의 다른 테스트는 데이터베이스의 모든 레코드를 삭제하는 작업을 수행하고 Post_DeleteMessageHandler_ReturnsRedirectToRoot 메서드 이전에 실행될 수 있기 때문에, 이 테스트 메서드에서 데이터베이스가 다시 시드되어 SUT가 삭제할 레코드가 존재하게 됩니다. SUT에서 messages 양식의 첫 번째 삭제 단추를 선택하면 SUT에 대한 요청에서 시뮬레이트됩니다.

[Fact]
public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                var serviceProvider = services.BuildServiceProvider();

                using (var scope = serviceProvider.CreateScope())
                {
                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices
                        .GetRequiredService<ApplicationDbContext>();
                    var logger = scopedServices
                        .GetRequiredService<ILogger<IndexPageTests>>();

                    try
                    {
                        Utilities.ReinitializeDbForTests(db);
                    }
                    catch (Exception ex)
                    {
                        logger.LogError(ex, "An error occurred seeding " +
                            "the database with test messages. Error: {Message}", 
                            ex.Message);
                    }
                }
            });
        })
        .CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    var defaultPage = await client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);

    //Act
    var response = await client.SendAsync(
        (IHtmlFormElement)content.QuerySelector("form[id='messages']"),
        (IHtmlButtonElement)content.QuerySelector("form[id='messages']")
            .QuerySelector("div[class='panel-body']")
            .QuerySelector("button"));

    // Assert
    Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.Equal("/", response.Headers.Location.OriginalString);
}

클라이언트 옵션

다음 표에서는 HttpClient 인스턴스를 만들 때 사용할 수 있는 기본 WebApplicationFactoryClientOptions를 보여 줍니다.

옵션 Description 기본값
AllowAutoRedirect HttpClient 인스턴스가 자동으로 리디렉션 응답을 따르는지 여부를 가져오거나 설정합니다. true
BaseAddress HttpClient 인스턴스의 기준 주소를 가져오거나 설정합니다. http://localhost
HandleCookies HttpClient 인스턴스에서 cookie를 처리할지 여부를 가져오거나 설정합니다. true
MaxAutomaticRedirections HttpClient 인스턴스에서 따라야 하는 리디렉션 응답의 최대 수를 가져오거나 설정합니다. 7

WebApplicationFactoryClientOptions 클래스를 만들고 CreateClient() 메서드에 전달합니다(기본값은 코드 예제에 표시됨).

// Default client option values are shown
var clientOptions = new WebApplicationFactoryClientOptions();
clientOptions.AllowAutoRedirect = true;
clientOptions.BaseAddress = new Uri("http://localhost");
clientOptions.HandleCookies = true;
clientOptions.MaxAutomaticRedirections = 7;

_client = _factory.CreateClient(clientOptions);

모의 서비스 주입

호스트 작성기에서 ConfigureTestServices에 대한 호출을 사용하여 테스트에서 서비스를 재정의할 수 있습니다. 모의 서비스를 주입하려면 SUT에 Startup.ConfigureServices 메서드를 포함하는 Startup 클래스가 있어야 합니다.

샘플 SUT에는 따옴표를 반환하는 범위 지정 서비스가 포함되어 있습니다. 인덱스 페이지를 요청하면 따옴표가 인덱스 페이지의 숨겨진 필드에 포함됩니다.

Services/IQuoteService.cs:

public interface IQuoteService
{
    Task<string> GenerateQuote();
}

Services/QuoteService.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil
// https://www.bbc.co.uk/programmes/p00pyrx6
public class QuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult<string>(
            "Come on, Sarah. We've an appointment in London, " +
            "and we're already 30,000 years late.");
    }
}

Startup.cs:

services.AddScoped<IQuoteService, QuoteService>();

Pages/Index.cshtml.cs:

public class IndexModel : PageModel
{
    private readonly ApplicationDbContext _db;
    private readonly IQuoteService _quoteService;

    public IndexModel(ApplicationDbContext db, IQuoteService quoteService)
    {
        _db = db;
        _quoteService = quoteService;
    }

    [BindProperty]
    public Message Message { get; set; }

    public IList<Message> Messages { get; private set; }

    [TempData]
    public string MessageAnalysisResult { get; set; }

    public string Quote { get; private set; }

    public async Task OnGetAsync()
    {
        Messages = await _db.GetMessagesAsync();

        Quote = await _quoteService.GenerateQuote();
    }

Pages/Index.cs:

<input id="quote" type="hidden" value="@Model.Quote">

다음 태그는 SUT 앱이 실행될 때 생성됩니다.

<input id="quote" type="hidden" value="Come on, Sarah. We&#x27;ve an appointment in 
    London, and we&#x27;re already 30,000 years late.">

통합 테스트에서 서비스 및 따옴표 주입을 테스트하기 위해, 테스트를 통해 모의 서비스가 SUT에 주입됩니다. 모의 서비스는 앱의 QuoteServiceTestQuoteService이라는 테스트 앱에서 제공하는 서비스로 바꿉니다.

IntegrationTests.IndexPageTests.cs:

// Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars
// https://www.bbc.co.uk/programmes/p00pys55
public class TestQuoteService : IQuoteService
{
    public Task<string> GenerateQuote()
    {
        return Task.FromResult<string>(
            "Something's interfering with time, Mr. Scarman, " +
            "and time is my business.");
    }
}

ConfigureTestServices가 호출되고 범위 지정 서비스가 등록됩니다.

[Fact]
public async Task Get_QuoteService_ProvidesQuoteInPage()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddScoped<IQuoteService, TestQuoteService>();
            });
        })
        .CreateClient();

    //Act
    var defaultPage = await client.GetAsync("/");
    var content = await HtmlHelpers.GetDocumentAsync(defaultPage);
    var quoteElement = content.QuerySelector("#quote");

    // Assert
    Assert.Equal("Something's interfering with time, Mr. Scarman, " +
        "and time is my business.", quoteElement.Attributes["value"].Value);
}

테스트 실행 중에 생성된 태그는 TestQuoteService에서 제공하는 따옴표 텍스트를 반영하므로 어설션은 성공합니다.

<input id="quote" type="hidden" value="Something&#x27;s interfering with time, 
    Mr. Scarman, and time is my business.">

모의 인증

AuthTests 클래스의 테스트는 보안 엔드포인트가 다음을 수행하는지 확인합니다.

  • 인증되지 않은 사용자를 앱의 로그인 페이지로 리디렉션합니다.
  • 인증된 사용자에 대한 콘텐츠를 반환합니다.

SUT에서 /SecurePage 페이지는 AuthorizePage 규칙을 사용하여 페이지에 AuthorizeFilter를 적용합니다. 자세한 내용은 Razor Pages 권한 부여 규칙을 참조하세요.

services.AddRazorPages(options =>
{
    options.Conventions.AuthorizePage("/SecurePage");
});

Get_SecurePageRedirectsAnUnauthenticatedUser 테스트에서 WebApplicationFactoryClientOptionsAllowAutoRedirectfalse로 설정하여 리디렉션을 허용하지 않도록 설정됩니다.

[Fact]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
    // Arrange
    var client = _factory.CreateClient(
        new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });

    // Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.StartsWith("http://localhost/Identity/Account/Login", 
        response.Headers.Location.OriginalString);
}

클라이언트에서 리디렉션을 따르도록 허용하지 않으면 다음 검사를 수행할 수 있습니다.

  • SUT에서 반환된 상태 코드는 로그인 페이지로 리디렉션된 후의 최종 상태 코드(HttpStatusCode.OK)가 아니라 예상된 HttpStatusCode.Redirect 결과에 따라 확인할 수 있습니다.
  • 응답 헤더의 Location 헤더 값을 검토하여 Location 헤더가 없는 마지막 로그인 페이지 응답이 아니라 http://localhost/Identity/Account/Login으로 시작하는지 확인합니다.

테스트 앱은 인증과 권한 부여 측면을 테스트하기 위해 ConfigureTestServices에서 AuthenticationHandler<TOptions>를 모의로 시험할 수 있습니다. 최소 시나리오는 AuthenticateResult.Success를 반환합니다.

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, 
        ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] { new Claim(ClaimTypes.Name, "Test user") };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

        var result = AuthenticateResult.Success(ticket);

        return Task.FromResult(result);
    }
}

TestAuthHandler는 인증 체계가 Test로 설정된 경우 사용자를 인증하도록 호출됩니다. 여기서는 AddAuthenticationConfigureTestServices에 등록되어 있습니다. Test 체계가 앱에 필요한 체계와 일치하는 것이 중요합니다. 그렇지 않으면 인증이 작동하지 않습니다.

[Fact]
public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser()
{
    // Arrange
    var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddAuthentication("Test")
                    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                        "Test", options => {});
            });
        })
        .CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
        });

    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Test");

    //Act
    var response = await client.GetAsync("/SecurePage");

    // Assert
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

WebApplicationFactoryClientOptions에 대한 자세한 내용은 클라이언트 옵션 섹션을 참조하세요.

환경 설정

기본적으로 SUT의 호스트 및 앱 환경은 개발 환경을 사용하도록 구성됩니다. IHostBuilder를 사용하여 SUT의 환경을 재정의하는 방법은 다음과 같습니다.

  • ASPNETCORE_ENVIRONMENT 환경 변수(예: Staging, Production 또는 Testing과 같은 기타 사용자 지정 값)를 설정합니다.
  • ASPNETCORE를 접두사로 사용하는 환경 변수를 읽도록 테스트 앱의 CreateHostBuilder를 재정의합니다.
protected override IHostBuilder CreateHostBuilder() =>
    base.CreateHostBuilder()
        .ConfigureHostConfiguration(
            config => config.AddEnvironmentVariables("ASPNETCORE"));

SUT에서 웹 호스트(IWebHostBuilder)를 사용하는 경우 CreateWebHostBuilder를 재정의합니다.

protected override IWebHostBuilder CreateWebHostBuilder() =>
    base.CreateWebHostBuilder().UseEnvironment("Testing");

테스트 인프라가 앱 콘텐츠 루트 경로를 유추하는 방법

WebApplicationFactory 생성자는 TEntryPoint 어셈블리 System.Reflection.Assembly.FullName과 같은 키가 있는 통합 테스트를 포함하는 어셈블리에서 WebApplicationFactoryContentRootAttribute를 검색하여 앱 콘텐츠 루트 경로를 유추합니다. 올바른 키를 포함하는 특성을 찾을 수 없는 경우 WebApplicationFactory는 솔루션 파일( .sln) 검색으로 대체되고 TEntryPoint 어셈블리 이름을 솔루션 디렉터리에 추가합니다. 앱 루트 디렉터리(콘텐츠 루트 경로)는 뷰와 콘텐츠 파일을 검색하는 데 사용됩니다.

섀도 복사 사용 안 함

섀도 복사를 수행하면 테스트가 출력 디렉터리와는 다른 디렉터리에서 실행됩니다. 테스트에서 Assembly.Location을 기준으로 한 파일 로드를 사용하는 경우 문제가 발생하면 섀도 복사를 사용하지 않도록 설정해야 할 수 있습니다.

XUnit을 사용하는 경우 섀도 복사를 사용하지 않도록 설정하려면 올바른 구성 설정을 사용하여 테스트 프로젝트 디렉터리에 xunit.runner.json 파일을 만듭니다.

{
  "shadowCopy": false
}

개체 삭제

IClassFixture 구현 테스트를 실행한 후 TestServerHttpClient는 xUnit이 WebApplicationFactory를 삭제하면 삭제됩니다. 개발자가 인스턴스화한 개체를 삭제해야 하는 경우 IClassFixture 구현에서 삭제합니다. 자세한 내용은 Dispose 메서드 구현을 참조하세요.

통합 테스트 샘플

샘플 앱은 다음 두 앱으로 구성됩니다.

프로젝트 디렉터리 설명
메시지 앱(SUT) src/RazorPagesProject 사용자가 메시지를 추가, 메시지 1개 또는 모든 메시지를 삭제, 메시지를 분석할 수 있도록 허용합니다.
앱 테스트 tests/RazorPagesProject.Tests SUT에 대해 통합 테스트를 수행하는 데 사용됩니다.

Visual Studio와 같은 IDE의 기본 제공 테스트 기능을 사용하여 테스트를 실행할 수 있습니다. Visual Studio Code 또는 명령줄을 사용하는 경우 tests/RazorPagesProject.Tests 디렉터리의 명령 프롬프트에서 다음 명령을 실행합니다.

dotnet test

메시지 앱(SUT) 조직

SUT는 다음과 같은 특징을 가진 Razor Pages 메시지 시스템입니다.

  • 앱의 인덱스 페이지(Pages/Index.cshtmlPages/Index.cshtml.cs)는 메시지의 추가, 삭제 및 분석을 제어하기 위한 UI 및 페이지 모델 메서드를 제공합니다(메시지당 평균 단어 수).
  • 메시지는 Id(키) 및 Text(메시지)의 두 가지 속성을 사용하여 Message 클래스(Data/Message.cs)에서 설명됩니다. Text 속성은 필수이며 200자로 제한됩니다.
  • 메시지는 Entity Framework의 메모리 내 데이터베이스를 사용하여 저장됩니다†.
  • 앱은 데이터베이스 컨텍스트 클래스 AppDbContext(Data/AppDbContext.cs)에 DAL(데이터 액세스 계층)을 포함합니다.
  • 앱 시작 시 데이터베이스가 비어 있는 경우 메시지 저장소는 세 개의 메시지로 초기화됩니다.
  • 앱에는 인증된 사용자만 액세스할 수 있는 /SecurePage가 포함되어 있습니다.

†EF 항목 InMemory로 테스트에서는 MSTest를 사용하여 테스트에 메모리 내 데이터베이스를 사용하는 방법을 설명합니다. 이 항목에서는 xUnit 테스트 프레임워크를 사용합니다. 여러 테스트 프레임워크의 테스트 개념 및 테스트 구현은 비슷하지만 동일하지는 않습니다.

앱은 리포지토리 패턴을 사용하지 않고 UoW(작업 단위) 패턴의 효과적인 예가 아니지만 Razor Pages는 이러한 개발 패턴을 지원합니다. 자세한 내용은 인프라 지속성 계층 디자인테스트 컨트롤러 논리(샘플에서 리포지토리 패턴 구현)를 참조하세요.

테스트 앱 구성

테스트 앱은 tests/RazorPagesProject.Tests 디렉터리에 있는 콘솔 앱입니다.

테스트 앱 디렉터리 설명
AuthTests 다음에 대한 테스트 메서드를 포함합니다.
  • 인증되지 않은 사용자가 보안 페이지에 액세스
  • 모의 AuthenticationHandler<TOptions>를 사용하여 인증된 사용자가 보안 페이지에 액세스
  • GitHub 사용자 프로필을 가져오고 프로필의 사용자 로그인 확인
BasicTests 라우팅 및 콘텐츠 형식에 대한 테스트 메서드를 포함합니다.
IntegrationTests 사용자 지정 WebApplicationFactory 클래스를 사용하여 인덱스 페이지에 대한 통합 테스트를 포함합니다.
Helpers/Utilities
  • Utilities.cs에는 테스트 데이터로 데이터베이스를 시드하는 데 사용되는 InitializeDbForTests 메서드가 포함되어 있습니다.
  • HtmlHelpers.cs는 테스트 메서드에서 사용할 AngleSharp IHtmlDocument를 반환하기 위한 메서드를 제공합니다.
  • HttpClientExtensions.csSendAsync에 대한 오버로드를 제공하여 SUT에 요청을 제출합니다.

테스트 프레임워크는 xUnit입니다. 통합 테스트는 TestServer를 포함하는 Microsoft.AspNetCore.TestHost를 사용하여 수행됩니다. Microsoft.AspNetCore.Mvc.Testing 패키지를 사용하여 테스트 호스트와 테스트 서버를 구성하기 때문에 TestHostTestServer 패키지에는 테스트 앱의 프로젝트 파일 또는 테스트 앱의 개발자 구성에서 직접 패키지 참조가 필요하지 않습니다.

통합 테스트에서는 일반적으로 테스트 실행 전에 데이터베이스에 작은 데이터 세트가 필요합니다. 예를 들어, 삭제 테스트는 데이터베이스 레코드 삭제를 요구하므로 삭제 요청이 성공하려면 데이터베이스에 적어도 하나 이상의 레코드가 있어야 합니다.

샘플 앱은 테스트가 실행될 때 사용할 수 있는 Utilities.cs의 세 가지 메시지로 데이터베이스를 시드합니다.

public static void InitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.AddRange(GetSeedingMessages());
    db.SaveChanges();
}

public static void ReinitializeDbForTests(ApplicationDbContext db)
{
    db.Messages.RemoveRange(db.Messages);
    InitializeDbForTests(db);
}

public static List<Message> GetSeedingMessages()
{
    return new List<Message>()
    {
        new Message(){ Text = "TEST RECORD: You're standing on my scarf." },
        new Message(){ Text = "TEST RECORD: Would you like a jelly baby?" },
        new Message(){ Text = "TEST RECORD: To the rational mind, " +
            "nothing is inexplicable; only unexplained." }
    };
}

SUT의 데이터베이스 컨텍스트는 Startup.ConfigureServices 메서드에 등록되어 있습니다. 테스트 앱의 builder.ConfigureServices 콜백은 앱의 Startup.ConfigureServices 코드가 실행된 에 실행됩니다. 다른 데이터베이스를 테스트에 사용하려면 앱의 데이터베이스 컨텍스트를 builder.ConfigureServices에서 바꾸어야 합니다. 자세한 내용은 WebApplicationFactory 사용자 지정 섹션을 참조하세요.

여전히 웹 호스트를 사용하는 SUT의 경우, SUT의 Startup.ConfigureServices 코드 이전에 테스트 앱의 builder.ConfigureServices 콜백이 실행됩니다. 테스트 앱의 builder.ConfigureTestServices 콜백은 이후에 실행됩니다.

추가 자료