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


前面我们讲的很多单元测试的的方法和技巧不论是在.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(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(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页面.这样比上线后手动打开浏览器检测页面是否能正常打开要可靠的多.

仍然有一点需要注意的是并不是集成测试通过了就万事大吉,我们仍然要在项目上线后对页面进行抽检,查看页面布局是否正常.当然这些也可以自动化来完成.但是抽检仍然是必要的,不要相信所有的方法都是天衣无缝的.