在 ASP.NET Core 2.0 中创建 Web API
在本指南中,我们将使用 WideWorldImporters 数据库来创建 Web API。
引言
让我们使用最新版本的 ASP.NET Core 和 Entity Framework Core 创建一个 Web API。
在本指南中,我们将使用 WideWorldImporters
数据库来创建 Web API。
REST API 至少提供以下操作:
GET
POST
PUT
删除
REST 还有其他操作,但本指南不需要它们。
这些操作允许客户端通过 REST API 执行操作,因此我们的 Web API 必须包含这些操作。
WideWorldImporters
数据库包含 4 个架构
Application
采购
销售
仓库
在本指南中,我们将处理 Warehouse.StockItems
表。我们将添加代码来处理此实体:允许从数据库检索库存项、按 ID 检索库存项、创建、更新和删除库存项。
此 API 的版本是 1。
这是 API 的路由表
动词 | Url | 描述 |
---|---|---|
GET | api/v1/Warehouse/StockItem | 检索库存项 |
GET | api/v1/Warehouse/StockItem/id | 按 ID 检索库存项 |
POST | api/v1/Warehouse/StockItem | 创建新的库存项 |
PUT | api/v1/Warehouse/StockItem/id | 更新现有库存项 |
删除 | api/v1/Warehouse/StockItem/id | 删除现有库存项 |
请记住这些路由,因为 API 必须实现所有路由。
必备组件
软件
- .NET Core
- Visual Studio 2017(最新更新)
- SQL Server
- WideWorldImporters 数据库
技能
- C#
- ORM(对象关系映射)
- TDD(测试驱动开发)
- RESTful 服务
Using the Code
本指南中,源代码的工作目录是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
现在,我们将通过右键单击 WideWorldImporters.API
项目来安装 Nuget 中的 EntityFrameworkCore.SqlServer
包。
切换到“浏览”选项卡,然后键入 Microsoft.EntityFrameworkCore.SqlServer
。
接下来,安装 Swashbuckle.AspNetCore
包。
Swashbuckle.AspNetCore
包允许为 Web API 启用帮助页面。
这是项目的结构。
现在运行项目以检查解决方案是否已准备好,按 F5,Visual Studio 将显示此浏览器窗口。
默认情况下,Visual Studio 会在Controllers目录中添加一个名为ValuesController
的文件,请将其从项目中删除。
步骤 03 - 添加模型
现在,创建一个名为Models
的目录,并添加以下文件:
- Domain.cs
- Extensions.cs
- Requests.cs
- Responses.cs
Domain.cs 将包含所有与Entity Framework Core相关的代码。
Extensions.cs 将包含 DbContext 和集合的扩展方法。
Requests.cs 将包含请求的定义。
Responses.cs 将包含响应的定义。
Domain.cs 文件的代码
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
public 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 entity
builder.ToTable("StockItems", "Warehouse");
// Set key for entity
builder.HasKey(p => p.StockItemID);
// Set configuration for columns
builder.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();
// Columns with default value
builder
.Property(p => p.StockItemID)
.HasColumnType("int")
.IsRequired()
.HasDefaultValueSql("NEXT VALUE FOR [Sequences].[StockItemID]");
// Computed columns
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 update
builder
.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 entity
modelBuilder
.ApplyConfiguration(new StockItemsConfiguration());
base.OnModelCreating(modelBuilder);
}
public DbSet<StockItem> StockItems { get; set; }
}
#pragma warning restore CS1591
}
Extensions.cs 文件的代码
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace WideWorldImporters.API.Models
{
#pragma warning disable CS1591
public 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 DbSet
var 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 CS1591
public 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 CS1591
public 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 实例的扩展方法,一个用于检索库存项,另一个用于按 ID 检索库存项,最后一个用于按名称检索库存项。
IQueryableExtensions
包含 IQueryable
的扩展方法,用于添加分页功能。
请求
我们有以下定义:
PostStockItemsRequest
PutStockItemsRequest
PostStockItemsRequest
代表创建新库存项的模型,包含保存到数据库所需的所有属性。
PutStockItemsRequest
代表更新现有库存项的模型,在这种情况下,它只包含 4 个属性:StockItemName
、SupplierID
、ColorID
和 UnitPrice
。此类不包含 StockItemID
属性,因为 ID 存在于控制器操作的路由中。
请求的模型不需要包含与实体相同的全部属性,因为我们不需要在请求或响应中公开完整的定义,使用属性较少的模型来限制数据是一个好习惯。
Extensions
类包含 PostStockItemsRequest
的一个扩展方法,用于从请求模型返回 StockItem
类的实例。
响应
这些是接口:
IResponse
ISingleResponse<TModel>
IListResponse<TModel>
IPagedResponse<TModel>
这些接口中的每一个都有实现,为什么我们需要这些定义,如果直接返回对象而不是将它们包装在这些模型中更简单?请记住,此 Web API 将为客户端提供操作,无论是否有 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 repository
var query = DbContext.GetStockItems();
// Set paging values
response.PageSize = pageSize;
response.PageNumber = pageNumber;
// Get the total rows
response.ItemsCount = await query.CountAsync();
// Get the specific page from database
response.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 id
response.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 model
var entity = request.ToEntity();
// Add entity to repository
DbContext.Add(entity);
// Save entity in database
await DbContext.SaveChangesAsync();
// Set the entity to response model
response.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="404">If stock item is not exists</response>
/// <response code="500">If there was an internal server error</response>
[HttpPut("StockItem/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
[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 id
var entity = await DbContext.GetStockItemsAsync(new StockItem(id));
// Validate if entity exists
if (entity == null)
return NotFound();
// Set changes to entity
entity.StockItemName = request.StockItemName;
entity.SupplierID = request.SupplierID;
entity.ColorID = request.ColorID;
entity.UnitPrice = request.UnitPrice;
// Update entity in repository
DbContext.Update(entity);
// Save entity in database
await 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="404">If stock item is not exists</response>
/// <response code="500">If there was an internal server error</response>
[HttpDelete("StockItem/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
[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 id
var entity = await DbContext.GetStockItemsAsync(new StockItem(id));
// Validate if entity exists
if (entity == null)
return NotFound();
// Remove entity from repository
DbContext.Remove(entity);
// Delete entity in database
await 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、服务、日志记录等实例的配置。
修改 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 CS1591
public 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 file
services.AddDbContext<WideWorldImportersDbContext>(builder =>
{
builder.UseSqlServer(Configuration["AppSettings:ConnectionString"]);
});
// Set up dependency injection for controller's logger
services.AddScoped<ILogger, Logger<WarehouseController>>();
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "WideWorldImporters API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.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(options =>
{
options.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 文档。
- 右键单击项目 > 属性
- 转到生成 > 输出
- 启用XML 文档文件
- 保存更改
现在,按 F5 开始调试 Web API 项目,如果一切正常,我们将在浏览器中获得以下输出:
另外,我们可以在另一个选项卡中加载帮助页面。
步骤 07 - 添加单元测试
为了给 API 项目添加单元测试,请按照以下步骤操作:
- 右键单击解决方案 > 添加 > 新项目
- 转到已安装 > 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(断言);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 instance
var options = new DbContextOptionsBuilder<WideWorldImportersDbContext>()
.UseInMemoryDatabase(databaseName: dbName)
.Options;
// Create instance of DbContext
var dbContext = new WideWorldImportersDbContext(options);
// Add entities in memory
dbContext.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 instance
dbContext.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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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 = new DateTime(2016, 5, 31, 23, 11, 0),
ValidTo = new DateTime(9999, 12, 31, 23, 59, 59)
});
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()
{
// Arrange
var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemsAsync));
var controller = new WarehouseController(null, dbContext);
// Act
var response = await controller.GetStockItemsAsync() as ObjectResult;
var value = response.Value as IPagedResponse<StockItem>;
dbContext.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetStockItemAsync()
{
// Arrange
var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestGetStockItemAsync));
var controller = new WarehouseController(null, dbContext);
var id = 1;
// Act
var response = await controller.GetStockItemAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<StockItem>;
dbContext.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestPostStockItemAsync()
{
// Arrange
var 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)
};
// Act
var response = await controller.PostStockItemAsync(requestModel) as ObjectResult;
var value = response.Value as ISingleResponse<StockItem>;
dbContext.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestPutStockItemAsync()
{
// Arrange
var 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
};
// Act
var response = await controller.PutStockItemAsync(id, requestModel) as ObjectResult;
var value = response.Value as IResponse;
dbContext.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestDeleteStockItemAsync()
{
// Arrange
var dbContext = DbContextMocker.GetWideWorldImportersDbContext(nameof(TestDeleteStockItemAsync));
var controller = new WarehouseController(null, dbContext);
var id = 5;
// Act
var response = await controller.DeleteStockItemAsync(id) as ObjectResult;
var value = response.Value as IResponse;
dbContext.Dispose();
// Assert
Assert.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 项目添加集成测试,请按照以下步骤操作:
- 右键单击解决方案 > 添加 > 新项目
- 转到已安装 > 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(""))
{
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
Server.Dispose();
}
protected virtual void InitializeServices(IServiceCollection services)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var manager = new ApplicationPartManager
{
ApplicationParts =
{
new AssemblyPart(startupAssembly)
},
FeatureProviders =
{
new ControllerFeatureProvider(),
new ViewComponentFeatureProvider()
}
};
services.AddSingleton(manager);
}
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));
// Create instance of test server
Server = new TestServer(webHostBuilder);
// Add configuration for client
Client = Server.CreateClient();
Client.BaseAddress = new Uri("https://:5001");
Client.DefaultRequestHeaders.Accept.Clear();
Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
}
}
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()
{
// Arrange
var request = new
{
Url = "/api/v1/Warehouse/StockItem"
};
// Act
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetStockItemAsync()
{
// Arrange
var request = new
{
Url = "/api/v1/Warehouse/StockItem/1"
};
// Act
var response = await Client.GetAsync(request.Url);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestPostStockItemAsync()
{
// Arrange
var request = new
{
Url = "/api/v1/Warehouse/StockItem",
Body = 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)
}
};
// Act
var response = await Client.PostAsync(request.Url, ContentHelper.GetStringContent(request.Body));
var value = await response.Content.ReadAsStringAsync();
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestPutStockItemAsync()
{
// Arrange
var request = new
{
Url = "/api/v1/Warehouse/StockItem/1",
Body = new
{
StockItemName = string.Format("USB anime flash drive - Vegeta {0}", Guid.NewGuid()),
SupplierID = 12,
Color = 3,
UnitPrice = 39.00m
}
};
// Act
var response = await Client.PutAsync(request.Url, ContentHelper.GetStringContent(request.Body));
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestDeleteStockItemAsync()
{
// Arrange
var postRequest = new
{
Url = "/api/v1/Warehouse/StockItem",
Body = 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)
}
};
// Act
var postResponse = await Client.PostAsync(postRequest.Url, ContentHelper.GetStringContent(postRequest.Body));
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));
// Assert
postResponse.EnsureSuccessStatusCode();
Assert.False(singleResponse.DidError);
deleteResponse.EnsureSuccessStatusCode();
}
}
}
正如我们所见,WarehouseTests
包含 Web API 的所有测试,这些是方法:
方法 | 描述 |
TestGetStockItemsAsync | 检索库存项 |
TestGetStockItemAsync | 按 ID 检索现有库存项 |
TestPostStockItemAsync | 创建新的库存项 |
TestPutStockItemAsync | 更新现有库存项 |
TestDeleteStockItemAsync | 删除现有库存项 |
集成测试如何工作?
TestFixture<TStartup>
类为 Web API 提供 HTTP 客户端,使用项目中的Startup
类作为引用来应用客户端的配置。
WarehouseTests
类包含发送 HTTP 请求到 Web API 的所有方法,HTTP 客户端的端口号是 1234
。
ContentHelper
类包含一个辅助方法,用于从请求模型创建 JSON 格式的 StringContent
,这适用于 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 的命令行
- 为 API 添加安全性(身份验证和授权)
- 将模型定义拆分到文件中
- 将模型重构到 Web API 项目之外
- 还有什么?请在评论中告诉我:)
关注点
- 在本文中,我们正在使用Entity Framework Core。
- Entity Framework Core 具有内存数据库。
- 我们可以调整所有存储库以公开特定的操作,在某些情况下,我们不希望有
GetAll
、Add
、Update
或Delete
操作。 - 单元测试执行程序集的测试。
- 集成测试执行 HTTP 请求的测试。
- 所有测试均使用xUnit框架创建。
相关链接
历史
- 2018 年 10 月 22 日:初始版本
- 2018 年 11 月 22 日:移除存储库模式
- 2018 年 12 月 11 日:添加 Web API 的帮助页面