.netcore持續集成測試篇之MVC層單元測試

  • 2019 年 10 月 3 日
  • 筆記

系列目錄

前面我們講的很多單元測試的的方法和技巧不論是在.net core和.net framework裡面都是通用的,但是mvc項目里有一種比較特殊的類是Controller,首先Controller類的返回結果跟普通的類並不一樣,普通的類返回的都是確定的類型,而mvc項目的返回的ActionResult或者core mvc里返回的IActionResult則是一個高度封裝的對象,想對它進行很細緻的測試並不是一件很容易的事.因此在編寫程式碼的時候建議盡量把業務邏輯的程式碼單元寫到單獨類中,Controller里只進行簡單的前端請求參數檢驗以及各自http狀態和數據的返回.還有一點就是Controller是在http請求到達後動態創建的,單元測試的時候很多對象諸如Httpcontext,Modelstate,request,response,routedata,uri,MetadataProvider等都是不存在的,和在http請求環境中有很大差別.但是我們仍然能通過對Controller進行單元測試做很多工作,確保結果是我們想要的.

確保Action返回正確View和ViewModel

我們使用HomeController裡面的Index方法,程式碼稍作修改

public IActionResult Index()          {              return View("Index","hello");          }

它的測試程式碼如下

        [Fact]          public void ViewTest()          {              HomeController hc = new HomeController();              var result = (ViewResult)hc.Index();              var viewName = result.ViewName;              var model = (string)result.Model;              Assert.True(viewName == "Index" && model == "hello");          }

首先我們先創建一個Controller類,由於業務上我們需要這個方法返回一個View,這是提前預知的,所以我們把hc.Index的結果轉為ViewResult,如果轉換失敗則說明程式中存在bug.

下面是分別獲取View的名稱的數據模型,然後我們斷言View名稱是Index,model的值是hello,當然以上程式碼比較簡單顯然是能通過的,在實際業務中我們還要對Model進行更為複雜的斷言.

需要注意的是,Action返回的view並不是都有名稱的,如果是返回的本方法對應的view,默認名稱是可以省略的,這樣以上斷言就會失敗,因此如果名稱不寫的時候我們可以斷言ViewName是空,同樣返回的是本方法默認的view.

確保Action返回了正確的viewData

我們把HomeController里的Index方法再稍改下如下:

 public IActionResult Index()          {              ViewBag.name = "sto";              return View("Index","hello");          }

測試方法如下

 HomeController hc = new HomeController();              var name= result.ViewData["name"];              Assert.True(name=="sto");

看到以上有些同事可能會有疑惑,為什麼設置的是ViewBag而能用ViewData獲取到呢,很多都從網上看到過有人說二者一個是dynamic類型,一個是字典類型,這只是它們外在的表現,其實才者運行時是同一個對象.所以可以通過ViewData[xxx]方式獲取到它的值.

確保程式進入的正確的分支

我們常常會看到如下程式碼

 public IActionResult Index(Student stud)          {              if (!ModelState.IsValid) return BadRequest();              return View("Index","hello");          }

Student類我們加上註解,改成如下

 public class Student      {          public string Name { get; set; }          [Range(3,10,ErrorMessage ="年齡必須在三到十歲之間")]          public int Age { get; set; }          public byte Gender { get; set; }          public string School { get; set; }      }

我們對年齡進行註解,標識它必須是3到10之間的一個值.

我們編寫以下測試來測試如果如果有模型綁定錯誤的時候返回 BadRequest

        [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              var result = hc.Index(new Student{Age=1});              Assert.IsType<BadRequestResult>(result);          }

以上測試我們把stud的年齡設置為1,根據程式邏輯它不在3到10之間,因此應該返回BadRequest(實際上是一個BadRequestResult類型對象),然而運行以上測試會發現測試並沒有通過,通過單步調試我們發現實際上返回的是一個ViewResult對象.為什麼會是這樣呢?其實原因很簡單,因為Modelstate.IsValid是在模型綁定的時候如果模型驗證有錯誤,就會寫稿Modelstate對象里,然而控制器並不是動態創建的,模型數據也不是動態綁定的,沒有向Modelstate里添加錯誤資訊的動作,所以單元測試里它啟動返回True,那是不是就沒有辦法測試了呢,其實也不是,因為ModelState不僅程式可以在模型綁定的時候動態添加,我們也可以在控制器裡面根據自己的業務邏輯添加.

我們把程式碼改為如下

       [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              hc.ModelState.AddModelError("Age", "年齡不在3到10範圍內");              var result = hc.Index(new Student{Age=1});               Assert.IsType<BadRequestResult>(result);          }

由於我們知道這裡的Age值是不合法的,因此顯式在controller的Modelstate對象里顯式寫入一個錯誤,這樣Model.Isvalid就應該返回False,邏輯應該走入BadRequest里.以上測試通過.

確保程式重定向到正確Action

我們把Index方法改為如下

public IActionResult Index(int? id)          {              if (!id.HasValue) return RedirectToAction("Contact","Home");              return View("Index","hello");          }

如果id為null的時候,就會返回一個RedirectToActionResult,導到Home控制器下的Contact方法下.

 [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              var result = hc.Index(null);              var redirect = (RedirectToActionResult) result;              var controllerName = redirect.ControllerName;              var actionName = redirect.ActionName;              Assert.True(controllerName == "Home" && actionName == "Contact");          }

當然以上的程式碼並不是很有意義,因為RediRectToAction裡面傳入的參數往往是兩個字元串,並不需要特別複雜的計算,而redirect.ControllerName,redirect.ActionName獲取的也並不是真正控制器的Action的名稱,而是上面方法賦值來的.因此它們的值總是相等.

我們可以通過以下改造來使測試變得更有意義

       [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              var result = hc.Index(null);              var redirect = (RedirectToActionResult) result;              var controllerName = redirect.ControllerName;              var actionName = redirect.ActionName;              Assert.True(                  controllerName.Equals(nameof(HomeController).GetControllerName(),                      StringComparison.InvariantCultureIgnoreCase) && actionName.Equals(nameof(HomeController.Contact),                      StringComparison.InvariantCultureIgnoreCase));          }

以上程式碼我們使用nameof獲取類型或者方法的名稱,然後判斷手動寫的和通過nameof獲取到的是不是一樣,這樣如果我們手寫有錯誤就會被發現,但是有一個問題是我們通過nameof獲取的HomeController的名稱是字元串HomeController而不是Home,其它類型也是如此,但是這個很容易處理,因為它們都是以Controller結尾,我們只要對它進行一下處理就行了.我們來看GetControllerName方法,它是一個String類的擴展方法

 public static class ControllerNameExtension      {          public static string GetControllerName(this string str)          {              if (string.IsNullOrWhiteSpace(str) || !str.EndsWith("Controller",StringComparison.InvariantCultureIgnoreCase))              {                  throw new InvalidOperationException("無法獲取指定類型的ControllerName");              }                string controllerName =                  str.Replace("Controller", string.Empty, StringComparison.InvariantCultureIgnoreCase);              return controllerName;          }      }

這個方法非常簡單,就是把Controller類的結果’Controller’字元串去掉

由於ControllerFactory在創建Controller的時候是並不區分大小寫的,因此我們的equals都加上了不區分大小寫的選項,這導致方法看上去特別長,我們也進行一下簡單封裝.

 public static class StringComparisionIgnoreCaseExtension      {          public static bool EqualsIgnoreCase(this string str, string other)          {              return str.Equals(other, StringComparison.InvariantCultureIgnoreCase);          }      }

以上方法非常簡單,就是在比較的時候加上StringComparison.InvariantCultureIgnoreCase

最終Assert的斷言程式碼變成如下:

 Assert.True(                  controllerName.EqualsIgnoreCase(nameof(HomeController).GetControllerName()) && actionName.EqualsIgnoreCase(nameof(HomeController.Contact)));

這樣如果我們因為手寫錯誤把名稱拼錯或者多空格就很容易被識別出來,並且如果方法名稱改掉這裡會出現編譯錯誤,方便我們定位錯誤.

確保程式重定向到正確路由

有些時候我們重定向到指定路由,下面看看如何測試

public IActionResult Index(int? id)          {              if (!id.HasValue) return RedirectToRoute(new{controller="Home",action="Contact"});              return View("Index","hello");          }

以上方法如果id為null就重定向到一個路由,這裡簡單說一下為什麼創建這樣一個匿名對象,為什麼對象的名稱為controller,和action而不是controllername和actionname?我們可以運行一下mvc程式,看看RouteData里的鍵值對的名稱是什麼,就會明白了.

測試方法如下

       [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              var result = hc.Index(null);              var redirect = (RedirectToRouteResult) result;              var data = redirect.RouteValues;              var controllerName = data?["controller"]?.ToString();              var actionName = data?["action"]?.ToString();              Assert.True(!string.IsNullOrWhiteSpace(controllerName));              Assert.True(!string.IsNullOrWhiteSpace(actionName));              Assert.True(controllerName.EqualsIgnoreCase(nameof(HomeController).GetControllerName()));              Assert.True(actionName.EqualsIgnoreCase(nameof(HomeController.Contact)));          }

以上方法實際上和上面的RedirectToAction測試本質上差不多,都是確定導向到了正確的controller和action里,不同的是值的獲取方法.

RedirectToAction和RedirecttoRoute都可以傳路由值,和上面以樣通過索引鍵獲取到值,這裡不再展開講解.

確保正確重定向到指定短url

.net core里新增了一個LocalRedirect(以及對應的永久重寫向,永久重定向保持方法等,其它重定向也都有這些類似方法族).它類似於RedirecttoRoute,只不過是參數並不是RouteData,而是一個短路由(不帶主機名和ip,因為默認並且只能內部重定向).

我們把HomeController下的Index方法改為如下:

 public IActionResult Index(int? id)          {              if (!id.HasValue) return LocalRedirect("/Home/Hello");              return View("Index","hello");          }

如果Id是null就重定向到/home/Hello想必大家在頁面向後端請求的時候寫過不少這樣的類似程式碼,這裡就不再詳細解釋了.

測試方法如下:

       [Fact]          public async Task ViewTest()          {              HomeController hc = new HomeController();              var result = hc.Index(null);              var redirect = (LocalRedirectResult) result;              var url = redirect.Url.Split("/").Where(a=>!string.IsNullOrEmpty(a));          }

這裡主要是通過Url獲取到這個地址,然後把它分成若干部分.默認情況下第一部分是控制器名,第二部分是action名.後面的程式碼不再寫了,大家自己嘗試一下.

需要注意的是,以上所有的示例只處理了默認路由的情況,並沒有處理路由參數,自定義路由以及aera中的路由等.如果不是默認路由,則以上內容的第一部分就不一定是controller名了,這裡還需要根據實際業務來處理.

view測試

上一節知識算是對mvc控制器測試的補充知識.這節正式開始講解關於mvc里view的集成測試.

有一點需要弄明白的是通過發送http請求進行集成測試是無法獲取到程式里的Controller對象的,我們只能能View的頁面進行集成測試.

對頁面的測試主要包含了對返回狀態的測試和頁面內容的測試.產生確保正確響應,並且返回了正確頁面,前面單元測試里主要測試的是返回的view名稱是正確的,至於能否到達這個頁面則不一定.集成測試里我們要根據當前頁面的特徵來確定當前頁面的身份.也就是這個頁面有與眾不同的,能區分它和別的頁面不同的特徵.

我們仍然用HomeController下的Index來作為案例講解.對Index方法改為出廠設置,內容如下

 public IActionResult Index()          {              return View();          }

這裡返回的首先頁面裡面包含了一個輪播圖,我們可以斷言返回的頁面中包含有carousel關鍵字,測試程式碼如下

        [Fact]          public async Task ViewIntegrityTest()          {              var response = await _client.GetAsync("/Home/Index");              response.EnsureSuccessStatusCode();              var responseStr = await response.Content.ReadAsStringAsync();              Assert.Contains("carousel", responseStr);          }

以上測試返回的內容(就是整個view頁面)中包含carousel這樣的字樣.

需要注意的是以上內容在實際項目中遠不能區分這個頁面就是home頁面,可能還需要其它的判斷,需要根據實際情況酌情考慮,如果以特定id,名稱等可能會變的內容作為判斷則會給集成測試帶來維護上的麻煩.有時候頁面太多改動又太大導致單元測試大片報錯,可能在時間緊任務重的情況下直接把單元測試放棄了,因此不是範圍越小,判斷的內容越精細越好,而是盡量找到本頁面中不易變的,能區別其它頁面的東西.即便是區分不了,這裡至少能確定頁面正確返回了而不是404頁面.這樣比上線後手動打開瀏覽器檢測頁面是否能正常打開要可靠的多.

仍然有一點需要注意的是並不是集成測試通過了就萬事大吉,我們仍然要在項目上線後對頁面進行抽檢,查看頁面布局是否正常.當然這些也可以自動化來完成.但是抽檢仍然是必要的,不要相信所有的方法都是天衣無縫的.