系列目录

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

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

.netcore持续集成测试篇之MVC测试的更多相关文章

  1. .netcore持续集成测试篇之Xunit结合netcore内存服务器发送post请求

    系列目录 Web项目中,很多与用户数据交互的请求都是Post请求,想必大家都用过HttpClient构造过post请求,这里并不对HttpClient做详细介绍,只介绍一些常用的功能.并结合AutoF ...

  2. .netcore持续集成测试篇之Xunit数据驱动测试一

    系列目录 Nunit里提供了丰富的数据测试功能,虽然Xunit里提供的比较少,但是也能满足很多场景下使用了,如果数据场景非常复杂,Nunit和Xunit都是无法胜任的,有不少测试者选择自己编写一个数据 ...

  3. .netcore持续集成测试篇之开篇简介及Xunit基本使用

    系列目录 为了支持跨平台,微软为.net平台提供了.net core test sdk,这样第三方测试框架诸如Nunit,Xunit等只需要按照sdk提供的api规范进行开发便可以被dotnet cl ...

  4. .netcore持续集成测试篇之搭建内存服务器进行集成测试一

    系列目录 在web项目里,我们把每一层的代码的单元测试都通过并不代表程序能正常运行,因为这个过程缺失了http管道,很多时候我们还还需要把项目布在iis环境中或者在vs里启动iis express服务 ...

  5. .netcore持续集成测试篇之测试方法改造

    系列目录 通过前面两节讲解,我们的测试类中已经有两个测试方法了,总体上如下 public class mvc20 { private readonly HttpClient _client; publ ...

  6. .netcore持续集成测试篇之 .net core 2.1项目集成测试

    系列目录 从.net到.net core以后,微软非常努力,以每年一到两个大版本的频率在演进.net core,去年相继发布了.net core 2.1和2.2,其中2.1是长期支持版,不断的快速更新 ...

  7. .netcore持续集成测试篇之web项目验收测试

    系列目录 通过前面的单元测试,我们能够保证项目的基本模块功能逻辑是正常的,通过集成测试能够保证接口的请求是正常的.然而最终项目交付我们还需要对项目进行页面的行为进行测试,比如页面布局是否正常,按钮是否 ...

  8. .net持续集成测试篇之Nunit 测试配置

    系列目录 在开始之前我们先看一个陷阱 用到的Person类如下 public class Person:IPerson { public string Name { get; set; } publi ...

  9. .net持续集成测试篇之Nunit参数化测试

    系列目录 在进行单元测试的时候,很多时候,很多时候我们都是在单元测试方法内部提供特定的值,但是这样测试往往造成样本数不足从而导致覆盖的结果不够全面,很多时候我们更想提供来自外部的,满足条件的一组值来进 ...

随机推荐

  1. mybatis基础配置

    我这个写的比较简略,是自己短时间记录的可能只适合自己看,新手或者不懂的建议看看下面大神这篇: https://www.cnblogs.com/homejim/p/9613205.html 1.MyBa ...

  2. Java读写二进制数据

    import java.io.*; import java.time.LocalDate; public class Test { public static void main(String[] a ...

  3. CentOS 7离线安装Ansible

    前言 我一直都想成为自动化运维界最亮的仔,奈何自己实力不允许.不过,我一直都在奋斗的路上:这不,最近就在学习自动化运维界的神器--Ansible. 要系统的学习一下Ansible,那就是要先搭建学习环 ...

  4. NOIP2018普及T4暨洛谷P5018 对称二叉树题解

    题目链接:https://www.luogu.org/problemnew/show/P5018 花絮:这道题真的比历年的t4都简单的多呀,而且本蒟蒻做得出t4做不出t3呜呜呜... 这道题可以是一只 ...

  5. Excel催化剂开源第14波-VSTO开发之单元格区域转DataTable

    在Excel开发过程中,大部分时候是和Range单元格区域打交道,在VBA开发中,大家都知道的一点是,不能动不动就去遍历所有单元格,那性能是非常糟糕的,很多时候,是需要把整个单元格区域装入数组中再作处 ...

  6. Vue的基本使用(三)

    1.过滤器 1.局部过滤器,在当前组件内部使用过滤器,给某些数据添油加醋. //声明 filters:{ "过滤器的名字":function(val,a,b){ //a就是alex ...

  7. JavaSE总结(一)

    一.Java 简介 Java是由Sun Microsystems公司于1995年5月推出的Java面向对象程序设计语言和Java平台的总称.由James Gosling和同事们共同研发,并在1995年 ...

  8. C#编程之接口

    1.定义 接口是把公共方法和属性组合起来,以封装特定功能的一个集合.(一旦定义了接口,就可以在类中实现它.这样类就可以支持接口所指定的所有属性和成员) 注意1:接口不能单独存在.不能像实例化一个类那样 ...

  9. 使用jquery删除链接所在的行

    <!doctype html> <html> <head> <meta charset="utf-8"> <title> ...

  10. 恢复在iterm2中当滚动光标时候触发滚动历史记录的问题

    在Iterm2中,如果你上下滚动光标(上下滑动触摸板.或者滚动鼠标滚轮),通常情况下是触发了屏幕内容上下滚动. 但是在某些异常情况下,却触发了命令行历史记录的上下滚动,效果和你连续按了多次键盘的上下键 ...