上一篇咱們介紹了數據塑形,HATEOAS和內容協商,並在制器方法中完成了對應功能的添加;本章咱們將介紹日誌和測試相關的概念,並添加對應的功能html
在第一章介紹項目結構時,有提到.NET Core啓動時默認加載了日誌服務,且在appsetting.json文件配置了一些日誌的設置,根據設置的日誌等級的不一樣能夠進行不一樣級別的信息的顯示,但它沒法作到輸出固定格式的log信息至本地磁盤或是數據庫,因此須要咱們本身手動實現,而咱們能夠藉助日誌框架實現。vue
ps:在第7章節中咱們記錄的是數據處理層方法調用的日誌信息,這裏記錄的則是ASP.NET Core WebAPI層級的日誌信息,二者有所差別web
.NET程序中經常使用的日誌框架有log4net,serilog 和Nlog,這裏咱們使用Serilog來實現相關功能,在BlogSystem.Core層使用NuGet安裝Serilog.AspNetCore,同時還須要搜索Serilog.Skins安裝但願支持的功能,這裏咱們但願添加對文件和控制檯的輸出,因此選擇安裝的是Serilog.Skins.File和Serilog.Skins.Console正則表達式
須要注意的是Serilog是不受appsetting.json的日誌設置影響的,且它能夠根據命名空間重寫記錄級別。還有一點須要注意的是須要手動對Serilog對象進行資源的釋放,不然在系統運行期間,沒法打開日誌文件。數據庫
在BlogSystem.Core項目中添加一個Logs文件夾,並在Program類中進行Serilog對象的添加和使用,以下:json
一、這個時候其實系統已經使用Serilog替換了系統自帶的log對象,以下圖,Serilog會根據相關信息進行高亮顯示:c#
二、這個時候問題就來了,咱們怎麼才能進行全局的添加呢,總不能一個方法一個方法的添加吧?還記得以前咱們介紹AOP時提到的過濾器Filter嗎?ASP.NET Core中一共有五類過濾器,分別是:後端
過濾器的具體執行順序以下:api
三、這裏咱們能夠藉助異常過濾器實現全局日誌功能的添加;在在BlogSystem.Core項目添加一個Filters文件夾,添加一個名爲ExceptionFilter的類,繼承IExceptionFilter接口,這裏是參考老張的哲學的簡化版本,實現以下:數組
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; using Serilog; using System; namespace BlogSystem.Core.Filters { public class ExceptionsFilter : IExceptionFilter { private readonly ILogger<ExceptionsFilter> _logger; public ExceptionsFilter(ILogger<ExceptionsFilter> logger) { _logger = logger; } public void OnException(ExceptionContext context) { try { //錯誤信息 var msg = context.Exception.Message; //錯誤堆棧信息 var stackTraceMsg = context.Exception.StackTrace; //返回信息 context.Result = new InternalServerErrorObjectResult(new { msg, stackTraceMsg }); //記錄錯誤日誌 _logger.LogError(WriteLog(context.Exception)); } catch (Exception e) { Console.WriteLine(e); throw; } finally { //記得釋放,不然運行時沒法打開日誌文件 Log.CloseAndFlush(); } } //返回500錯誤 public class InternalServerErrorObjectResult : ObjectResult { public InternalServerErrorObjectResult(object value) : base(value) { StatusCode = StatusCodes.Status500InternalServerError; } } //自定義格式內容 public string WriteLog(Exception ex) { return $"【異常信息】:{ex.Message} \r\n 【異常類型】:{ex.GetType().Name} \r\n【堆棧調用】:{ex.StackTrace}"; } } }
四、在Startup類的ConfigureServices方法中進行異常處理過濾器的註冊,以下:
五、咱們在控制器方法中拋出一個異常,分別查看效果以下,若是以爲信息太多,可調整日誌記錄級別:
這裏咱們從測試的類別出發,瞭解下測試相關的內容,並添加相關的測試(介紹內容大部分來自微軟官方文檔,爲了更易理解,從我的習慣的角度進行了修改,若有形容不當之處,可在評論區指出)
一、自動測試是確保軟件應用程序按照做者指望執行操做的一種絕佳方式。軟件應用有多種類型的測試,包括單元測試、集成測試、Web測試、負載測試和其餘測試。單元測試用於測試我的軟件的組件或方法,並不包括如數據庫、文件系統和網絡資源類的基礎結構測試。
固然咱們可使用編寫測試的最佳方法,如測試驅動開發(TDD)所指的先編寫單元測試,再編寫該單元測試要檢查的代碼,就比如先編寫書籍的大綱,再編寫書籍。其主要目的是爲了幫助開發人員編寫更簡單,更具可讀性的高效代碼。二者區別以下(來自Edison Zhou)
二、以深度(測試的細緻程度)和廣度(測試的覆蓋程度)區分, 測試分類以下(此處內容來自solenovex):
Unit Test 單元測試:它能夠測試一個類或者一個類的某個功能,但其覆蓋程度較低;
Integration Test 集成測試:它的細緻程度沒有單元測試高,可是有較好的覆蓋程度,它能夠測試功能的組合,以及像數據庫或文件系統這樣的外部資源;
Subcutaneous Test 皮下測試 :其做用區域爲UI層的下一層,有較好的覆蓋程度,可是深度欠佳;
UI測試:直接從UI層進行測試,覆蓋程度很高,可是深度欠佳
三、在編寫單元測試時,儘可能不要引入基礎結構依賴項,這些依賴項會下降測試速度,使測試更加脆弱,咱們應當將其保留供集成測試使用。能夠經過遵循顯示依賴項原則和使用依賴項注入避免應用程序中的這些依賴項,還能夠將單元測試保留在單獨的項目中與集成測試相分離,以確保單元測試項目沒有引用或依賴於基礎結構包。
總結下經常使用的單元測試和集成測試,單元測試會與外部資源隔離,以保證結果的一致性;而集成測試會依賴外部資源,且覆蓋面更廣。
一、爲何須要測試?咱們從以單元測試爲例從4個方面進行說明:
二、優質的測試須要符合哪些特徵,一樣以單元測試爲例:
在具體的執行時,咱們應當遵循一些最佳實踐規則,具體請參考微軟官方文檔單元測試最佳作法
經常使用的單元測試框架有MSTest、xUnit和NUnit,這裏咱們以xUnit爲例進行相關的說明
首先咱們要明確如何編寫測試代碼,通常來講,測試分爲三個主要操做:
Assert時一般會對不一樣類型的返回值進行判斷,而在xUnit中是支持多種返回值類型的,經常使用的類型以下:
boolean:針對方法返回值爲bool的結果,能夠判斷結果是true或false
string:針對方法返回值爲string的結果,能夠判斷結果是否相等,是否以某字符串開頭或結尾,是否包含某些字符,並支持正則表達式
數值型:針對方法返回值爲數值的結果,能夠判斷數值是否相等,數值是否在某個區間內,數值是否爲null或非null
Collection:針對方法返回值爲集合的結果,能夠針對集合內全部元素或至少一個元素判斷其是否包含某某字符,兩個集合是否相等
ObjectType:針對方法返回值爲某種類型的狀況,能夠判斷是否爲預期的類型,一個類是否繼承於另外一個類,兩個類是否爲同一實例
Raised event:針對事件是否執行的狀況,能夠判斷方法內部是否執行了預期的事件
在xUnit中還有一些經常使用的特性,可做用於方法或類,以下:
[Fact]:用來標註該方法爲測試方法
[Trait("Name","Value")]:用來對測試方法進行分組,支持標註多個不一樣的組名
[Fact(Skip="忽略說明...")]:用來修飾須要忽略測試的方法
在測試時咱們應當注意性能上的問題,針對一個對象供多個方法使用的狀況,咱們可使用共享上下文
須要注意在使用IClassFixture和ICollectionFixture對象時應當避免多個測試方法之間相互影響的狀況
在進行測試方法時,一般咱們會指定輸入值和輸出值,如但願多測試幾種狀況,咱們能夠定義多個測試方法,但這顯然不是一個最佳的實現;在合理的狀況下,咱們能夠將參數和數據分離,如何實現?
首先咱們右鍵項目解決方案選擇添加一個項目,輸入選擇xUnit後進行添加,項目命名爲BlogSystem.Core.Test,以下:
項目添加完成後咱們須要添加對測試項目的引用,在解決方案中右擊依賴項選擇添加BlogSystem.Core;這裏咱們預期對Controller進行測試,但後續有可能會添加其餘項目的測試,因此咱們創建一個Controller_Test文件夾保證項目結構相對清晰。
在BlogSystem.Core.Test項目的Controller_Test文件夾下新建一個命名爲UserController_Should的方法;在微軟的《單元測試的最佳作法》文檔中有提到,測試命名應該包括三個部分:①被測試方法的名稱②測試的方案③方案預期行爲;實際使用時也能夠對照測試的方法進行命名,這裏咱們先不考慮最佳命名原則,僅對照測試方法進行命名,以下:
using Xunit; namespace BlogSystem.Core.Test.Controller_Test { public class UserController_Should { [Fact] public void Register_Test() { } } }
一、在進行測試時,咱們能夠根據實際狀況使用如下方案來進行測試:
這裏咱們以測試UserController爲例,其構造函數包含了接口服務實例和HttpContext對象實例,Action方法內部又有數據庫鏈接操做,從嚴格意義上來說測試這類方法已經脫離了單元測試的範疇,屬於集成測試,但這類測試必定程度上能夠節省咱們大量的重複勞動。這裏咱們選擇方案三進行相關的測試。
二、如何使用TestHost對象?先來看看它的工做流程,首先它會建立一個IHostBuilder對象,並用它建立一個TestServer對象,TestServer對象能夠建立HttpClient對象,該對象支持發送及響應請求,以下圖所示(來自solenovex):
在嘗試使用該對象的過程當中咱們會發現一個問題,建立IHostBuilder對象時須要指明相似Startup的配置項,由於這裏是測試環境,因此實際上會與BlogSystem.Core中的配置類StartUp存在必定的差別,於是這裏咱們須要爲測試新創建一個Startup配置類。
一、咱們在測試項目中添加名爲TestServerFixture 的類和名爲TestStartup的類,TestServerFixture 用來建立HttpClient對象並作一些準備工做,TestStartup類爲配置類。而後使用Nuget安裝Microsoft.AspNetCore.TestHost;TestServerFixture 和TestStartup實現以下:
using Autofac.Extensions.DependencyInjection; using BlogSystem.Core.Helpers; using BlogSystem.Model; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Hosting; using System; using System.Net.Http; namespace BlogSystem.Core.Test { public static class TestServerFixture { public static IHostBuilder GetTestHost() { return Host.CreateDefaultBuilder() .UseServiceProviderFactory(new AutofacServiceProviderFactory())//使用autofac做爲DI容器 .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseTestServer()//創建TestServer——測試的關鍵 .UseEnvironment("Development") .UseStartup<TestStartup>(); }); } //生成帶token的httpclient public static HttpClient GetTestClientWithToken(this IHost host) { var client = host.GetTestClient(); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {GenerateJwtToken()}");//把token加到Header中 return client; } //生成JwtToken public static string GenerateJwtToken() { TokenModelJwt tokenModel = new TokenModelJwt { UserId = userData.Id, Level = userData.Level.ToString() }; var token = JwtHelper.JwtEncrypt(tokenModel); return token; } //測試用戶的數據 private static readonly User userData = new User { Account = "jordan", Id = new Guid("9CF2DAB5-B9DC-4910-98D8-CBB9D54E3D7B"), Level = Level.普通用戶 }; } }
using Autofac; using Autofac.Extras.DynamicProxy; using BlogSystem.Common.Helpers; using BlogSystem.Common.Helpers.SortHelper; using BlogSystem.Core.AOP; using BlogSystem.Core.Filters; using BlogSystem.Core.Helpers; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using System; using System.IO; using System.Linq; using System.Reflection; using System.Text; namespace BlogSystem.Core.Test { public class TestStartup { private readonly IConfiguration _configuration; public TestStartup(IConfiguration configuration) { _configuration = GetConfig(null); //傳遞Configuration對象 JwtHelper.GetConfiguration(_configuration); } public void ConfigureServices(IServiceCollection services) { //控制器服務註冊 services.AddControllers(setup => { setup.ReturnHttpNotAcceptable = true;//開啓不存在請求格式則返回406狀態碼的選項 var jsonOutputFormatter = setup.OutputFormatters.OfType<SystemTextJsonOutputFormatter>()?.FirstOrDefault();//不爲空則繼續執行 jsonOutputFormatter?.SupportedMediaTypes.Add("application/vnd.company.hateoas+json"); setup.Filters.Add(typeof(ExceptionsFilter));//添加異常過濾器 }).AddXmlDataContractSerializerFormatters()//開啓輸出輸入支持XML格式 //jwt受權服務註冊 services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(x => { x.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, //驗證密鑰 IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration["JwtTokenManagement:secret"])), ValidateIssuer = true, //驗證發行人 ValidIssuer = _configuration["JwtTokenManagement:issuer"], ValidateAudience = true, //驗證訂閱人 ValidAudience = _configuration["JwtTokenManagement:audience"], RequireExpirationTime = true, //驗證過時時間 ValidateLifetime = true, //驗證生命週期 ClockSkew = TimeSpan.Zero, //緩衝過時時間,即便配置了過時時間,也要考慮過時時間+緩衝時間 }; }); //註冊HttpContext存取器服務 services.AddHttpContextAccessor(); //自定義判斷屬性隱射關係 services.AddTransient<IPropertyMappingService, PropertyMappingService>(); services.AddTransient<IPropertyCheckService, PropertyCheckService>(); } //configureContainer訪問AutoFac容器生成器 public void ConfigureContainer(ContainerBuilder builder) { //獲取程序集並註冊,採用每次請求都建立一個新的對象的模式 var assemblyBll = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.BLL.dll")); var assemblyDal = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.DAL.dll")); builder.RegisterAssemblyTypes(assemblyDal).AsImplementedInterfaces().InstancePerDependency(); //註冊攔截器 builder.RegisterType<LogAop>(); //對目標類型啓用動態代理,並注入自定義攔截器攔截BLL builder.RegisterAssemblyTypes(assemblyBll).AsImplementedInterfaces().InstancePerDependency() .EnableInterfaceInterceptors().InterceptedBy(typeof(LogAop)); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler(builder => { builder.Run(async context => { context.Response.StatusCode = 500; await context.Response.WriteAsync("Unexpected Error!"); }); }); } app.UseRouting(); //添加認證中間件 app.UseAuthentication(); //添加受權中間件 app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } private IConfiguration GetConfig(string environmentName) { var path = Microsoft.DotNet.PlatformAbstractions.ApplicationEnvironment.ApplicationBasePath; IConfigurationBuilder builder = new ConfigurationBuilder().SetBasePath(path) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); if (!string.IsNullOrWhiteSpace(environmentName)) { builder = builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true); } builder = builder.AddEnvironmentVariables(); return builder.Build(); } } }
二、這裏對UserController中的註冊、登陸、獲取用戶信息方法進行測試,實際上這裏的斷言並不嚴謹,會產生什麼後果?請繼續往下看
using BlogSystem.Model.ViewModels; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Hosting; using Newtonsoft.Json; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; using Xunit; namespace BlogSystem.Core.Test.Controller_Test { public class UserController_Should { const string _mediaType = "application/json"; readonly Encoding _encoding = Encoding.UTF8; /// <summary> /// 用戶註冊 /// </summary> [Fact] public async Task Register_Test() { // 一、Arrange var data = new RegisterViewModel { Account = "test", Password = "123456", RequirePassword = "123456" }; StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType); using var host = await TestServerFixture.GetTestHost().StartAsync();//啓動TestServer // 二、Act var response = await host.GetTestClient().PostAsync($"http://localhost:5000/api/user/register", content); var result = await response.Content.ReadAsStringAsync(); // 三、Assert Assert.DoesNotContain("用戶已存在", result); } /// <summary> /// 用戶登陸 /// </summary> [Fact] public async Task Login_Test() { var data = new LoginViewModel { Account = "jordan", Password = "123456" }; StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType); var host = await TestServerFixture.GetTestHost().StartAsync();//啓動TestServer var response = await host.GetTestClientWithToken().PostAsync($"http://localhost:5000/api/user/Login", content); var result = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain("帳號或密碼錯誤!", result); } /// <summary> /// 獲取用戶信息 /// </summary> [Fact] public async Task UserInfo_Test() { string id = "jordan"; using var host = await TestServerFixture.GetTestHost().StartAsync();//啓動TestServer var client = host.GetTestClient(); var response = await client.GetAsync($"http://localhost:5000/api/user/{id}"); var result = response.StatusCode; Assert.True(Equals(HttpStatusCode.OK, result)|| Equals(HttpStatusCode.NotFound, result)); } } }
一、添加完上述的測試方法後,咱們使用打開Visual Studio自帶的測試資源管理器,點擊運行全部測試,發現提示錯誤沒法加載BLL?在原先的BlogSystem.Core的StartUp類中咱們是加載BLL和DAL項目的dll來達到解耦的目的,因此作了一個將dll輸出到Core項目bin文件夾的動做,可是在測試項目的TestStarup類中,咱們是沒法加載到BLL和DAL的。我嘗試將BLL和DAL同時輸出到兩個路徑下,但未找到對應的方法,因此這裏我採用了最簡單的解決方法,測試項目添加了對DAL和BLL的引用。再次運行,以下圖,彷佛成功了??
二、咱們在測試方法內部打上斷點,右擊測試方法,選擇調試測試,結果發現response參數爲空,只應Assert不嚴謹致使看上去沒有問題;在各類查找後,我終於找到了解決辦法,在TestStarup類的ConfigureServices方法內部service.AddControllers方法最後加上這麼一句話便可解決 .AddApplicationPart(Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.Core.dll")))
三、再次運行測試方法,成功!可是又發現了另一個問題,這裏咱們只是測試,可是數據庫中卻出現了咱們測試添加的test帳號,如何解決?咱們可使用Microsoft.EntityFrameworkCore.InMemory庫 ,它支持使用內存數據庫進行測試,這裏暫未添加,有興趣的朋友能夠自行研究。
本人知識點有限,若文中有錯誤的地方請及時指正,方便你們更好的學習和交流。
本文部份內容參考了網絡上的視頻內容和文章,僅爲學習和交流,視頻地址以下:
老張的哲學,系列教程一目錄:.netcore+vue 先後端分離
我想吃晚飯,ASP.NET Core搭建多層網站架構【12-xUnit單元測試之集成測試】
solenovex,使用 xUnit.NET 對 .NET Core 項目進行單元測試
solenovex,ASP.NET Core Web API 集成測試
微軟官方文檔,.NET Core 和 .NET Standard 中的單元測試
Edison Zhou,.NET單元測試的藝術