在ASP.NET Core 2.0中创建Web API
目录
介绍
先决条件
软件
技能
使用代码
第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并按照下列步骤操作:
- 转到文件>新建>项目
- 转到已安装> Visual C#> .NET Core
- 将项目名称设置为 WideWorldImporters.API
- 单击确定
在下一个窗口中,选择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();}}
}
所有控制器操作的过程是:
- 记录方法的调用。
- 根据操作(分页,列表或单个)创建响应实例。
- 通过DbContext实例执行对数据库的访问。
- 如果存储库调用失败,请将DidError属性设置为true和设置ErrorMessage属性:出现内部错误,请联系技术支持。,因为不建议在响应中公开错误详细信息,所以最好将所有异常详细信息保存在日志文件中。
- 将结果作为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文档:
- 右键单击Project> Properties
- 转到Build > Output
- 启用XML文档文件
- 保存更改
现在,按F5开始调试Web API项目,如果一切正常,我们将在浏览器中获得以下输出:
另外,我们可以在另一个标签中加载帮助页面:
步骤07 - 添加单元测试
要为API项目添加单元测试,请按照下列步骤操作:
- 右键单击Solution> Add> New Project
- 转到已安装> Visual C#>测试> xUnit测试项目(.NET Core)
- 将项目名称设置为 WideWorldImporters.API.UnitTests
- 单击确定
管理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数据库(关系,事务等)。
单元测试的过程是:
- 创建一个WideWorldImportersDbContext实例
- 创建一个控制器实例
- 调用控制器的方法
- 从控制器的调用中获取值
- 释放WideWorldImportersDbContext实例(占用空间)
- 验证响应
运行单元测试
保存所有更改并构建WideWorldImporters.API.UnitTests项目。
现在,检查测试资源管理器中的测试:
使用测试资源管理器运行所有测试,如果出现任何错误,请检查错误消息,查看代码并重复此过程。
步骤08 - 添加集成测试
要为API项目添加集成测试,请按照下列步骤操作:
- 右键单击Solution> Add> New Project
- 转到已安装> Visual C#>测试> xUnit测试项目(.NET Core)
- 将项目名称设置为 WideWorldImporters.API.IntegrationTests
- 单击确定
管理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,这适用于POST和PUT请求。
集成测试的过程是:
- 在类构造函数中创建的Http客户端
- 定义请求:url和请求模型(如果适用)
- 发送请求
- 从响应中获取值
- 确保响应具有成功状态
运行集成测试
保存所有更改并构建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相关推荐
- 在ASP.NET Core 2.2 中创建 Web API并结合Swagger
一.创建 ASP.NET Core WebApi项目 二.添加 三. ----------------------------------------------------------- 一.创建项 ...
- 使用Http-Repl工具测试ASP.NET Core 2.2中的Web Api项目
今天,Visual Studio中没有内置工具来测试WEB API.使用浏览器,只能测试http GET请求.您需要使用Postman,SoapUI,Fiddler或Swagger等第三方工具来执行W ...
- ASP.NET Core 3.0中使用动态控制器路由
原文:Dynamic controller routing in ASP.NET Core 3.0 作者:Filip W 译文:https://www.cnblogs.com/lwqlun/p/114 ...
- 避免在 ASP.NET Core 3.0 中为启动类注入服务
本篇是如何升级到ASP.NET Core 3.0系列文章的第二篇. Part 1 - 将.NET Standard 2.0 类库转换为.NET Core 3.0 类库 Part 2 - IHostin ...
- ASP.Net Core 2.0中的Razor Page不是WebForm
随着.net core2.0的发布,我们可以创建2.0的web应用了.2.0中新东西的出现,会让我们忘记老的东西,他就是Razor Page.下面的这篇博客将会介绍ASP.Net Core 2.0中的 ...
- asp.net core 3.0 中使用 swagger
asp.net core 3.0 中使用 swagger Intro 上次更新了 asp.net core 3.0 简单的记录了一下 swagger 的使用,那个项目的 api 比较简单,都是匿名接口 ...
- 在ASP.NET Core 2.0中使用CookieAuthentication
在ASP.NET Core中关于Security有两个容易混淆的概念一个是Authentication(认证),一个是Authorization(授权).而前者是确定用户是谁的过程,后者是围绕着他们允 ...
- 在 ASP.NET 2.0 中创建 Web 应用程序主题(一)
在ASP.net2.0中创建Web应用程序主题 来源:MSDN 适用范围: MicrosoftASP.NET2.0 MicrosoftVisualStudio2005 摘要:StephenWalthe ...
- 【视频教程】使用 ASP.NET Core 3.x 构建 RESTful Web API 已完结
使用 ASP.NET Core 3.x 构建 RESTful Web API 的视频教程已经完结,共50讲,约10.5小时. B站可看,点击原文链接. 度娘盘可下载完整视频: https://pan. ...
最新文章
- HTML5背后的故事
- Aggregate累加器
- RSA公钥格式PKCS#1,PKCS#8互转(微信获取RSA加密公钥)
- 元学习论文OPTIMIZATION AS A MODEL FOR FEW-SHOT LEARNING的简单理解
- 2019/Province_C_C++_A/B/数列求值
- 开发直播APP软件一定要了解的H.264编码,即时通讯中的战斗机
- Hive体系结构(二)Hive的执行原理、与关系型数据库的比较
- python字典的键可以是列表吗_如何返回字典键作为Python中的列表?
- 极兔正式入股百世快递
- numpy随机生成01矩阵_Python数据分析Numpy库常用函数详解,提到循环就该想到的库...
- oracle脚本转mpp脚本,范本:使用expdp/impdp克隆生成一个新数据库
- 谷粒商城:13.分布式基础篇总结
- 路由器连接、静态路由配置实例
- Anaconda3安装及opencv配置
- 微信小程序的组件传值
- 《大话数据结构(C实现)》(Yanlz+VR云游戏+Unity+SteamVR+云技术+5G+AI+软件架构设计+框架编程+数据结构+链表+栈+队列+二叉树+冒泡排序+KMP模式+立钻哥哥+==)
- TNS-12555 permission denied
- 什么样的人适合搞科研?
- Visual Studio完成并换行快捷键
- 《阿里云服务器教程2》:如何远程连接linux系统阿里云服务器ECS
热门文章
- oracle视频教程 任务,Oracle基础教程(两位专家手把手教你完成各种常见任务)
- 7a系列mrcc xilinx_artix-7A200T的输入时钟(50M)管脚接到MRCC的N端了,怎么解决?
- python读取文件名包含某字符的文件_python 读写文件时判断文件名是否包含某字符串...
- js几个页面生成pdf 然后批量打印_太好用了!这款免费PDF工具能够满足你的各种需求...
- 情人节海报psd分层模板|好的图层,任你选择
- canva怎么拼接图片_小间距LED显示屏怎么拼接成2K,4K和8K显示屏_小间距显示屏厂家为您科普...
- C++ Semaphore信号量使用
- 地震勘探专业词汇(3)地震波动力学
- EPTP 和 EPT 分页结构条目的格式
- TCP/IP:SCTP报文格式