.netcore持续集成测试篇之MVC层单元测试

系列目录html

前面咱们讲的不少单元测试的的方法和技巧不管是在.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.mvc

下面是分别获取View的名称的数据模型,而后咱们断言View名称是Index,model的值是hello,固然以上代码比较简单显然是能经过的,在实际业务中咱们还要对Model进行更为复杂的断言.async

须要注意的是,Action返回的view并非都有名称的,若是是返回的本方法对应的view,默认名称是能够省略的,这样以上断言就会失败,所以若是名称不写的时候咱们能够断言ViewName是空,一样返回的是本方法默认的view.ide

确保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页面.这样比上线后手动打开浏览器检测页面是否能正常打开要可靠的多.

仍然有一点须要注意的是并非集成测试经过了就万事大吉,咱们仍然要在项目上线后对页面进行抽检,查看页面布局是否正常.固然这些也能够自动化来完成.可是抽检仍然是必要的,不要相信全部的方法都是完美无缺的.