xUnit을 사용하여 .NET에서 기능 테스트 구현

이 기사에서는 xUnit을 사용하여 .NET API에서 기능 테스트를 구현하는 방법을 배울 것입니다.

전제 조건:
  • .NET 6 SDK가 포함된 Visual Studio 2022
  • here에서 프로젝트 다운로드

  • 테스트된 프로젝트는 종종 테스트 중인 시스템 또는 줄여서 "SUT"라고 합니다.

    기능 테스트는 일반적인 Arrange, Act 및 Assert 테스트 단계를 포함하는 일련의 이벤트를 따릅니다.
  • SUT의 웹 호스트가 구성되었습니다.
  • 앱에 요청을 제출하기 위해 테스트 서버 클라이언트가 생성됩니다.
  • 테스트 정렬 단계가 실행됩니다. 테스트 앱이 요청을 준비합니다.
  • Act 테스트 단계가 실행됩니다. 클라이언트가 요청을 제출하고 응답을 받습니다.
  • Assert 테스트 단계가 실행됩니다. 실제 응답은 예상 응답을 기반으로 통과 또는 실패로 검증됩니다.
  • 모든 테스트가 실행될 때까지 프로세스가 계속됩니다.
  • 테스트 결과가 보고됩니다.

  • 개발을 시작해 봅시다.

    다음과 같은 구조를 가져야 합니다.


    테스트 폴더에서 마우스 오른쪽 버튼을 클릭합니다. Add/New Project .../Store.FunctionalTests라는 xUnit Test 프로젝트를 추가하고 대상 프레임워크로 .NET 6을 선택합니다.


    Store.SharedDatabaseSetupStore.WebApi 프로젝트에 대한 참조를 추가합니다.

    다음 패키지를 설치합니다.
  • Microsoft.AspNetCore.Mvc.Testing
  • Microsoft.EntityFrameworkCore.InMemory

  • ASP.NET Core 6은 Startup 클래스에 대한 필요성을 제거한 WebApplication을 도입했습니다. WebApplicationFactory 클래스 없이 Startup로 테스트하려면 ASP.NET Core 6 앱이 암시적으로 정의된 Program 클래스를 노출해야 합니다. 따라서 Store.WebApi 프로젝트에서 부분 클래스 선언을 사용하여 Program 클래스를 공개해야 합니다.

    ...
    public partial class Program { }
    


    웹 애플리케이션을 변경한 후 테스트 프로젝트는 이제 Program 에 대해 WebApplicationFactory 클래스를 사용할 수 있습니다.
    WebApplicationFactory<TEntryPoint>는 기능 테스트를 위한 TestServer를 만드는 데 사용됩니다. TEntryPoint는 SUT의 진입점 클래스이며 일반적으로 Startup 클래스입니다.

    Store.FunctionalTests 프로젝트에서 CustomWebApplicationFactory 클래스를 생성합니다.

    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.Mvc.Testing;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Logging;
    using Store.Infrastructure.Persistence.Contexts;
    using Store.SharedDatabaseSetup;
    using System;
    using System.Linq;
    
    namespace Store.FunctionalTests
    {
        public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
        {
    
            protected override void ConfigureWebHost(IWebHostBuilder builder)
            {
                builder.ConfigureServices(services =>
                {
                    // Remove the app's StoreContext registration.
                    var descriptor = services.SingleOrDefault(
                        d => d.ServiceType ==
                            typeof(DbContextOptions<StoreContext>));
    
                    if (descriptor != null)
                    {
                        services.Remove(descriptor);
                    }
    
                    // Add StoreContext using an in-memory database for testing.
                    services.AddDbContext<StoreContext>(options =>
                    {
                        options.UseInMemoryDatabase("InMemoryDbForFunctionalTesting");
                    });
    
                    // Get service provider.
                    var serviceProvider = services.BuildServiceProvider();
    
                    using (var scope = serviceProvider.CreateScope())
                    {
                        var scopedServices = scope.ServiceProvider;
    
                        var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
    
                        var storeDbContext = scopedServices.GetRequiredService<StoreContext>();
                        storeDbContext.Database.EnsureCreated();
    
                        try
                        {
                            DatabaseSetup.SeedData(storeDbContext);
                        }
                        catch (Exception ex)
                        {
                            logger.LogError(ex, $"An error occurred seeding the Store database with test messages. Error: {ex.Message}");
                        }
                    }
                });
            }
    
            public void CustomConfigureServices(IWebHostBuilder builder)
            {
                builder.ConfigureServices(services =>
                {
                    // Get service provider.
                    var serviceProvider = services.BuildServiceProvider();
    
                    using (var scope = serviceProvider.CreateScope())
                    {
                        var scopedServices = scope.ServiceProvider;
    
                        var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
    
                        var storeDbContext = scopedServices.GetRequiredService<StoreContext>();
    
                        try
                        {
                            DatabaseSetup.SeedData(storeDbContext);
                        }
                        catch (Exception ex)
                        {
                            logger.LogError(ex, $"An error occurred seeding the Store database with test messages. Error: {ex.Message}");
                        }
                    }
                });
            }
        }
    }
    

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

    SUT의 데이터베이스 컨텍스트는 Startup.ConfigureServices 메서드에 등록됩니다. 테스트 앱의 builder.ConfigureServices 콜백은 앱의 Startup.ConfigureServices 코드가 실행된 후 실행됩니다.

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

    테스트 클래스는 클래스 고정 인터페이스(IClassFixture)를 구현하여 클래스에 테스트가 포함되어 있음을 표시하고 클래스의 테스트 전반에 걸쳐 공유 객체 인스턴스를 제공합니다.

    Controller 폴더를 만들고 이 안에 BaseControllerTests 클래스를 만듭니다.

    using System.Net.Http;
    using Xunit;
    
    namespace Store.FunctionalTests.Controllers
    {
        public class BaseControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
        {
            private readonly CustomWebApplicationFactory<Program> _factory;
    
            public BaseControllerTests(CustomWebApplicationFactory<Program> factory)
            {
                _factory = factory;
            }
    
            public HttpClient GetNewClient()
            {
                var newClient = _factory.WithWebHostBuilder(builder =>
                {
                    _factory.CustomConfigureServices(builder);
                }).CreateClient();
    
                return newClient;
            }
        }
    }
    

    CustomConfigureServices 클래스의 CustomWebApplicationFactory 메소드는 WithWebHostBuilder 로 클라이언트를 사용자 정의하기 위해 생성되었습니다. 다른 테스트가 데이터베이스를 변경하는 작업을 수행할 수 있고 다른 테스트보다 먼저 실행될 수 있기 때문입니다.

    ProductsControllerTests 클래스를 만들고 BaseControllerTests에서 확장합니다.

    using Newtonsoft.Json;
    using Store.ApplicationCore.DTOs;
    using Store.FunctionalTests.Models;
    using System.Collections.Generic;
    using System.Linq;
    using System.Net.Http;
    using System.Text;
    using System.Threading.Tasks;
    using Xunit;
    
    namespace Store.FunctionalTests.Controllers
    {
        public class ProductsControllerTests : BaseControllerTests
        {
            public ProductsControllerTests(CustomWebApplicationFactory<Program> factory) : base(factory)
            {
            }
    
            [Fact]
            public async Task GetProducts_ReturnsAllRecords()
            {
                var client = this.GetNewClient();
                var response = await client.GetAsync("/api/Products");
                response.EnsureSuccessStatusCode();
    
                var stringResponse = await response.Content.ReadAsStringAsync();
                var result = JsonConvert.DeserializeObject<IEnumerable<ProductResponse>>(stringResponse).ToList();
                var statusCode = response.StatusCode.ToString();
    
                Assert.Equal("OK", statusCode);
                Assert.True(result.Count == 10);
            }
    
            [Fact]
            public async Task GetProductById_ProductExists_ReturnsCorrectProduct()
            {
                var productId = 5;
                var client = this.GetNewClient();
                var response = await client.GetAsync($"/api/Products/{productId}");
                response.EnsureSuccessStatusCode();
    
                var stringResponse = await response.Content.ReadAsStringAsync();
                var result = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse);
                var statusCode = response.StatusCode.ToString();
    
                Assert.Equal("OK", statusCode);
                Assert.Equal(productId, result.Id);
                Assert.NotNull(result.Name);
                Assert.True(result.Price > 0);
                Assert.True(result.Stock > 0);
            }
    
            [Theory]
            [InlineData(0)]
            [InlineData(20)]
            public async Task GetProductById_ProductDoesntExist_ReturnsNotFound(int productId)
            {
                var client = this.GetNewClient();
                var response = await client.GetAsync($"/api/Products/{productId}");
    
                var statusCode = response.StatusCode.ToString();
    
                Assert.Equal("NotFound", statusCode);
            }
    
            [Fact]
            public async Task PostProduct_ReturnsCreatedProduct()
            {
                var client = this.GetNewClient();
    
                // Create product
    
                var request = new CreateProductRequest
                {
                    Description = "Description",
                    Name = "Test product",
                    Price = 25.3
                };
    
                var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    
                var response1 = await client.PostAsync("/api/Products", stringContent);
                response1.EnsureSuccessStatusCode();
    
                var stringResponse1 = await response1.Content.ReadAsStringAsync();
                var createdProduct = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse1);
                var statusCode1 = response1.StatusCode.ToString();
    
                Assert.Equal("Created", statusCode1);
    
                // Get created product
    
                var response2 = await client.GetAsync($"/api/Products/{createdProduct.Id}");
                response2.EnsureSuccessStatusCode();
    
                var stringResponse2 = await response2.Content.ReadAsStringAsync();
                var result2 = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse2);
                var statusCode2 = response2.StatusCode.ToString();
    
                Assert.Equal("OK", statusCode2);
                Assert.Equal(createdProduct.Id, result2.Id);
                Assert.Equal(createdProduct.Name, result2.Name);
                Assert.Equal(createdProduct.Description, result2.Description);
                Assert.Equal(createdProduct.Stock, result2.Stock);
            }
    
            [Fact]
            public async Task PostProduct_InvalidData_ReturnsErrors()
            {
                var client = this.GetNewClient();
    
                // Create product
    
                var request = new CreateProductRequest
                {
                    Description = "Description",
                    Name = null,
                    Price = 0
                };
    
                var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    
                var response = await client.PostAsync("/api/Products", stringContent);
    
                var stringResponse = await response.Content.ReadAsStringAsync();
                var badRequest = JsonConvert.DeserializeObject<BadRequestModel>(stringResponse);
                var statusCode = response.StatusCode.ToString();
    
                Assert.Equal("BadRequest", statusCode);
                Assert.NotNull(badRequest.Title);
                Assert.NotNull(badRequest.Errors);
                Assert.Equal(2, badRequest.Errors.Count);
                Assert.Contains(badRequest.Errors.Keys, k => k == "Name");
                Assert.Contains(badRequest.Errors.Keys, k => k == "Price");
            }
    
    
            [Fact]
            public async Task PutProduct_ReturnsUpdatedProduct()
            {
                var client = this.GetNewClient();
    
                // Update product
    
                var productId = 6;
                var request = new UpdateProductRequest
                {
                    Description = "Description",
                    Name = "Test product",
                    Price = 17.67,
                    Stock = 94
                };
    
                var stringContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    
                var response1 = await client.PutAsync($"/api/Products/{productId}", stringContent);
                response1.EnsureSuccessStatusCode();
    
                var stringResponse1 = await response1.Content.ReadAsStringAsync();
                var updatedProduct = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse1);
                var statusCode1 = response1.StatusCode.ToString();
    
                Assert.Equal("OK", statusCode1);
    
                // Get updated product
    
                var response2 = await client.GetAsync($"/api/Products/{updatedProduct.Id}");
                response2.EnsureSuccessStatusCode();
    
                var stringResponse2 = await response2.Content.ReadAsStringAsync();
                var result2 = JsonConvert.DeserializeObject<SingleProductResponse>(stringResponse2);
                var statusCode2 = response2.StatusCode.ToString();
    
                Assert.Equal("OK", statusCode2);
                Assert.Equal(updatedProduct.Id, result2.Id);
                Assert.Equal(updatedProduct.Name, result2.Name);
                Assert.Equal(updatedProduct.Description, result2.Description);
                Assert.Equal(updatedProduct.Stock, result2.Stock);
            }
    
            [Fact]
            public async Task DeleteProductById_ReturnsNoContent()
            {
                var client = this.GetNewClient();
                var productId = 5;
    
                // Delete product
    
                var response1 = await client.DeleteAsync($"/api/Products/{productId}");
    
                var statusCode1 = response1.StatusCode.ToString();
    
                Assert.Equal("NoContent", statusCode1);
    
                // Get deleted product
    
                var response2 = await client.GetAsync($"/api/Products/{productId}");
    
                var statusCode2 = response2.StatusCode.ToString();
    
                Assert.Equal("NotFound", statusCode2);
            }
        }
    }
    


    솔루션을 마우스 오른쪽 버튼으로 클릭하고 테스트 실행을 클릭합니다.

    테스트 탐색기에서 볼 수 있듯이 모든 테스트가 통과했습니다.



    소스 코드here를 찾을 수 있습니다.

    읽어 주셔서 감사합니다

    읽어주셔서 대단히 감사합니다. 이 기사가 흥미롭고 앞으로 유용할 수 있기를 바랍니다. 토론해야 할 질문이나 아이디어가 있으면 함께 협력하고 지식을 교환할 수 있어 기쁩니다.

    좋은 웹페이지 즐겨찾기