xunit.net_在ASP.NET Core中使用xUnit进行TDD和异常处理

xunit.net

简介和前提条件 (Introduction and prerequisites)

In this post, we’re continuing our “walking skeleton” application where we build and deploy a minimal application with an ASP.NET Core WebApi and an Angular client. At this stage, the API is almost ready. We’ve got a controller that accepts a city location, a service that calls the third-party OpenWeatherMap API to return forecasts for that location, and in the last post we added the xUnit testing framework to describe the API. If you would like to start from the beginning, this is the first post.

在本文中,我们将继续使用“行走骨架”应用程序,在该应用程序中,我们将使用ASP.NET Core WebApi和Angular客户端来构建和部署最小的应用程序。 在此阶段,API几乎已准备就绪。 我们有一个接受城市位置的控制器,一个调用第三方OpenWeatherMap API的服务以返回该位置的天气预报,在上一篇文章中,我们添加了xUnit测试框架来描述API。 如果您想从头开始, 这是第一篇文章

The goal of this series, and this application, is to create a bare-bones, testable, and deploy-able web application that can be used as a reference for starting similar projects. In each of these steps, I intend to describe the code we add in detail.

本系列以及该应用程序的目标是创建一个简捷,可测试且可部署的Web应用程序,可用作启动类似项目的参考。 在每个步骤中,我打算描述我们详细添加的代码。

If you’re starting the tutorial from this post, you can clone the following branch and continue modifying the code from there (note that you will need the .NET Core SDK installed on your machine):

如果您是从这篇文章开始本教程,则可以克隆以下分支,然后从那里继续修改代码(请注意,您将需要在计算机上安装.NET Core SDK ):

$ git clone -b 3_adding-tests --single-branch git@github.com:jsheridanwells/WeatherWalkingSkeleton.git
$ cd WeatherWalkingSkeleton
$ dotnet restore

You’ll also need to sign up for and register an OpenWeatherMap API key. This previous post contains the steps for doing that.

您还需要注册并注册一个OpenWeatherMap API密钥上一篇文章包含执行此操作的步骤。

我们的TDD流程 (Our TDD process)

In the previous step, we started with one controller method that called one service method, then we set up a testing library using xUnit and Moq. With our testing framework ready to go, we’ll use unit tests to guide some improvements to our API endpoint that fetches weather forecasts.

在上一步中,我们从一个称为一种服务方法的控制器方法开始,然后使用xUnitMoq建立一个测试库。 准备好我们的测试框架后,我们将使用单元测试来指导对获取天气预报的API端点的一些改进。

TDD stands for “Test-Driven Development”, and it’s a technique for ensuring that our code performs according to expectations, documenting the current expectations for code, and using tests to help ensure that changes aren’t breaking prior functionality, especially when deploying code to higher environments. I won’t discuss here the different types of testing, or the differing opinions on testing, but I’ve recently found this article which gives an excellent overview of the different software testing strategies and where they can fit into different kinds of projects.

TDD代表“测试驱动开发”,它是一种确保我们的代码按照预期执行,记录当前对代码的预期并使用测试来确保更改不会破坏先前功能的技术,尤其是在部署代码时到更高的环境。 在这里,我不会讨论不同类型的测试或对测试的不同意见,但是最近我发现这篇文章很好地概述了不同的软件测试策略以及它们可以应用于不同类型的项目中。

Our test-driven development is going to follow a Red, Green, Refactor pattern:

我们的测试驱动开发将遵循RedGreenRefactor模式:

  1. Red: We will write a test and ensure that it fails. That way we’re sure that our changes are actually bringing about the behavior we want, not some unanticipated side effect.

    红色 :我们将编写一个测试并确保它失败。 这样,我们可以确保我们所做的更改实际上带来了我们想要的行为,而不是某些意料之外的副作用。

  2. Green: We will modify our methods so that the tests pass.

    绿色 :我们将修改方法,以便测试通过。

  3. Refactor: We will do any necessary refactoring to our changes so that the code is up to par, while making sure the test still passes.

    重构 :我们将对所做的更改进行任何必要的重构,以确保代码符合标准,同时确保测试仍然可以通过。

我们的变化 (Our changes)

Right now, our API consists of one endpoint — GET http://localhost:5000/WeatherForecast/:location - that terminates at the WeatherForecastController and calls the Get method. Inside the Get method, the OpenWeatherService.GetFiveDayForecastAsync method is then called which returns a list of five forecasts for the next fifteen hours.

现在,我们的API包含一个端点GET http://localhost:5000/WeatherForecast/:location终止于WeatherForecastController并调用Get方法。 在Get方法内部,然后调用OpenWeatherService.GetFiveDayForecastAsync方法,该方法将返回下一个15小时的五个预测的列表。

While manually testing this endpoint with Postman, and running our three current unit tests, proves that this indeed happens, our methods are very brittle right now. If an API consumer calls the endpoint without a location, or with a non-existent location, the unexpected result isn’t handled. If we deploy the API to another environment without registering the OpenWeatherMap API key, we need to handle that failure as well in a way that communicates the problem to other developers. Also, the OpenWeatherMap API might itself fail and we need to be able to communicate the source of the problem. At the moment, if anything unexpected happens a long and unhelpful NullReferenceException is returned from the API.

当使用Postman手动测试此端点并运行我们当前的三个单元测试时,这确实发生了,我们的方法目前非常脆弱。 如果API使用者在没有位置或位置不存在的情况下调用端点,则不会处理意外结果。 如果我们在没有注册OpenWeatherMap API密钥的情况下将API部署到另一个环境,则我们还需要以将该问题传达给其他开发人员的方式来处理该故障。 另外,OpenWeatherMap API本身可能会失败,因此我们需要能够传达问题的根源。 目前,如果发生任何意外情况,则会从API返回长时间且无用的NullReferenceException

Let’s refactor the methods to handle the following scenarios:

让我们重构方法以处理以下情况:

  1. One of our users sends a location that OpenWeatherAPI doesn’t recognize: I would expect this to happen frequently, and it wouldn’t be the result of any fault in the application, so to handle this we’ll send back a helpful message to the user without throwing an exception.

    我们的一位用户发送了OpenWeatherAPI无法识别的位置:我希望这种情况经常发生,并且不是应用程序中任何错误的结果,因此,为了处理此问题,我们将向您发送一条有用的消息至用户而不会引发异常。

  2. The OpenWeatherMap API key is invalid: Right now, the application is running on our local machines with an API key configured. When we deploy to other environments, those servers will also need an API key to run. If the application gets deployed without one, or if the API key expires, we’ll need to make that clear to any developers if OpenWeatherMap returns an unauthorized response.

    OpenWeatherMap API密钥无效:目前,该应用程序在配置了API密钥的本地计算机上运行。 当我们部署到其他环境时,这些服务器还将需要API密钥才能运行。 如果没有一个就部署了该应用程序,或者API密钥已过期,那么如果OpenWeatherMap返回未经授权的响应,我们将需要向所有开发人员明确说明。

  3. OpenWeatherMap returns its own error: Since OpenWeatherMap is a third party, we cannot guarantee that it always functions within our own application as expected. If for some reason, a request to OpenWeatherMap fails, we need to handle that scenario as well.

    OpenWeatherMap返回其自身的错误:由于OpenWeatherMap是第三方,因此我们无法保证它始终能够在我们自己的应用程序中正常运行。 如果由于某种原因,对OpenWeatherMap的请求失败,我们也需要处理该情况。

测试服务 (Testing the service)

We’ll modify the OpenWeatherService class first. Open the corresponding unit test file: ./WeatherWalkingSkeletonTests/Services_Tests/OpenWeatherService_Tests.cs. Note, that in the previous post, we also created a static fixture class called OpenWeatherResponses that returns three simulated error responses from the OpenWeatherMap API: NotFoundResponse, UnauthorizedResponse, InternalErrorResponse. We'll use these responses to trigger the errors we could get from the third-party API.

我们将首先修改OpenWeatherService类。 打开相应的单元测试文件: ./WeatherWalkingSkeletonTests/Services_Tests/OpenWeatherService_Tests.cs 。 请注意,在上一篇文章中,我们还创建了一个名为OpenWeatherResponses的静态装置类,该类从OpenWeatherMap API返回三个模拟的错误响应: NotFoundResponseUnauthorizedResponseInternalErrorResponse 。 我们将使用这些响应来触发可能从第三方API获得的错误。

In OpenWeatherService_Tests add the following tests:

OpenWeatherService_Tests添加以下测试:

[Fact]
public async Task Returns_OpenWeatherException_When_Called_With_Bad_Argument()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse,
HttpStatusCode.NotFound);
var sut = new OpenWeatherService(opts, clientFactory); var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Westeros"));
Assert.Equal(404, (int)result.StatusCode);
}[Fact]
public async Task Returns_OpenWeatherException_When_Unauthorized()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
HttpStatusCode.Unauthorized);
var sut = new OpenWeatherService(opts, clientFactory); var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Chicago"));
Assert.Equal(401, (int)result.StatusCode);
}[Fact]
public async Task Returns_OpenWeatherException_On_OpenWeatherInternalError()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.InternalErrorResponse,
HttpStatusCode.InternalServerError);
var sut = new OpenWeatherService(opts, clientFactory); var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("New York"));
Assert.Equal(500, (int)result.StatusCode);
}

The tests follow the basic setup of the previous two tests, but we’ve configured the different possible error responses from the mock API. When OpenWeatherMap returns an unexpected result, we want our service to throw a custom exception called OpenWeatherException. This exception will communicate to the consuming class that the failure came from the third-party API.

这些测试遵循前两个测试的基本设置,但是我们已经从模拟API配置了不同的可能的错误响应。 当OpenWeatherMap返回意外结果时,我们希望我们的服务抛出一个名为OpenWeatherException的自定义异常。 此异常将通知使用方类该失败来自第三方API。

If you run the test using your IDE’s test runner, or using $ dotnet test in the terminal, we see our tests fail. We expected our custom exception and instead got a NullReferenceException since our service can't yet handle a response that it can't parse.

如果您使用IDE的测试运行程序或在终端中使用$ dotnet test ,我们将看到测试失败。 我们期望我们的自定义异常,而是得到了NullReferenceException因为我们的服务尚无法处理无法解析的响应。

Open ./Api/Services/OpenWeatherService.cs and navigate to the GetFiveDayForecastAsync method. Going through the method line by line, we see the point where the method waits for a response from OpenWeatherMap:

打开./Api/Services/OpenWeatherService.cs并导航到GetFiveDayForecastAsync方法。 逐行浏览方法,我们看到了该方法等待OpenWeatherMap响应的地方:

var response = await client.GetAsync(url);

We’ll check if the response is successful, and if it is then we’ll deserialize the response as we were initially. If it’s any other result, we’ll build and throw an OpenWeatherException so the consuming class can respond accordingly. The if/else block will look like this (I'll copy the entire method further below):

我们将检查响应是否成功,如果响应成功,则将像最初那样反序列化响应。 如果还有其他结果,我们将构建并抛出OpenWeatherException以便使用类可以做出相应的响应。 if / else块将如下所示(我将在下面进一步复制整个方法):

if (response.IsSuccessStatusCode)
{
// deserialize and return an OpenWeatherResponse
var json = await response.Content.ReadAsStringAsync();
var openWeatherResponse = JsonSerializer.Deserialize<OpenWeatherResponse>(json);
foreach (var forecast in openWeatherResponse.Forecasts)
{
forecasts.Add(new WeatherForecast
{
Date = new DateTime(forecast.Dt),
Temp = forecast.Temps.Temp,
FeelsLike = forecast.Temps.FeelsLike,
TempMin = forecast.Temps.TempMin,
TempMax = forecast.Temps.TempMax,
});
} return forecasts;
}
else
{
// build an exception with information from the third-party API
throw new OpenWeatherException(response.StatusCode, "Error response from OpenWeatherApi: " + response.ReasonPhrase);
}

The exception will contain the OpenWeatherMap HTTP status code and a simple message, then a consuming class can create logic based on that information.

异常将包含OpenWeatherMap HTTP状态代码和一条简单消息,然后使用类可以根据该信息创建逻辑。

Below is what the entire GetFiveDayFirecastAsync method should look like:

下面是整个GetFiveDayFirecastAsync方法的外观:

public async Task<List<WeatherForecast>> GetFiveDayForecastAsync(string location, Unit unit = Unit.Metric)
{
string url = BuildOpenWeatherUrl("forecast", location, unit);
var forecasts = new List<WeatherForecast>(); var client = _httpFactory.CreateClient("OpenWeatherClient");
var response = await client.GetAsync(url); if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var openWeatherResponse = JsonSerializer.Deserialize<OpenWeatherResponse>(json);
foreach (var forecast in openWeatherResponse.Forecasts)
{
forecasts.Add(new WeatherForecast
{
Date = new DateTime(forecast.Dt),
Temp = forecast.Temps.Temp,
FeelsLike = forecast.Temps.FeelsLike,
TempMin = forecast.Temps.TempMin,
TempMax = forecast.Temps.TempMax,
});
} return forecasts;
}
else
{
throw new OpenWeatherException(response.StatusCode, "Error response from OpenWeatherApi: " + response.ReasonPhrase);
}
}

Run the tests again and they should all pass. At this point, we’ve done the Red and Green steps of the test process. I’ll leave it up to you to find any opportunities for refactoring this method or letting it go as is.

再次运行测试,它们都应该通过。 至此,我们已经完成了测试过程的红色绿色步骤。 我将由您自己决定是否有任何机会重构此方法或将其保留。

测试控制器 (Testing the controller)

Our service can now graceful indicate if there was a failed response from the OpenWeatherMap API. Now we need our controller communicate these exceptions back to our API’s consuming clients.

现在,我们的服务可以正常显示OpenWeatherMap API是否响应失败。 现在,我们需要控制器将这些异常传达回API的使用方客户端。

Going back to our original three scenarios, the controller could respond to each in the following ways:

回到我们最初的三种情况,控制器可以通过以下方式对每种情况做出响应:

  1. If OpenWeatherMap couldn’t recognize the location, the controller can return a 400 BadRequest response and let the consumer know the name of the location that failed. Also, if the request is made without a location, we should return a 400 before even calling the service.

    如果OpenWeatherMap无法识别该位置,则控制器可以返回400 BadRequest响应,并让使用者知道发生故障的位置的名称。 同样,如果请求是在没有位置的情况下进行的,则在致电服务之前,我们应该返回400

  2. If the OpenWeatherMap returns an Unauthorized response, it's due to an invalid API key and for this project that's likely from a bad configuration. We'll return a 500 Internal Server Error with the message from the OpenWeatherMap API which will indicate if the request was not authorized.

    如果OpenWeatherMap返回Unauthorized响应,则归因于无效的API密钥,对于此项目,可能是由于配置错误所致。 我们将返回500 Internal Server Error并带有来自OpenWeatherMap API的消息,该消息将指示请求是否未经授权。

  3. If there is any other error, we’ll return another 500 response with the message from OpenWeatherMap. Lastly, we'll also return a 500 response for any other exception that is thrown within the application.

    如果还有其他错误,我们将再返回500响应,并附带来自OpenWeatherMap的消息。 最后,对于应用程序中引发的任何其他异常,我们还将返回500响应。

The responses above lead to three tests that we’ll add to ./Tests/Controllers_Tests/:

上面的响应导致我们将添加到./Tests/Controllers_Tests/三个测试:

[Fact]
public async Task Returns_400_Result_When_Missing_Location()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse);
var service = new OpenWeatherService(opts, clientFactory);
var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service); var result = await sut.Get(String.Empty) as ObjectResult; Assert.Equal(400, result.StatusCode);
}[Fact]
public async Task Returns_BadRequestResult_When_Location_Not_Found()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.NotFoundResponse,
HttpStatusCode.NotFound);
var service = new OpenWeatherService(opts, clientFactory);
var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service); var result = await sut.Get("Westworld") as ObjectResult; Assert.Contains("not found", result.Value.ToString());
Assert.Equal(400, result.StatusCode);
}[Fact]
public async Task Returns_OpenWeatherException_When_Unauthorized()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
HttpStatusCode.Unauthorized);
var sut = new OpenWeatherService(opts, clientFactory); var result = await Assert.ThrowsAsync<OpenWeatherException>(() => sut.GetFiveDayForecastAsync("Chicago"));
Assert.Equal(401, (int)result.StatusCode);
}[Fact]
public async Task Returns_500_When_Api_Returns_Error()
{
var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.UnauthorizedResponse,
HttpStatusCode.Unauthorized);
var service = new OpenWeatherService(opts, clientFactory);
var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service); var result = await sut.Get("Rio de Janeiro") as ObjectResult; Assert.Contains("Error response from OpenWeatherApi: Unauthorized", result.Value.ToString());
Assert.Equal(500, result.StatusCode);
}

If we run them, they should fail.

如果我们运行它们,它们将失败。

We’ll open the class under test in ./Api/Controllers/WeatherForecastController.cs and find the Get() method. Add the following as the first step of the method to check if there is a usable location value with the request:

我们将在./Api/Controllers/WeatherForecastController.cs打开要测试的类,然后找到Get()方法。 将以下内容添加为方法的第一步,以检查请求中是否存在可用的location值:

[HttpGet]
public async Task<IActionResult> Get(string location, Unit unit = Unit.Metric)
{
if (string.IsNullOrEmpty(location))
return BadRequest("location parameter is missing");
// [ ... ]
}

Now, three of the four new tests should be failing.

现在,四个新测试中的三个应该失败。

For the rest of the tests, we can get them to pass by returning a 400 Bad Request result if OpenWeatherMap can't find the location, or returning a 500 Internal Server Error for any other reason, along with a helpful message. Also, we can wrap our logic in a try/catch block that will handle an OpenWeatherException as indicated above, then handle any other exception. The updated Get() method can now look like this:

对于其余测试,如果OpenWeatherMap找不到位置,则返回400 Bad Request结果,或者由于其他任何原因返回500 Internal Server Error以及有用的信息,以使它们通过。 另外,我们可以将逻辑包装在try / catch块中,该块将如上所述处理OpenWeatherException ,然后处理其他任何异常。 更新后的Get()方法现在可以如下所示:

[HttpGet]
public async Task<IActionResult> Get(string location, Unit unit = Unit.Metric)
{
if (string.IsNullOrEmpty(location))
return BadRequest("location parameter is missing");
try
{
var forecast = await _weatherService.GetFiveDayForecastAsync(location, unit);
return Ok(forecast);
}
catch (OpenWeatherException e)
{
if (e.StatusCode == HttpStatusCode.NotFound)
return BadRequest($"Location: \"{ location }\" not found.");
else
return StatusCode(500, e.Message);
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}

Run the tests and we’re successful if we now have nine passing tests in the collection. As before, if you would like to experiment with other ways to handle exceptions from the OpenWeatherService, you can now refactor the method secured with its corresponding tests.

运行测试,如果集合中现在有九个通过测试,则我们成功。 和以前一样,如果您想尝试其他方法来处理来自OpenWeatherService异常,则现在可以重构通过相应测试保护的方法。

结论 (Conclusion)

In this tutorial, we started with a project with an API endpoint that could handle a “happy path,” but could not meaningfully handle exceptions. We came up with three possible exception scenarios, then used test-driven development for describing the desired behavior for our classes, making changes until the tests passed. We now have a more robust example ASP.NET Core project. In the next tutorials, we will Docker-ize the API to support complimentary development, then scaffold an Angular project to serve as a client.

在本教程中,我们从一个带有API终结点的项目开始,该终结点可以处理“快乐的道路”,但不能有效地处理异常。 我们提出了三种可能的异常情况,然后使用测试驱动的开发来描述我们的类所需的行为,进行更改直到测试通过。 现在,我们有一个更强大的示例ASP.NET Core项目。 在接下来的教程中,我们将对Docker大小的API进行支持以支持免费开发,然后搭建一个Angular项目作为客户端。

翻译自: https://medium.com/swlh/tdd-and-exception-handling-with-xunit-in-asp-net-core-f9ffe5dde800

xunit.net

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值