C#웹 API 테스트 방법

33051 단어 testdotnetcsharpxunit
트위터에서 저를 팔로우해 주세요. 구독Newsletter | 최초로 timdeschryver.dev에 발표되었습니다.
만약 네가 이미 나의 다른 블로그 글을 읽은 적이 있다면, 너는 내가 단원 테스트를 그리 좋아하지 않는다는 것을 알 수 있을 것이다.
물론 그들만의 목적이 있지만, 이것은 통상적으로 테스트 시스템의 한 부분 또는 여러 부분이 모방되거나 저장되고 있음을 의미한다.
나는 이런 방법을 그다지 좋아하지 않는다.
나의 코드에 대해 충분한 자신감을 가지기 위해서 나는 통합 테스트를 진행할 것이다.
통합 테스트를 통해 API 클라이언트를 회전하고 실제 HTTP 요청을 실행하여 외부에서 API를 테스트합니다.
나는 가능한 한 적게 모방하고, 응용 프로그램 (또는 사용자) 처럼 API를 사용할 수 있다는 자신감을 얻었다.
코드 보여줘!

The following tests are written in .NET Core 3 and are using XUnit as test the runner.
The setup might change with other versions and test runners but the idea remains the same.


간단한 테스트


유일한 요구 사항은 Microsoft.AspNetCore.Mvc.Testing 패키지가 설치되어 있다는 것입니다. 다음 명령을 사용하면 이 작업을 수행할 수 있습니다.
나는 가방에 유용한 실용적인 방법이 포함되어 있고 읽기 쉽기 때문에 FluentAssertions를 사용하여 단언을 작성했다.
dotnet add package Microsoft.AspNetCore.Mvc.Testing
이러한 패키지에는 메모리에서 API를 부팅하는 데 사용되는 WebApplicationFactory<TEntryPoint> 클래스가 포함되어 있습니다.
이러한 테스트를 실행하기 전에 API를 실행할 필요가 없기 때문에 매우 편리합니다.
테스트 클래스에서 우리는 공장에 구조 함수를 주입할 것이다.
이 공장이 있으면, 우리는 HttpClient 을 만들 수 있으며, 이것은 테스트에서 HTTP 요청을 보내는 데 사용될 것이다.
public class WeatherForecastControllerTests: IClassFixture<WebApplicationFactory<Api.Startup>>
{
    public HttpClient Client { get; }

    public WeatherForecastControllerTests(WebApplicationFactory<Api.Startup> fixture)
    {
        Client = fixture.CreateClient();
    }
}
테스트 클래스는 XUnitIClassFixture 인터페이스를 통해 이루어지기 때문에 이 클래스의 테스트는 하나의 테스트 상하문을 공유할 것이다.모든 테스트의 경우 API는 한 번만 부트되고 나중에 정리됩니다.
이것이 바로 우리가 첫 번째 테스트를 작성하는 데 필요한 모든 것이다.HttpClient를 사용하면 GET 요청을 보내고 응답이 되돌아온다고 단언할 수 있습니다.
public class WeatherForecastControllerTests: IClassFixture<WebApplicationFactory<Api.Startup>>
{
    readonly HttpClient _client { get; }

    public WeatherForecastControllerTests(WebApplicationFactory<Api.Startup> fixture)
    {
        _client = fixture.CreateClient();
    }

    [Fact]
    public async Task Get_Should_Retrieve_Forecast()
    {
        var response = await _client.GetAsync("/weatherforecast");
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var forecast = JsonConvert.DeserializeObject<WeatherForecast[]>(await response.Content.ReadAsStringAsync());
        forecast.Should().HaveCount(5);
    }
}
이것은 얼마나 깔끔한가!진정한 가치를 제공하는 테스트를 작성하기 위해서, 우리는 (거의) 설정하지 않았다.

자체 WebApplicationFactory 작성


유감스럽게도 실제 응용에서는 상황이 더욱 복잡해졌다.
외부의 의존이 있을 수 있다. 이런 것들은 여전히 비웃음을 당하거나 근원을 보존해야 한다.
나는 네가 제어하는 의존 관계의 실제 실례, 예를 들어 데이터베이스를 계속 사용할 것을 건의한다.
그러나 닿을 수 없는 의존항은 주로 제3자가 구동하는 포트에 대해 저는 메모리 실례를 사용하거나 아날로그 실례를 만들 것입니다.

explains why you should avoid in-memory databases for your tests in his recent blog post "Avoid In-Memory Databases for Tests"


다행히도, 덮어쓰기 서비스의 실례는 매우 간단하다.
사용자 정의WebApplicationFactory를 작성하여 API를 구축하기 전에 구성을 변경할 수 있습니다.
이를 위해 ConfigureWebHost 방법을 덮어쓰십시오.
public class ApiWebApplicationFactory : WebApplicationFactory<Api.Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // will be called after the `ConfigureServices` from the Startup
        builder.ConfigureTestServices(services =>
        {
            services.AddTransient<IWeatherForecastConfigService, WeatherForecastConfigStub>();
        });
    }
}

public class WeatherForecastConfigStub : IWeatherForecastConfigService
{
    public int NumberOfDays() => 7;
}
진정한 데이터베이스를 사용하기 위해서, 나는 이러한 테스트를 실행하기 위해 단독 데이터베이스를 만드는 것이 더욱 쉽다는 것을 발견했다.
따라서 통합 테스트 설정이 필요하다.
이 설정들은 통합 테스트 데이터베이스를 가리키는 새로운 연결 문자열을 포함합니다.
더 복잡한 장면에서도 같은 설정을 환경 변수를 덮어쓰는 데 사용할 수 있다.
응용 프로그램을 설정하려면 ConfigureAppConfiguration 방법으로 설정 설정을 추가할 수 있습니다.
public class ApiWebApplicationFactory : WebApplicationFactory<Api.Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration(config =>
        {
            var integrationConfig = new ConfigurationBuilder()
              .AddJsonFile("integrationsettings.json")
              .Build();

            config.AddConfiguration(integrationConfig);
        });

        builder.ConfigureTestServices(services =>
        {
            services.AddTransient<IWeatherForecastConfigService, WeatherForecastConfigStub>();
        });
    }
}
{
  "ConnectionStrings": {
    "SQL": "Data Source=tcp:localhost,1533;Initial Catalog=Local_zebra_e2e;User Id=sa;Password=tk837HL_Zebra;MultipleActiveResultSets=True"
  }
}

사용자정의 및 재사용 가능한 고정장치


내가 좋아하는 것은 모든 테스트가 서로 독립하도록 하는 것이다.
이렇게 하면 테스트가 서로 방해가 되지 않고 모든 테스트가 스스로 작성하거나 디버깅할 수 있다는 장점이 있다.
이를 위해, 우리는 매번 테스트가 실행되기 전에 데이터베이스에 피드를 다시 설정해야 한다.

To reseed my databases I'm using the Respawn package


일을 시원시원하게 하고 이런 논리를 숨기기 위해 그 중 하나는 추상층을 만드는 것이다.
추상 클래스 IntegrationTest 를 사용하면 자주 사용하는 변수를 공개할 수 있습니다. 가장 중요한 변수는 HttpClient 입니다. HTTP 요청을 만드는 데 필요하기 때문입니다.
public class abstract IntegrationTest : IClassFixture<ApiWebApplicationFactory>
{
    private readonly Checkpoint _checkpoint = new Checkpoint
    {
        SchemasToInclude = new[] {
            "Playground"
        },
        WithReseed = true
    };

    protected readonly ApiWebApplicationFactory _factory;
    protected readonly HttpClient _client;
    protected readonly IConfiguration _configuration;

    public IntegrationTest(ApiWebApplicationFactory fixture)
    {
        _factory = fixture;
        _client = _factory.CreateClient();
        _configuration = new ConfigurationBuilder()
              .AddJsonFile("integrationsettings.json")
              .Build();

        _checkpoint.Reset(_configuration.GetConnectionString("SQL")).Wait();
    }
}
테스트 클래스는 현재 IntegrationTestfixture에서 계승할 수 있습니다. 아래와 같습니다.
public class WeatherForecastControllerTests: Fixtures.IntegrationTest
{
    public WeatherForecastControllerTests(ApiWebApplicationFactory fixture)
      : base(fixture) {}

    [Fact]
    public async Task Get_Should_Return_Forecast()
    {
        var response = await _client.GetAsync("/weatherforecast");
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var forecast = JsonConvert.DeserializeObject<WeatherForecast[]>(
          await response.Content.ReadAsStringAsync()
        );
        forecast.Should().HaveCount(7);
    }
}
위의 코드에서 보듯이 IntegrationTest 추상적이기 때문에 테스트 클래스는 설정 논리를 포함하지 않습니다.

WebHostBuilder 사용


테스트 클러치의 지수 증가를 방지하기 위해서 우리는 WithWebHostBuilder에서 WebApplicationFactory 방법을 사용할 수 있다.이것은 서로 다른 특정 설정이 필요한 테스트에 매우 도움이 된다.WithWebHostBuilder 방법은 WebApplicationFactory의 새로운 실례를 만들 것이다.
사용자 정의 WebApplicationFactory 클래스 (이 예에서 ApiWebApplicationFactory 를 사용하면 ConfigureWebHost 의 논리를 실행합니다.
다음 코드에서, 우리는 InvalidWeatherForecastConfigStub 클래스를 사용하여 잘못된 설정을 위조합니다. 이것은 잘못된 요청을 초래할 것입니다.이 설정은 한 번만 필요하기 때문에 테스트 내부에서 설정할 수 있습니다.
[Fact]
public async Task Get_Should_ResultInABadRequest_When_ConfigIsInvalid()
{
    var client = _factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureServices(services =>
        {
            services.AddTransient<IWeatherForecastConfigService, InvalidWeatherForecastConfigStub>();
        });
    })
    .CreateClient(new WebApplicationFactoryClientOptions());

    var response = await client.GetAsync("/weatherforecast");
    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

    var forecast = JsonConvert.DeserializeObject<WeatherForecast[]>(
      await response.Content.ReadAsStringAsync()
    );
    forecast.Should().HaveCount(1);
}

유틸리티


기본 끝점 지금 테스트


같은 설정이 필요한 테스트에 대해 우리는 TheoryInlineData를 작성하여 여러 단점을 동시에 테스트할 수 있다.
이 기술은 간단한 검색에만 적용되며, 이 단점들이 실패했는지 확인하는 빠른 방법입니다.
[Theory]
[InlineData("/endoint1")]
[InlineData("/endoint2/details")]
[InlineData("/endoint3?amount=10&page=1")]
public async Task Smoketest_Should_ResultInOK(string endpoint)
{
    var response = await _client.GetAsync(endpoint);
    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

인증된 엔드포인트 테스트


테스트가 반드시 신분 검증을 거쳐야 하는 단점에 대해 우리는 몇 가지 옵션이 있다.

이방차 필터


가장 간단한 방법은 익명 요청만 허용하는 것이다. 이것은 추가 AllowAnonymousFilter 를 통해 실현할 수 있다.
builder.ConfigureTestServices(services =>
{
    MvcServiceCollectionExtensions.AddMvc(services, options => options.Filters.Add(new AllowAnonymousFilter()));
});

AuthenticationHandler


두 번째 옵션은 사용자 정의 인증 프로그램을 만드는 것입니다.

In a GitHub issue you can find multiple solutions to implement this.


인증 처리 프로그램은 인증된 사용자를 표시하는 성명을 만들 것입니다.
public class IntegrationTestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public IntegrationTestAuthenticationHandler(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, "IntegrationTest User"),
            new Claim(ClaimTypes.NameIdentifier, "IntegrationTest User"),
        };
        var identity = new ClaimsIdentity(claims, "IntegrationTest");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "IntegrationTest");
        var result = AuthenticateResult.Success(ticket);
        return Task.FromResult(result);
    }
}
인증 프로세서를 추가해서 프로그램을 설정해야 합니다.
인증된 요청을 만들려면 Authorization 헤더를 요청에 추가해야 합니다.
builder.ConfigureTestServices(services =>
{
    services.AddAuthentication("IntegrationTest")
        .AddScheme<AuthenticationSchemeOptions, AuthenticationHandler>(
          "IntegrationTest",
          options => { }
        );
});

...

_client = _factory.CreateClient();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("IntegrationTest");

진정한 토큰을 사용하다


마지막 선택은 진정한 기호화폐를 사용하는 것이다.
이것은 또한 테스트가 실행되기 전에 영패를 만들어야 한다는 것을 의미한다.
일단 영패가 생성되면 그것을 저장할 수 있다. 그러면 모든 테스트에 영패를 생성할 필요가 없고 테스트의 실행 속도를 낮출 수 있다.또한 우리는 이 통합 테스트에서 신분 검증을 테스트하지 않았다.
이전과 마찬가지로, 우리는 반드시 영패를 요청 헤더에 추가해야 하지만, 우리도 영패를 요청 헤더에 분배해야 한다.
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", GetToken());

public static string GetToken()
{
    if (accessToken != null)
    {
        return accessToken;
    }

    // actual logic of generating a token
    return accessToken;
}

결론


이전에 나는 C#API에 대한 테스트를 작성하는 것을 좋아하지 않았다.
그러나 지금 나는 기능 테스트를 발견했고, 나는 그것들을 쓰는 것을 좋아한다.
설정이 거의 필요 없기 때문에 테스트를 작성하는 데 걸리는 시간이 절반으로 줄었다.
그 전의 대부분의 시간(적어도 나에게)은 실제 테스트 자체가 아니라 테스트의 설정에 썼다.
글쓰기에 쓰이는 시간은 사람들로 하여금 더욱 쓸 만하다고 느끼게 한다.
만약 네가 재구성 이론을 따른다면, 너는 너의 테스트를 바꾸어서는 안 된다.
실천에서 우리는 이것이 항상 정확하지 않다는 것을 간신히 발견했다.
따라서 이는 통상적으로 오류로 돌아가는 것을 의미한다.
통합 테스트는 세부 사항에 관심이 없기 때문에, 이전에 작성한 테스트를 재구성하거나 다시 쓸 필요가 없다는 것을 의미할 것입니다.
코드 라이브러리의 관리자로서, 우리가 코드를 변경, 이동, 삭제할 때, 이것은 우리에게 더욱 많은 자신감을 가져다 줄 것이다.
테스트 자체는 시간의 추이에 따라 거의 바뀌지 않기 때문에 이러한 테스트를 유지하는 데 걸리는 시간도 줄어든다.
이것은 내가 단원 테스트를 쓰지 않는다는 것을 의미하는 것입니까?
아니오, 없습니다. 하지만 그것들은 많이 쓰지 않습니다.
의존 관계가 필요 없는 진실한 업무 논리에만 적용되며 결과를 입력하고 출력하면 된다.
이런 통합 테스트의 운행 속도는 비교적 느릴 수 있지만, 내가 보기에는 가치가 있다.
왜?그들이 나에게 더 많은 자신감을 주었기 때문에, 나는 우리가 발표한 코드가 실제로 작동하고 있으며, 예상한 작업 방식에 따라 작동하고 있다고 믿게 되었다.
우리는 프로그램의 일부분을 시뮬레이션하거나 캡처하는 것이 아니라 전체 프로그램을 테스트하는 것이다.
기계의 속도가 높아짐에 따라 다른 테스트와 집적 테스트 사이에는 큰 차이가 없을 것이다.
몇 년 전에는 이런 시차가 더 컸는데, 이것은 통상적으로 작성된 통합 테스트가 더 적거나 없다는 것을 의미했다.
바꿀 때가 됐어, 나한테 물어보면!

추가 리소스

  • The official docs about integration tests

  • Easier functional and integration testing of ASP.NET Core applications

  • Avoid In-Memory Databases for Tests
  • 트위터에서 저를 팔로우해 주세요. 구독Newsletter | 최초로 timdeschryver.dev에 발표되었습니다.

    좋은 웹페이지 즐겨찾기