目录

介绍

先决条件

软件

技能

使用代码

第01步 - 创建项目

第02步 - 安装Nuget包

步骤03 - 添加模型

步骤04 - 添加控制器

步骤05 - 设置依赖注入

步骤06 - 运行Web API

步骤07 - 添加单元测试

步骤08 - 添加集成测试

代码挑战

代码改进

相关链接


在ASP.NET Core 2.0中创建Web API,非常详细的操作步骤,包括单元测试和集成测试

  • GitHub存储库

介绍

让我们使用最新版本的ASP.NET Core和Entity Framework Core创建一个Web API。

在本指南中,我们将使用WideWorldImporters数据库来创建Web API。

REST API至少提供以下操作:

  • GET
  • POST
  • PUT
  • DELETE

REST还有其他操作,但本指南不需要它们。

这些操作允许客户端通过REST API执行操作,因此我们的Web API必须包含这些操作。

WideWorldImporters 数据库包含4个模式:

  • Application
  • Purchasing
  • Sales
  • Warehouse

在本指南中,我们将使用Warehouse.StockItems表格。我们将添加代码以使用此实体:允许检索库存项目,按ID检索库存项目,创建,更新和删除数据库中的库存项目。

此API的版本为1。

这是API的路由表:

Verb

Url

Description

GET

api/v1/Warehouse/StockItem

返回库存商品

GET

api/v1/Warehouse/StockItem/id

更加id返回库存项目

POST

api/v1/Warehouse/StockItem

新建一个新的库存项目

PUT

api/v1/Warehouse/StockItem/id

更新已存在的库存项目

DELETE

api/v1/Warehouse/StockItem/id

删除已存在的库存项目

请牢记这些路线,因为API必须实现所有路线。

先决条件

软件

  • .NET核心
  • 的NodeJS
  • Visual Studio 2017上次更新
  • SQL Server
  • WideWorldImporters数据库

技能

  • C#
  • ORM(对象关系映射)
  • TDD(测试驱动开发)
  • RESTful服务

使用代码

对于本指南,源代码的工作目录是C\ Projects

第01步 - 创建项目

打开Visual Studio并按照下列步骤操作:

  1. 转到文件>新建>项目
  2. 转到已安装> Visual C#> .NET Core
  3. 将项目名称设置为 WideWorldImporters.API
  4. 单击确定

在下一个窗口中,选择API和.ASP.NET Core的最新版本,在本例中为2.1:

Visual Studio完成解决方案创建后,我们将看到此窗口:

第02步 - 安装Nuget包

在此步骤中,我们需要安装以下NuGet包:

  • EntityFrameworkCore.SqlServer
  • Swashbuckle.AspNetCore

现在,我们将继续EntityFrameworkCore.SqlServer从Nuget 安装软件包,右键单击WideWorldImporters.API项目:

更改为“浏览”选项卡并键入Microsoft.EntityFrameworkCore.SqlServer:

接下来,安装Swashbuckle.AspNetCore包:

Swashbuckle.AspNetCore package允许为Web API启用帮助页面。

这是项目的结构。

现在运行项目以检查解决方案是否准备就绪,按F5,Visual Studio将显示此浏览器窗口:

默认情况下,Visual Studio ValuesController在Controllers目录中添加一个带有名称的文件,将其从项目中删除。

步骤03 - 添加模型

现在,使用名称Models创建一个目录并添加以下文件:

  • Entities.cs
  • Extensions.cs
  • Requests.cs
  • Responses.cs

Entities.cs将包含与Entity Framework Core相关的所有代码。

Extensions.cs将包含DbContext和集合的扩展方法。

Requests.cs将包含请求的定义。

Responses.cs将包含响应的定义。

Entities.cs文件的代码:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591public partial class StockItem{public StockItem(){}public StockItem(int? stockItemID){StockItemID = stockItemID;}public int? StockItemID { get; set; }public string StockItemName { get; set; }public int? SupplierID { get; set; }public int? ColorID { get; set; }public int? UnitPackageID { get; set; }public int? OuterPackageID { get; set; }public string Brand { get; set; }public string Size { get; set; }public int? LeadTimeDays { get; set; }public int? QuantityPerOuter { get; set; }public bool? IsChillerStock { get; set; }public string Barcode { get; set; }public decimal? TaxRate { get; set; }public decimal? UnitPrice { get; set; }public decimal? RecommendedRetailPrice { get; set; }public decimal? TypicalWeightPerUnit { get; set; }public string MarketingComments { get; set; }public string InternalComments { get; set; }public string CustomFields { get; set; }public string Tags { get; set; }public string SearchDetails { get; set; }public int? LastEditedBy { get; set; }public DateTime? ValidFrom { get; set; }public DateTime? ValidTo { get; set; }}public class StockItemsConfiguration : IEntityTypeConfiguration<StockItem>{public void Configure(EntityTypeBuilder<StockItem> builder){// Set configuration for entitybuilder.ToTable("StockItems", "Warehouse");// Set key for entitybuilder.HasKey(p => p.StockItemID);// Set configuration for columnsbuilder.Property(p => p.StockItemName).HasColumnType("nvarchar(200)").IsRequired();builder.Property(p => p.SupplierID).HasColumnType("int").IsRequired();builder.Property(p => p.ColorID).HasColumnType("int");builder.Property(p => p.UnitPackageID).HasColumnType("int").IsRequired();builder.Property(p => p.OuterPackageID).HasColumnType("int").IsRequired();builder.Property(p => p.Brand).HasColumnType("nvarchar(100)");builder.Property(p => p.Size).HasColumnType("nvarchar(40)");builder.Property(p => p.LeadTimeDays).HasColumnType("int").IsRequired();builder.Property(p => p.QuantityPerOuter).HasColumnType("int").IsRequired();builder.Property(p => p.IsChillerStock).HasColumnType("bit").IsRequired();builder.Property(p => p.Barcode).HasColumnType("nvarchar(100)");builder.Property(p => p.TaxRate).HasColumnType("decimal(18, 3)").IsRequired();builder.Property(p => p.UnitPrice).HasColumnType("decimal(18, 2)").IsRequired();builder.Property(p => p.RecommendedRetailPrice).HasColumnType("decimal(18, 2)");builder.Property(p => p.TypicalWeightPerUnit).HasColumnType("decimal(18, 3)").IsRequired();builder.Property(p => p.MarketingComments).HasColumnType("nvarchar(max)");builder.Property(p => p.InternalComments).HasColumnType("nvarchar(max)");builder.Property(p => p.CustomFields).HasColumnType("nvarchar(max)");builder.Property(p => p.LastEditedBy).HasColumnType("int").IsRequired();// Computed columnsbuilder.Property(p => p.StockItemID).HasColumnType("int").IsRequired().HasComputedColumnSql("NEXT VALUE FOR [Sequences].[StockItemID]");builder.Property(p => p.Tags).HasColumnType("nvarchar(max)").HasComputedColumnSql("json_query([CustomFields],N'$.Tags')");builder.Property(p => p.SearchDetails).HasColumnType("nvarchar(max)").IsRequired().HasComputedColumnSql("concat([StockItemName],N' ',[MarketingComments])");// Columns with generated value on add or updatebuilder.Property(p => p.ValidFrom).HasColumnType("datetime2").IsRequired().ValueGeneratedOnAddOrUpdate();builder.Property(p => p.ValidTo).HasColumnType("datetime2").IsRequired().ValueGeneratedOnAddOrUpdate();}}public class WideWorldImportersDbContext : DbContext{public WideWorldImportersDbContext(DbContextOptions<WideWorldImportersDbContext> options): base(options){}protected override void OnModelCreating(ModelBuilder modelBuilder){// Apply configurations for entitymodelBuilder.ApplyConfiguration(new StockItemsConfiguration());base.OnModelCreating(modelBuilder);}public DbSet<StockItem> StockItems { get; set; }}
#pragma warning restore CS1591
}

Code for Extensions.cs文件:

using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591public static class WideWorldImportersDbContextExtensions{public static IQueryable<StockItem> GetStockItems(this WideWorldImportersDbContext dbContext, int pageSize = 10, int pageNumber = 1, int? lastEditedBy = null, int? colorID = null, int? outerPackageID = null, int? supplierID = null, int? unitPackageID = null){// Get query from DbSetvar query = dbContext.StockItems.AsQueryable();// Filter by: 'LastEditedBy'if (lastEditedBy.HasValue)query = query.Where(item => item.LastEditedBy == lastEditedBy);// Filter by: 'ColorID'if (colorID.HasValue)query = query.Where(item => item.ColorID == colorID);// Filter by: 'OuterPackageID'if (outerPackageID.HasValue)query = query.Where(item => item.OuterPackageID == outerPackageID);// Filter by: 'SupplierID'if (supplierID.HasValue)query = query.Where(item => item.SupplierID == supplierID);// Filter by: 'UnitPackageID'if (unitPackageID.HasValue)query = query.Where(item => item.UnitPackageID == unitPackageID);return query;}public static async Task<StockItem> GetStockItemsAsync(this WideWorldImportersDbContext dbContext, StockItem entity)=> await dbContext.StockItems.FirstOrDefaultAsync(item => item.StockItemID == entity.StockItemID);public static async Task<StockItem> GetStockItemsByStockItemNameAsync(this WideWorldImportersDbContext dbContext, StockItem entity)=> await dbContext.StockItems.FirstOrDefaultAsync(item => item.StockItemName == entity.StockItemName);}public static class IQueryableExtensions{public static IQueryable<TModel> Paging<TModel>(this IQueryable<TModel> query, int pageSize = 0, int pageNumber = 0) where TModel : class=> pageSize > 0 && pageNumber > 0 ? query.Skip((pageNumber - 1) * pageSize).Take(pageSize) : query;}
#pragma warning restore CS1591
}

Requests.cs文件的代码:

using System;
using System.ComponentModel.DataAnnotations;namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591public class PostStockItemsRequest{[Key]public int? StockItemID { get; set; }[Required][StringLength(200)]public string StockItemName { get; set; }[Required]public int? SupplierID { get; set; }public int? ColorID { get; set; }[Required]public int? UnitPackageID { get; set; }[Required]public int? OuterPackageID { get; set; }[StringLength(100)]public string Brand { get; set; }[StringLength(40)]public string Size { get; set; }[Required]public int? LeadTimeDays { get; set; }[Required]public int? QuantityPerOuter { get; set; }[Required]public bool? IsChillerStock { get; set; }[StringLength(100)]public string Barcode { get; set; }[Required]public decimal? TaxRate { get; set; }[Required]public decimal? UnitPrice { get; set; }public decimal? RecommendedRetailPrice { get; set; }[Required]public decimal? TypicalWeightPerUnit { get; set; }public string MarketingComments { get; set; }public string InternalComments { get; set; }public string CustomFields { get; set; }public string Tags { get; set; }[Required]public string SearchDetails { get; set; }[Required]public int? LastEditedBy { get; set; }public DateTime? ValidFrom { get; set; }public DateTime? ValidTo { get; set; }}public class PutStockItemsRequest{[Required][StringLength(200)]public string StockItemName { get; set; }[Required]public int? SupplierID { get; set; }public int? ColorID { get; set; }[Required]public decimal? UnitPrice { get; set; }}public static class Extensions{public static StockItem ToEntity(this PostStockItemsRequest request)=> new StockItem{StockItemID = request.StockItemID,StockItemName = request.StockItemName,SupplierID = request.SupplierID,ColorID = request.ColorID,UnitPackageID = request.UnitPackageID,OuterPackageID = request.OuterPackageID,Brand = request.Brand,Size = request.Size,LeadTimeDays = request.LeadTimeDays,QuantityPerOuter = request.QuantityPerOuter,IsChillerStock = request.IsChillerStock,Barcode = request.Barcode,TaxRate = request.TaxRate,UnitPrice = request.UnitPrice,RecommendedRetailPrice = request.RecommendedRetailPrice,TypicalWeightPerUnit = request.TypicalWeightPerUnit,MarketingComments = request.MarketingComments,InternalComments = request.InternalComments,CustomFields = request.CustomFields,Tags = request.Tags,SearchDetails = request.SearchDetails,LastEditedBy = request.LastEditedBy,ValidFrom = request.ValidFrom,ValidTo = request.ValidTo};}
#pragma warning restore CS1591
}

Responses.cs文件的代码:

using System.Collections.Generic;
using System.Net;
using Microsoft.AspNetCore.Mvc;namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591public interface IResponse{string Message { get; set; }bool DidError { get; set; }string ErrorMessage { get; set; }}public interface ISingleResponse<TModel> : IResponse{TModel Model { get; set; }}public interface IListResponse<TModel> : IResponse{IEnumerable<TModel> Model { get; set; }}public interface IPagedResponse<TModel> : IListResponse<TModel>{int ItemsCount { get; set; }double PageCount { get; }}public class Response : IResponse{public string Message { get; set; }public bool DidError { get; set; }public string ErrorMessage { get; set; }}public class SingleResponse<TModel> : ISingleResponse<TModel>{public string Message { get; set; }public bool DidError { get; set; }public string ErrorMessage { get; set; }public TModel Model { get; set; }}public class ListResponse<TModel> : IListResponse<TModel>{public string Message { get; set; }public bool DidError { get; set; }public string ErrorMessage { get; set; }public IEnumerable<TModel> Model { get; set; }}public class PagedResponse<TModel> : IPagedResponse<TModel>{public string Message { get; set; }public bool DidError { get; set; }public string ErrorMessage { get; set; }public IEnumerable<TModel> Model { get; set; }public int PageSize { get; set; }public int PageNumber { get; set; }public int ItemsCount { get; set; }public double PageCount=> ItemsCount < PageSize ? 1 : (int)(((double)ItemsCount / PageSize) + 1);}public static class ResponseExtensions{public static IActionResult ToHttpResponse(this IResponse response){var status = response.DidError ? HttpStatusCode.InternalServerError : HttpStatusCode.OK;return new ObjectResult(response){StatusCode = (int)status};}public static IActionResult ToHttpResponse<TModel>(this ISingleResponse<TModel> response){var status = HttpStatusCode.OK;if (response.DidError)status = HttpStatusCode.InternalServerError;else if (response.Model == null)status = HttpStatusCode.NotFound;return new ObjectResult(response){StatusCode = (int)status};}public static IActionResult ToHttpResponse<TModel>(this IListResponse<TModel> response){var status = HttpStatusCode.OK;if (response.DidError)status = HttpStatusCode.InternalServerError;else if (response.Model == null)status = HttpStatusCode.NoContent;return new ObjectResult(response){StatusCode = (int)status};}}
#pragma warning restore CS1591
}

了解模型

实体

StockItems类是Warehouse.StockItems表的表示。

StockItemsConfiguration类包含类的映射StockItems。

WideWorldImportersDbContext 类是数据库和C#代码之间的链接,这个类处理查询并提交数据库中的更改,当然还有另外一些事情。

扩展

WideWorldImportersDbContextExtensions 包含DbContext实例的扩展方法,一种方法用于检索stock items,另一种用于按ID检索stock item,最后一种用于按名称检索stock item。

IQueryableExtensions包含扩展方法IQueryable,用于添加分页功能。

要求

我们有以下定义:

  • PostStockItemsRequest
  • PutStockItemsRequest

PostStockItemsRequest 表示用于创建新stock item的模型,包含要保存在数据库中的所有必需属性。

PutStockItemsRequest代表机型更新现有stock item,在这种情况下只包含4个属性:StockItemName,SupplierID,ColorID和UnitPrice。此类不包含StockItemID属性,因为id位于控制器操作的路径中。

请求模型不需要包含实体等所有属性,因为我们不需要在请求或响应中暴露完整定义,使用具有少量属性的模型来限制数据是一种很好的做法。

Extensions类包含一个PostStockItemsRequest的扩展方法,用于StockItem从请求模型返回类的实例。

回复

这些是接口:

  • IResponse
  • ISingleResponse<TModel>
  • IListResponse<TModel>
  • IPagedResponse<TModel>

这些接口中的每一个都有实现,如果返回对象而不将它们封装在这些模型中更简单,为什么我们需要这些定义呢?请记住,这个Web API将为客户端提供操作,具有UI或没有UI,如果发生错误,它更容易拥有发送消息的属性,拥有模型或发送信息,此外,我们在响应中设置Http状态代码描述请求的结果。

这些类是通用的,因为通过这种方式,我们可以节省定义将来响应的时间,此Web API仅返回单个实体,列表和分页列表的响应。

ISingleResponse 表示对单个实体的响应。

IListResponse 表示带有列表的响应,例如,所有运送到现有订单而不进行分页。

IPagedResponse 表示具有分页的响应,例如日期范围内的所有订单。

ResponseExtensions类包含用于转换Http响应中的响应的扩展方法,这些方法在发生错误时返回InternalServerError(500)状态,OK(200)如果成功,如果数据库中不存在实体则返回NotFound(404),或者返回NoContent(204)用于列表响应没有模特。

步骤04 - 添加控制器

现在,在Controllers目录内,添加名为WarehouseController.cs的代码文件并添加以下代码:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using WideWorldImporters.API.Models;namespace WideWorldImporters.API.Controllers
{
#pragma warning disable CS1591[ApiController][Route("api/v1/[controller]")]public class WarehouseController : ControllerBase{protected readonly ILogger Logger;protected readonly WideWorldImportersDbContext DbContext;public WarehouseController(ILogger<WarehouseController> logger, WideWorldImportersDbContext dbContext){Logger = logger;DbContext = dbContext;}
#pragma warning restore CS1591// GET// api/v1/Warehouse/StockItem/// <summary>/// Retrieves stock items/// </summary>/// <param name="pageSize">Page size</param>/// <param name="pageNumber">Page number</param>/// <param name="lastEditedBy">Last edit by (user id)</param>/// <param name="colorID">Color id</param>/// <param name="outerPackageID">Outer package id</param>/// <param name="supplierID">Supplier id</param>/// <param name="unitPackageID">Unit package id</param>/// <returns>A response with stock items list</returns>/// <response code="200">Returns the stock items list</response>/// <response code="500">If there was an internal server error</response>[HttpGet("StockItem")][ProducesResponseType(200)][ProducesResponseType(500)]public async Task<IActionResult> GetStockItemsAsync(int pageSize = 10, int pageNumber = 1, int? lastEditedBy = null, int? colorID = null, int? outerPackageID = null, int? supplierID = null, int? unitPackageID = null){Logger?.LogDebug("'{0}' has been invoked", nameof(GetStockItemsAsync));var response = new PagedResponse<StockItem>();try{// Get the "proposed" query from repositoryvar query = DbContext.GetStockItems();// Set paging valuesresponse.PageSize = pageSize;response.PageNumber = pageNumber;// Get the total rowsresponse.ItemsCount = await query.CountAsync();// Get the specific page from databaseresponse.Model = await query.Paging(pageSize, pageNumber).ToListAsync();response.Message = string.Format("Page {0} of {1}, Total of products: {2}.", pageNumber, response.PageCount, response.ItemsCount);Logger?.LogInformation("The stock items have been retrieved successfully.");}catch (Exception ex){response.DidError = true;response.ErrorMessage = "There was an internal error, please contact to technical support.";Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(GetStockItemsAsync), ex);}return response.ToHttpResponse();}// GET// api/v1/Warehouse/StockItem/5/// <summary>/// Retrieves a stock item by ID/// </summary>/// <param name="id">Stock item id</param>/// <returns>A response with stock item</returns>/// <response code="200">Returns the stock items list</response>/// <response code="404">If stock item is not exists</response>/// <response code="500">If there was an internal server error</response>[HttpGet("StockItem/{id}")][ProducesResponseType(200)][ProducesResponseType(404)][ProducesResponseType(500)]public async Task<IActionResult> GetStockItemAsync(int id){Logger?.LogDebug("'{0}' has been invoked", nameof(GetStockItemAsync));var response = new SingleResponse<StockItem>();try{// Get the stock item by idresponse.Model = await DbContext.GetStockItemsAsync(new StockItem(id));}catch (Exception ex){response.DidError = true;response.ErrorMessage = "There was an internal error, please contact to technical support.";Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(GetStockItemAsync), ex);}return response.ToHttpResponse();}// POST// api/v1/Warehouse/StockItem//// <summary>/// Creates a new stock item/// </summary>/// <param name="request">Request model</param>/// <returns>A response with new stock item</returns>/// <response code="200">Returns the stock items list</response>/// <response code="201">A response as creation of stock item</response>/// <response code="400">For bad request</response>/// <response code="500">If there was an internal server error</response>[HttpPost("StockItem")][ProducesResponseType(200)][ProducesResponseType(201)][ProducesResponseType(400)][ProducesResponseType(500)]public async Task<IActionResult> PostStockItemAsync([FromBody]PostStockItemsRequest request){Logger?.LogDebug("'{0}' has been invoked", nameof(PostStockItemAsync));var response = new SingleResponse<StockItem>();try{var existingEntity = await DbContext.GetStockItemsByStockItemNameAsync(new StockItem { StockItemName = request.StockItemName });if (existingEntity != null)ModelState.AddModelError("StockItemName", "Stock item name already exists");if (!ModelState.IsValid)return BadRequest();// Create entity from request modelvar entity = request.ToEntity();// Add entity to repositoryDbContext.Add(entity);// Save entity in databaseawait DbContext.SaveChangesAsync();// Set the entity to response modelresponse.Model = entity;}catch (Exception ex){response.DidError = true;response.ErrorMessage = "There was an internal error, please contact to technical support.";Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(PostStockItemAsync), ex);}return response.ToHttpResponse();}// PUT// api/v1/Warehouse/StockItem/5/// <summary>/// Updates an existing stock item/// </summary>/// <param name="id">Stock item ID</param>/// <param name="request">Request model</param>/// <returns>A response as update stock item result</returns>/// <response code="200">If stock item was updated successfully</response>/// <response code="400">For bad request</response>/// <response code="500">If there was an internal server error</response>[HttpPut("StockItem/{id}")][ProducesResponseType(200)][ProducesResponseType(400)][ProducesResponseType(500)]public async Task<IActionResult> PutStockItemAsync(int id, [FromBody]PutStockItemsRequest request){Logger?.LogDebug("'{0}' has been invoked", nameof(PutStockItemAsync));var response = new Response();try{// Get stock item by idvar entity = await DbContext.GetStockItemsAsync(new StockItem(id));// Validate if entity existsif (entity == null)return NotFound();// Set changes to entityentity.StockItemName = request.StockItemName;entity.SupplierID = request.SupplierID;entity.ColorID = request.ColorID;entity.UnitPrice = request.UnitPrice;// Update entity in repositoryDbContext.Update(entity);// Save entity in databaseawait DbContext.SaveChangesAsync();}catch (Exception ex){response.DidError = true;response.ErrorMessage = "There was an internal error, please contact to technical support.";Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(PutStockItemAsync), ex);}return response.ToHttpResponse();}// DELETE// api/v1/Warehouse/StockItem/5/// <summary>/// Deletes an existing stock item/// </summary>/// <param name="id">Stock item ID</param>/// <returns>A response as delete stock item result</returns>/// <response code="200">If stock item was deleted successfully</response>/// <response code="500">If there was an internal server error</response>[HttpDelete("StockItem/{id}")][ProducesResponseType(200)][ProducesResponseType(500)]public async Task<IActionResult> DeleteStockItemAsync(int id){Logger?.LogDebug("'{0}' has been invoked", nameof(DeleteStockItemAsync));var response = new Response();try{// Get stock item by idvar entity = await DbContext.GetStockItemsAsync(new StockItem(id));// Validate if entity existsif (entity == null)return NotFound();// Remove entity from repositoryDbContext.Remove(entity);// Delete entity in databaseawait DbContext.SaveChangesAsync();}catch (Exception ex){response.DidError = true;response.ErrorMessage = "There was an internal error, please contact to technical support.";Logger?.LogCritical("There was an error on '{0}' invocation: {1}", nameof(DeleteStockItemAsync), ex);}return response.ToHttpResponse();}}
}

所有控制器操作的过程是:

  1. 记录方法的调用。
  2. 根据操作(分页,列表或单个)创建响应实例。
  3. 通过DbContext实例执行对数据库的访问。
  4. 如果存储库调用失败,请将DidError属性设置为true和设置ErrorMessage属性:出现内部错误,请联系技术支持。,因为不建议在响应中公开错误详细信息,所以最好将所有异常详细信息保存在日志文件中。
  5. 将结果作为Http响应返回。

请记住以Async后缀结尾的方法的所有名称,因为所有操作都是异步的,但在Http属性中,我们不使用此后缀。

步骤05 - 设置依赖注入

ASP.NET Core能够原生方式依赖注入,这意味着我们不需要任何第三方框架在控制器注入依赖。

这是一个很大的挑战,因为我们需要从Web Forms和ASP.NET MVC改变主意,因为那些技术使用框架来注入依赖关系是一种奢侈,现在在ASP.NET Core依赖注入是一个基本方面。

ASP.NET Core的项目模板有一个带有名称Startup的类,在这个类中我们必须添加配置来为DbContext,Services,Loggers等注入实例。

修改Startup.cs文件的代码如下所示:

using System;
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Swagger;
using WideWorldImporters.API.Controllers;
using WideWorldImporters.API.Models;namespace WideWorldImporters.API
{
#pragma warning disable CS1591public class Startup{public Startup(IConfiguration configuration){Configuration = configuration;}public IConfiguration Configuration { get; }// This method gets called by the runtime. Use this method to add services to the container.public void ConfigureServices(IServiceCollection services){services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);// Add configuration for DbContext// Use connection string from appsettings.json fileservices.AddDbContext<WideWorldImportersDbContext>(options =>{options.UseSqlServer(Configuration["AppSettings:ConnectionString"]);});// Set up dependency injection for controller's loggerservices.AddScoped<ILogger, Logger<WarehouseController>>();// Register the Swagger generator, defining 1 or more Swagger documentsservices.AddSwaggerGen(options =>{options.SwaggerDoc("v1", new Info { Title = "WideWorldImporters API", Version = "v1" });// Get xml comments pathvar xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);// Set xml pathoptions.IncludeXmlComments(xmlPath);});}// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.public void Configure(IApplicationBuilder app, IHostingEnvironment env){if (env.IsDevelopment())app.UseDeveloperExceptionPage();// Enable middleware to serve generated Swagger as a JSON endpoint.app.UseSwagger();// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.app.UseSwaggerUI(c =>{c.SwaggerEndpoint("/swagger/v1/swagger.json", "WideWorldImporters API V1");});app.UseMvc();}}
#pragma warning restore CS1591
}

该ConfigureServices方法指定了如何解析依赖关系。我们需要建立DbContext和Logging。

该Configure方法为Http请求运行时添加了配置。

步骤06 - 运行Web API

在运行Web API项目之前,在appsettings.json文件中添加连接字符串:

{"Logging": {"LogLevel": {"Default": "Warning"}},"AllowedHosts": "*","AppSettings": {"ConnectionString": "server=(local);database=WideWorldImporters;integrated security=yes;"}
}

要在帮助页面中显示说明,请为Web API项目启用XML文档:

  1. 右键单击Project> Properties
  2. 转到Build > Output
  3. 启用XML文档文件
  4. 保存更改

现在,按F5开始调试Web API项目,如果一切正常,我们将在浏览器中获得以下输出:

另外,我们可以在另一个标签中加载帮助页面:

步骤07 - 添加单元测试

要为API项目添加单元测试,请按照下列步骤操作:

  1. 右键单击Solution> Add> New Project
  2. 转到已安装> Visual C#>测试> xUnit测试项目(.NET Core
  3. 将项目名称设置为 WideWorldImporters.API.UnitTests
  4. 单击确定

管理WideWorldImporters.API.UnitTests项目的引用:

现在添加WideWorldImporters.API项目的引用:

创建项目后,为项目添加以下NuGet包:

  • Microsoft.AspNetCore.Mvc.Core
  • Microsoft.EntityFrameworkCore.InMemory

删除UnitTest1.cs文件。

保存更改并构建WideWorldImporters.API.UnitTests项目。

现在我们继续添加与单元测试相关的代码,这些测试将与内存数据库一起使用

什么是TDD?测试是当今常见的做法,因为通过单元测试,在发布之前很容易对功能进行测试,测试驱动开发(TDD)是定义单元测试和验证代码行为的方法。

TDD的另一个概念是AAA安排行动断言 ; Arrange是用于创建对象的代码块,Act是用于放置方法的所有调用的代码块,Assert是用于验证方法调用的结果的代码块。

由于我们正在使用内存数据库进行单元测试,因此我们需要创建一个类来模拟WideWorldImportersDbContext类,并添加数据以执行IWarehouseRepository操作测试。

需要明确的是:这些单元测试不与SQL Server建立连接

对于单元测试,请添加以下文件:

  • DbContextMocker.cs
  • DbContextExtensions.cs
  • WarehouseControllerUnitTest.cs

DbContextMocker.cs文件的代码:

using Microsoft.EntityFrameworkCore;
using WideWorldImporters.API.Models;namespace WideWorldImporters.API.UnitTests
{public static class DbContextMocker{public static WideWorldImportersDbContext GetWideWorldImportersDbContext(string dbName){// Create options for DbContext instancevar options = new DbContextOptionsBuilder<WideWorldImportersDbContext>().UseInMemoryDatabase(databaseName: dbName).Options;// Create instance of DbContextvar dbContext = new WideWorldImportersDbContext(options);// Add entities in memorydbContext.Seed();return dbContext;}}
}

DbContextExtensions.cs文件的代码:

using System;
using WideWorldImporters.API.Models;namespace WideWorldImporters.API.UnitTests
{public static class DbContextExtensions{public static void Seed(this WideWorldImportersDbContext dbContext){// Add entities for DbContext instancedbContext.StockItems.Add(new StockItem{StockItemID = 1,StockItemName = "USB missile launcher (Green)",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 25.00m,RecommendedRetailPrice = 37.38m,TypicalWeightPerUnit = 0.300m,MarketingComments = "Complete with 12 projectiles",CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [\"USB Powered\"] }",Tags = "[\"USB Powered\"]",SearchDetails = "USB missile launcher (Green) Complete with 12 projectiles",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 2,StockItemName = "USB rocket launcher (Gray)",SupplierID = 12,ColorID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 25.00m,RecommendedRetailPrice = 37.38m,TypicalWeightPerUnit = 0.300m,MarketingComments = "Complete with 12 projectiles",CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [\"USB Powered\"] }",Tags = "[\"USB Powered\"]",SearchDetails = "USB rocket launcher (Gray) Complete with 12 projectiles",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 3,StockItemName = "Office cube periscope (Black)",SupplierID = 12,ColorID = 3,UnitPackageID = 7,OuterPackageID = 6,LeadTimeDays = 14,QuantityPerOuter = 10,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 18.50m,RecommendedRetailPrice = 27.66m,TypicalWeightPerUnit = 0.250m,MarketingComments = "Need to see over your cubicle wall? This is just what's needed.",CustomFields = "{ \"CountryOfManufacture\": \"China\", \"Tags\": [] }",Tags = "[]",SearchDetails = "Office cube periscope (Black) Need to see over your cubicle wall? This is just what's needed.",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:00:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 4,StockItemName = "USB food flash drive - sushi roll",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - sushi roll ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 5,StockItemName = "USB food flash drive - hamburger",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",Tags = "[\"16GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - hamburger ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 6,StockItemName = "USB food flash drive - hot dog",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - hot dog ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 7,StockItemName = "USB food flash drive - pizza slice",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",Tags = "[\"16GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - pizza slice ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 8,StockItemName = "USB food flash drive - dim sum 10 drive variety pack",SupplierID = 12,UnitPackageID = 9,OuterPackageID = 9,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 240.00m,RecommendedRetailPrice = 358.80m,TypicalWeightPerUnit = 0.500m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - dim sum 10 drive variety pack ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 9,StockItemName = "USB food flash drive - banana",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",Tags = "[\"16GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - banana ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 10,StockItemName = "USB food flash drive - chocolate bar",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - chocolate bar ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 11,StockItemName = "USB food flash drive - cookie",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"16GB\",\"USB Powered\"] }",Tags = "[\"16GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - cookie ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.StockItems.Add(new StockItem{StockItemID = 12,StockItemName = "USB food flash drive - donut",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB food flash drive - donut ",LastEditedBy = 1,ValidFrom = Convert.ToDateTime("5/31/2016 11:11:00 PM"),ValidTo = Convert.ToDateTime("12/31/9999 11:59:59 PM")});dbContext.SaveChanges();}}
}

WarehouseControllerUnitTest.cs文件的代码:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using WideWorldImporters.API.Controllers;
using WideWorldImporters.API.Models;
using Xunit;namespace WideWorldImporters.API.UnitTests
{public class WarehouseControllerUnitTest{[Fact]public async Task TestGetStockItemsAsync(){// Arrangevar dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemsAsync));var controller = new WarehouseController(null, dbContext);// Actvar response = await controller.GetStockItemsAsync() as ObjectResult;var value = response.Value as IPagedResponse<StockItem>;dbContext.Dispose();// AssertAssert.False(value.DidError);}[Fact]public async Task TestGetStockItemAsync(){// Arrangevar dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemAsync));var controller = new WarehouseController(null, dbContext);var id = 1;// Actvar response = await controller.GetStockItemAsync(id) as ObjectResult;var value = response.Value as ISingleResponse<StockItem>;dbContext.Dispose();// AssertAssert.False(value.DidError);}[Fact]public async Task TestPostStockItemAsync(){// Arrangevar dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestPostStockItemAsync));var controller = new WarehouseController(null, dbContext);var requestModel = new PostStockItemsRequest{StockItemID = 100,StockItemName = "USB anime flash drive - Goku",SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB anime flash drive - Goku",LastEditedBy = 1,ValidFrom = DateTime.Now,ValidTo = DateTime.Now.AddYears(5)};// Actvar response = await controller.PostStockItemAsync(requestModel) as ObjectResult;var value = response.Value as ISingleResponse<StockItem>;dbContext.Dispose();// AssertAssert.False(value.DidError);}[Fact]public async Task TestPutStockItemAsync(){// Arrangevar dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestPutStockItemAsync));var controller = new WarehouseController(null, dbContext);var id = 12;var requestModel = new PutStockItemsRequest{StockItemName = "USB food flash drive (Update)",SupplierID = 12,ColorID = 3};// Actvar response = await controller.PutStockItemAsync(id, requestModel) as ObjectResult;var value = response.Value as IResponse;dbContext.Dispose();// AssertAssert.False(value.DidError);}[Fact]public async Task TestDeleteStockItemAsync(){// Arrangevar dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestDeleteStockItemAsync));var controller = new WarehouseController(null, dbContext);var id = 5;// Actvar response = await controller.DeleteStockItemAsync(id) as ObjectResult;var value = response.Value as IResponse;dbContext.Dispose();// AssertAssert.False(value.DidError);}}
}

我们可以看到,WarehouseControllerUnitTest包含Web API的所有测试,这些是方法:

方法

描述

TestGetStockItemsAsync

检索库存商品

TestGetStockItemAsync

按ID检索现有库存料品

TestPostStockItemAsync

创建新的库存项目

TestPutStockItemAsync

更新现有库存项目

TestDeleteStockItemAsync

删除现有库存项目

单元测试如何工作?

DbContextMocker在内存数据库中创建一个WideWorldImportersDbContext实例,该dbName参数设置内存数据库中的名称; 然后有一个Seed方法的调用,这个方法添加WideWorldImportersDbContext实例的实体以提供结果。

DbContextExtensions类包含Seed扩展方法。

WarehouseControllerUnitTest类包含类的所有对WarehouseController类的测试。

请记住,每个测试在每个测试方法内部使用不同的内存数据库。我们使用nameof运算符的测试方法名称在内存数据库中检索。

在这个级别(单元测试),我们只需要检查存储库的操作,不需要使用SQL数据库(关系,事务等)。

单元测试的过程是:

  1. 创建一个WideWorldImportersDbContext实例
  2. 创建一个控制器实例
  3. 调用控制器的方法
  4. 从控制器的调用中获取值
  5. 释放WideWorldImportersDbContext实例(占用空间)
  6. 验证响应

运行单元测试

保存所有更改并构建WideWorldImporters.API.UnitTests项目。

现在,检查测试资源管理器中的测试:

使用测试资源管理器运行所有测试,如果出现任何错误,请检查错误消息,查看代码并重复此过程。

步骤08 - 添加集成测试

要为API项目添加集成测试,请按照下列步骤操作:

  1. 右键单击Solution> Add> New Project
  2. 转到已安装> Visual C#>测试> xUnit测试项目(.NET Core
  3. 将项目名称设置为 WideWorldImporters.API.IntegrationTests
  4. 单击确定

管理WideWorldImporters.API.IntegrationTests项目的引用:

现在添加WideWorldImporters.API项目的引用:

创建项目后,为项目添加以下NuGet包:

  • Microsoft.AspNetCore.Mvc
  • Microsoft.AspNetCore.Mvc.Core
  • Microsoft.AspNetCore.Diagnostics
  • Microsoft.AspNetCore.TestHost
  • Microsoft.Extensions.Configuration.Json

删除UnitTest1.cs文件。

保存更改并构建WideWorldImporters.API.IntegrationTests项目。

单元测试和集成测试有什么区别?对于单元测试,我们模拟Web API项目和集成测试的所有依赖项,我们运行一个模拟Web API执行的过程,这意味着Http请求。

现在我们继续添加与集成测试相关的代码。

对于这个项目,集成测试将执行Http请求,每个Http请求将对SQL Server实例中的现有数据库执行操作。我们将使用SQL Server的本地实例,这可以根据您的工作环境进行更改,我的意思是集成测试的范围。

TestFixture.cs文件的代码:

using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;namespace WideWorldImporters.API.IntegrationTests
{public class TestFixture<TStartup> : IDisposable{public static string GetProjectPath(string projectRelativePath, Assembly startupAssembly){var projectName = startupAssembly.GetName().Name;var applicationBasePath = AppContext.BaseDirectory;var directoryInfo = new DirectoryInfo(applicationBasePath);do{directoryInfo = directoryInfo.Parent;var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));if (projectDirectoryInfo.Exists)if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)return Path.Combine(projectDirectoryInfo.FullName, projectName);}while (directoryInfo.Parent != null);throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");}private TestServer Server;public TestFixture(): this(Path.Combine("")){}protected TestFixture(string relativeTargetProjectParentDir){var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly);var configurationBuilder = new ConfigurationBuilder().SetBasePath(contentRoot).AddJsonFile("appsettings.json");var webHostBuilder = new WebHostBuilder().UseContentRoot(contentRoot).ConfigureServices(InitializeServices).UseConfiguration(configurationBuilder.Build()).UseEnvironment("Development").UseStartup(typeof(TStartup));Server = new TestServer(webHostBuilder);Client = Server.CreateClient();Client.BaseAddress = new Uri("http://localhost:1234");Client.DefaultRequestHeaders.Accept.Clear();Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));}public void Dispose(){Client.Dispose();Server.Dispose();}public HttpClient Client { get; }protected virtual void InitializeServices(IServiceCollection services){var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;var manager = new ApplicationPartManager();manager.ApplicationParts.Add(new AssemblyPart(startupAssembly));manager.FeatureProviders.Add(new ControllerFeatureProvider());manager.FeatureProviders.Add(new ViewComponentFeatureProvider());services.AddSingleton(manager);}}
}

ContentHelper.cs文件的代码:

using System.Net.Http;
using System.Text;
using Newtonsoft.Json;namespace WideWorldImporters.API.IntegrationTests
{public static class ContentHelper{public static StringContent GetStringContent(object obj)=> new StringContent(JsonConvert.SerializeObject(obj), Encoding.Default, "application/json");}
}

WarehouseTests.cs文件的代码:

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using WideWorldImporters.API.Models;
using Xunit;namespace WideWorldImporters.API.IntegrationTests
{public class WarehouseTests : IClassFixture<TestFixture<Startup>>{private HttpClient Client;public WarehouseTests(TestFixture<Startup> fixture){Client = fixture.Client;}[Fact]public async Task TestGetStockItemsAsync(){// Arrangevar request = "/api/v1/Warehouse/StockItem";// Actvar response = await Client.GetAsync(request);// Assertresponse.EnsureSuccessStatusCode();}[Fact]public async Task TestGetStockItemAsync(){// Arrangevar request = "/api/v1/Warehouse/StockItem/1";// Actvar response = await Client.GetAsync(request);// Assertresponse.EnsureSuccessStatusCode();}[Fact]public async Task TestPostStockItemAsync(){// Arrangevar request = "/api/v1/Warehouse/StockItem";var requestModel = new{StockItemName = string.Format("USB anime flash drive - Vegeta {0}", Guid.NewGuid()),SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 15.000m,UnitPrice = 32.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"Japan\", \"Tags\": [\"32GB\",\"USB Powered\"] }",Tags = "[\"32GB\",\"USB Powered\"]",SearchDetails = "USB anime flash drive - Vegeta",LastEditedBy = 1,ValidFrom = DateTime.Now,ValidTo = DateTime.Now.AddYears(5)};// Actvar response = await Client.PostAsync(request, ContentHelper.GetStringContent(request));// Assertresponse.EnsureSuccessStatusCode();}[Fact]public async Task TestPutStockItemAsync(){// Arrangevar requestUrl = "/api/v1/Warehouse/StockItem/1";var requestModel = new{StockItemName = string.Format("USB anime flash drive - Vegeta {0}", Guid.NewGuid()),SupplierID = 12,Color = 3,UnitPrice = 39.00m};// Actvar response = await Client.PutAsync(requestUrl, ContentHelper.GetStringContent(requestModel));// Assertresponse.EnsureSuccessStatusCode();}[Fact]public async Task TestDeleteStockItemAsync(){// Arrangevar postRequest = "/api/v1/Warehouse/StockItem";var requestModel = new{StockItemName = string.Format("Product to delete {0}", Guid.NewGuid()),SupplierID = 12,UnitPackageID = 7,OuterPackageID = 7,LeadTimeDays = 14,QuantityPerOuter = 1,IsChillerStock = false,TaxRate = 10.000m,UnitPrice = 10.00m,RecommendedRetailPrice = 47.84m,TypicalWeightPerUnit = 0.050m,CustomFields = "{ \"CountryOfManufacture\": \"USA\", \"Tags\": [\"Sample\"] }",Tags = "[\"Sample\"]",SearchDetails = "Product to delete",LastEditedBy = 1,ValidFrom = DateTime.Now,ValidTo = DateTime.Now.AddYears(5)};// Actvar postResponse = await Client.PostAsync(postRequest, ContentHelper.GetStringContent(requestModel));var jsonFromPostResponse = await postResponse.Content.ReadAsStringAsync();var singleResponse = JsonConvert.DeserializeObject<SingleResponse<StockItem>>(jsonFromPostResponse);var deleteResponse = await Client.DeleteAsync(string.Format("/api/v1/Warehouse/StockItem/{0}", singleResponse.Model.StockItemID));// AssertpostResponse.EnsureSuccessStatusCode();Assert.False(singleResponse.DidError);deleteResponse.EnsureSuccessStatusCode();}}
}

我们可以看到,WarehouseTests包含Web API的所有测试,这些是方法:

方法

描述

TestGetStockItemsAsync

检索库存商品

TestGetStockItemAsync

按ID检索现有库存料品

TestPostStockItemAsync

创建新的库存项目

TestPutStockItemAsync

更新现有库存项目

TestDeleteStockItemAsync

删除现有库存项目

集成测试如何工作?

TestFixture<TStartup>类为Web API提供了一个Http客户端,使用项目中Startup的类作为为客户端应用配置的引用。

WarehouseTests类包含发送Web API的Http请求的所有方法,Http客户端的端口号是1234。

ContentHelper类包含一个帮助方法,可以从请求模型创建StringContent为JSON,这适用于POSTPUT请求。

集成测试的过程是:

  1. 在类构造函数中创建的Http客户端
  2. 定义请求:url和请求模型(如果适用)
  3. 发送请求
  4. 从响应中获取值
  5. 确保响应具有成功状态

运行集成测试

保存所有更改并构建WideWorldImporters.API.IntegrationTests项目,测试资源管理器将显示项目中的所有测试:

请记住要执行集成测试,您需要运行SQL Server实例,appsettings.json文件中的连接字符串将用于与SQL Server建立连接。

现在运行所有集成测试,测试资源管理器如下图所示:

如果执行集成测试时出现任何错误,请检查错误消息,查看代码并重复此过程。

代码挑战

此时,您具备扩展API的技能,将此作为挑战并添加以下测试(单元测试和集成测试):

测试

描述

按参数获取库存商品

为使库存物品通过搜索请求lastEditedBy,colorID,outerPackageID,supplierID,unitPackageID的参数。

获取不存在的库存商品

使用不存在的ID获取库存项并检查Web API返回NotFound(404)状态。

添加具有现有名称的库存项目

添加具有现有名称的库存项,并检查Web API返回BadRequest(400)状态。

添加没有必填字段的库存商品

添加没有必填字段的库存项目并检查Web API返回BadRequest(400)状态。

更新不存在的库存项目

使用不存在的ID更新库存项目并检查Web API返回NotFound(404)状态。

更新现有库存项目而不包含必填字段

更新没有必填字段的现有库存项,并检查Web API返回BadRequest(400)状态。

删除不存在的库存项目

使用不存在的ID删除库存项目并检查Web API返回NotFound(404)状态。

删除包含订单的库存商品

使用不存在的ID删除库存项目并检查Web API返回NotFound(404)状态。

遵循单元和集成测试中使用的约定来完成此挑战。

祝好运!

代码改进

  • 说明如何使用.NET Core的命令行
  • 添加Web API的帮助页面
  • 添加API的安全性(身份验证和授权)
  • 拆分文件中的模型定义
  • 在Web API项目之外重构模型
  • 还要别的吗?请在评论中告诉我

相关链接

  • 使用dotnet test和xUnit对.NET Core中的C#进行单元测试
  • ASP.NET Core中的集成测试
  • 开始使用Swashbuckle和ASP.NET Core

原文地址:https://www.codeproject.com/Articles/1264219/Creating-Web-API-in-ASP-NET-Core-2-0

在ASP.NET Core 2.0中创建Web API相关推荐

  1. 在ASP.NET Core 2.2 中创建 Web API并结合Swagger

    一.创建 ASP.NET Core WebApi项目 二.添加 三. ----------------------------------------------------------- 一.创建项 ...

  2. 使用Http-Repl工具测试ASP.NET Core 2.2中的Web Api项目

    今天,Visual Studio中没有内置工具来测试WEB API.使用浏览器,只能测试http GET请求.您需要使用Postman,SoapUI,Fiddler或Swagger等第三方工具来执行W ...

  3. ASP.NET Core 3.0中使用动态控制器路由

    原文:Dynamic controller routing in ASP.NET Core 3.0 作者:Filip W 译文:https://www.cnblogs.com/lwqlun/p/114 ...

  4. 避免在 ASP.NET Core 3.0 中为启动类注入服务

    本篇是如何升级到ASP.NET Core 3.0系列文章的第二篇. Part 1 - 将.NET Standard 2.0 类库转换为.NET Core 3.0 类库 Part 2 - IHostin ...

  5. ASP.Net Core 2.0中的Razor Page不是WebForm

    随着.net core2.0的发布,我们可以创建2.0的web应用了.2.0中新东西的出现,会让我们忘记老的东西,他就是Razor Page.下面的这篇博客将会介绍ASP.Net Core 2.0中的 ...

  6. asp.net core 3.0 中使用 swagger

    asp.net core 3.0 中使用 swagger Intro 上次更新了 asp.net core 3.0 简单的记录了一下 swagger 的使用,那个项目的 api 比较简单,都是匿名接口 ...

  7. 在ASP.NET Core 2.0中使用CookieAuthentication

    在ASP.NET Core中关于Security有两个容易混淆的概念一个是Authentication(认证),一个是Authorization(授权).而前者是确定用户是谁的过程,后者是围绕着他们允 ...

  8. 在 ASP.NET 2.0 中创建 Web 应用程序主题(一)

    在ASP.net2.0中创建Web应用程序主题 来源:MSDN 适用范围: MicrosoftASP.NET2.0 MicrosoftVisualStudio2005 摘要:StephenWalthe ...

  9. 【视频教程】使用 ASP.NET Core 3.x 构建 RESTful Web API 已完结

    使用 ASP.NET Core 3.x 构建 RESTful Web API 的视频教程已经完结,共50讲,约10.5小时. B站可看,点击原文链接. 度娘盘可下载完整视频: https://pan. ...

最新文章

  1. HTML5背后的故事
  2. Aggregate累加器
  3. RSA公钥格式PKCS#1,PKCS#8互转(微信获取RSA加密公钥)
  4. 元学习论文OPTIMIZATION AS A MODEL FOR FEW-SHOT LEARNING的简单理解
  5. 2019/Province_C_C++_A/B/数列求值
  6. 开发直播APP软件一定要了解的H.264编码,即时通讯中的战斗机
  7. Hive体系结构(二)Hive的执行原理、与关系型数据库的比较
  8. python字典的键可以是列表吗_如何返回字典键作为Python中的列表?
  9. 极兔正式入股百世快递
  10. numpy随机生成01矩阵_Python数据分析Numpy库常用函数详解,提到循环就该想到的库...
  11. oracle脚本转mpp脚本,范本:使用expdp/impdp克隆生成一个新数据库
  12. 谷粒商城:13.分布式基础篇总结
  13. 路由器连接、静态路由配置实例
  14. Anaconda3安装及opencv配置
  15. 微信小程序的组件传值
  16. 《大话数据结构(C实现)》(Yanlz+VR云游戏+Unity+SteamVR+云技术+5G+AI+软件架构设计+框架编程+数据结构+链表+栈+队列+二叉树+冒泡排序+KMP模式+立钻哥哥+==)
  17. TNS-12555 permission denied
  18. 什么样的人适合搞科研?
  19. Visual Studio完成并换行快捷键
  20. 《阿里云服务器教程2》:如何远程连接linux系统阿里云服务器ECS

热门文章

  1. oracle视频教程 任务,Oracle基础教程(两位专家手把手教你完成各种常见任务)
  2. 7a系列mrcc xilinx_artix-7A200T的输入时钟(50M)管脚接到MRCC的N端了,怎么解决?
  3. python读取文件名包含某字符的文件_python 读写文件时判断文件名是否包含某字符串...
  4. js几个页面生成pdf 然后批量打印_太好用了!这款免费PDF工具能够满足你的各种需求...
  5. 情人节海报psd分层模板|好的图层,任你选择
  6. canva怎么拼接图片_小间距LED显示屏怎么拼接成2K,4K和8K显示屏_小间距显示屏厂家为您科普...
  7. C++ Semaphore信号量使用
  8. 地震勘探专业词汇(3)地震波动力学
  9. EPTP 和 EPT 分页结构条目的格式
  10. TCP/IP:SCTP报文格式