前面我们讲的很多单元测试的的方法和技巧不论是在. 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 页面. 这样比上线后手动打开浏览器检测页面是否能正常打开要可靠的多.
仍然有一点需要注意的是并不是集成测试通过了就万事大吉, 我们仍然要在项目上线后对页面进行抽检, 查看页面布局是否正常. 当然这些也可以自动化来完成. 但是抽检仍然是必要的, 不要信息所有的方法都是天衣无缝的.
来源: https://www.cnblogs.com/tylerzhou/p/11361598.html