MVC 验证的单元测试

Posted

技术标签:

【中文标题】MVC 验证的单元测试【英文标题】:Unit tests on MVC validation 【发布时间】:2010-11-19 04:12:24 【问题描述】:

当我在 MVC 2 Preview 1 中使用 DataAnnotation 验证时,如何测试我的控制器操作在验证实体时是否在 ModelState 中放入了正确的错误?

一些代码来说明。一、动作:

    [HttpPost]
    public ActionResult Index(BlogPost b)
    
        if(ModelState.IsValid)
        
            _blogService.Insert(b);
            return(View("Success", b));
        
        return View(b);
    

这是一个失败的单元测试,我认为应该通过但没有通过(使用 MbUnit 和 Moq):

[Test]
public void When_processing_invalid_post_HomeControllerModelState_should_have_at_least_one_error()

    // arrange
    var mockRepository = new Mock<IBlogPostSVC>();
    var homeController = new HomeController(mockRepository.Object);

    // act
    var p = new BlogPost  Title = "test" ;            // date and content should be required
    homeController.Index(p);

    // assert
    Assert.IsTrue(!homeController.ModelState.IsValid);

我想除了这个问题之外,应该我测试验证,我应该以这种方式测试它吗?

【问题讨论】:

不是 var p = new BlogPost Title = "test" ;比行动更安排? Assert.IsFalse(homeController.ModelState.IsValid); 【参考方案1】:

讨厌删除旧帖子,但我想我会添加自己的想法(因为我刚刚遇到这个问题并在寻找答案时遇到了这个帖子)。

    不要在控制器测试中测试验证。你要么信任 MVC 的验证,要么自己编写(即不要测试别人的代码,测试你的代码) 如果您确实想测试验证是否符合您的预期,请在您的模型测试中进行测试(我这样做是为了进行一些更复杂的正则表达式验证)。

您真正想要在这里测试的是,当验证失败时,您的控制器会执行您期望它执行的操作。那是你的代码,也是你的期望。一旦您意识到这就是您想要测试的全部内容,测试就很容易了:

[test]
public void TestInvalidPostBehavior()

    // arrange
    var mockRepository = new Mock<IBlogPostSVC>();
    var homeController = new HomeController(mockRepository.Object);
    var p = new BlogPost();

    homeController.ViewData.ModelState.AddModelError("Key", "ErrorMessage"); // Values of these two strings don't matter.  
    // What I'm doing is setting up the situation: my controller is receiving an invalid model.

    // act
    var result = (ViewResult) homeController.Index(p);

    // assert
    result.ForView("Index")
    Assert.That(result.ViewData.Model, Is.EqualTo(p));

【讨论】:

我同意,这应该是正确的答案。正如 ARM 所说:不应测试内置验证。相反,您的控制器的行为应该是经过测试的东西。这是最有意义的。 控制器应该与模型绑定和验证分开测试。遵循 KISS 和关注点分离。我正在这里写一个关于单元测试 MVC 组件的小系列文章timoch.com/blog/2013/06/… 您应该怎么做才能测试自定义验证属性?如果正在使用这些,则不能“信任 MVC 的验证”。您将如何测试(可能是在模型测试中)自定义验证是否有效? 我不同意。我们仍然需要验证给定模型是否会产生在此测试中用作前提条件的模型错误。然而,示例代码是您在 1 中定义的问题的完美答案。但它不是最初问题的答案 这不是测试模型验证。举个例子,有人可以(有意或无意地)删除模型中的数据注释(可能是合并错误?)并且此测试不会失败。【参考方案2】:

我也遇到了同样的问题,在阅读了 Pauls 的回答和评论后,我寻找了一种手动验证视图模型的方法。

我找到了this tutorial,它解释了如何手动验证使用 DataAnnotations 的 ViewModel。他们的关键代码 sn-p 在帖子的末尾。

我稍微修改了代码 - 在教程中省略了 TryValidateObject 的第 4 个参数 (validateAllProperties)。为了让所有注释都进行验证,这应该设置为 true。

此外,我将代码重构为通用方法,以简化 ViewModel 验证的测试:

    public static void ValidateViewModel<TViewModel, TController>(this TController controller, TViewModel viewModelToValidate) 
        where TController : ApiController
    
        var validationContext = new ValidationContext(viewModelToValidate, null, null);
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(viewModelToValidate, validationContext, validationResults, true);
        foreach (var validationResult in validationResults)
        
            controller.ModelState.AddModelError(validationResult.MemberNames.FirstOrDefault() ?? string.Empty, validationResult.ErrorMessage);
        
    

到目前为止,这对我们来说效果很好。

【讨论】:

对不起,甚至没有检查。我们所有的 MVC 项目都在 4.0 谢谢!一个小附录;如果您有未与特定字段耦合的验证(即您已实现 IValidatableObject),则 MemberNames 为空,并且模型错误键应为空字符串。在 foreach 中你可以这样做:var key = validationResult.MemberNames.Any() ? validationResult.MemberNames.First() : string.Empty; controller.ModelState.AddModelError(key, validationResult.ErrorMessage); 为什么需要使用泛型?如果将其定义为:void ValidateViewModel(object viewModelToValidate, Controller controller) 甚至更好地作为扩展方法,则可以更容易地使用它:public static void ValidateViewModel(this Controller controller, object viewModelToValidate) 这很好,但我同意乍得只是摆脱通用语法。 如果有人在“验证器”方面遇到与我相同的问题,请使用“System.ComponentModel.DataAnnotations.Validator.TryValidateObject”来确保您使用正确的验证器。【参考方案3】:

当您在测试中调用 homeController.Index 方法时,您没有使用任何触发验证的 MVC 框架,因此 ModelState.IsValid 将始终为真。在我们的代码中,我们直接在控制器中调用辅助 Validate 方法,而不是使用环境验证。我对 DataAnnotations 没有太多经验(我们使用 NHibernate.Validators)也许其他人可以提供指导如何从您的控制器中调用 Validate。

【讨论】:

我喜欢“环境验证”这个词。但是必须有一种方法可以在单元测试中触发它? 问题是你基本上是在测试 MVC 框架——而不是你的控制器。您正在尝试确认 MVC 正在按照您的预期验证您的模型。唯一可以肯定地做到这一点的方法是模拟整个 MVC 管道并模拟 Web 请求。这可能比你真正需要知道的要多。如果您只是测试模型上的数据验证是否设置正确,您可以在没有控制器的情况下执行此操作,只需手动运行数据验证。【参考方案4】:

我今天正在研究这个问题,我发现 Roberto Hernández (MVP) 的 this blog post 似乎提供了在单元测试期间为控制器操作触发验证器的最佳解决方案。这将在验证实体时将正确的错误放入 ModelState 中。

【讨论】:

【参考方案5】:

我在我的测试用例中使用 ModelBinders 来更新 model.IsValid 值。

var form = new FormCollection();
form.Add("Name", "0123456789012345678901234567890123456789");

var model = MvcModelBinder.BindModel<AddItemModel>(controller, form);

ViewResult result = (ViewResult)controller.Add(model);

使用我的 MvcModelBinder.BindModel 方法如下(基本上使用相同的代码 在 MVC 框架内部):

        public static TModel BindModel<TModel>(Controller controller, IValueProvider valueProvider) where TModel : class
        
            IModelBinder binder = ModelBinders.Binders.GetBinder(typeof(TModel));
            ModelBindingContext bindingContext = new ModelBindingContext()
            
                FallbackToEmptyPrefix = true,
                ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(null, typeof(TModel)),
                ModelName = "NotUsedButNotNull",
                ModelState = controller.ModelState,
                PropertyFilter = (name =>  return true; ),
                ValueProvider = valueProvider
            ;

            return (TModel)binder.BindModel(controller.ControllerContext, bindingContext);
        

【讨论】:

如果您在一个属性上拥有多个验证属性,这将不起作用。在创建ModelBindingContext 的代码之前添加这一行controller.ModelState.Clear(); 就可以了【参考方案6】:

这并不能完全回答您的问题,因为它放弃了 DataAnnotations,但我会添加它,因为它可能会帮助其他人为其控制器编写测试:

您可以选择不使用 System.ComponentModel.DataAnnotations 提供的验证,但仍使用 ViewData.ModelState 对象,方法是使用其AddModelError 方法和其他一些验证机制。例如:

public ActionResult Create(CompetitionEntry competitionEntry)
        
    if (competitionEntry.Email == null)
        ViewData.ModelState.AddModelError("CompetitionEntry.Email", "Please enter your e-mail");

    if (ModelState.IsValid)
    
       // insert code to save data here...
       // ...

       return Redirect("/");
    
    else
    
        // return with errors
        var viewModel = new CompetitionEntryViewModel();
        // insert code to populate viewmodel here ...
        // ...


        return View(viewModel);
    

这仍然可以让您利用 MVC 生成的 html.ValidationMessageFor() 内容,而无需使用 DataAnnotations。您必须确保与 AddModelError 一起使用的密钥与视图期望的验证消息匹配。

然后控制器变得可测试,因为验证是显式发生的,而不是由 MVC 框架自动完成的。

【讨论】:

以这种方式进行验证会丢弃 MVC 中验证的一些最佳部分。我想在我的模型上添加验证,而不是在控制器中。如果我使用这个解决方案,我最终会得到很多可能的代码重复,伴随着噩梦。 @W.Meints:对,但是如果您愿意,也可以将上述示例中执行验证的代码行移至模型上的方法中。关键是,通过代码而不是属性进行验证使其更可测试。保罗在***.com/a/1269960/22194上方解释得更好【参考方案7】:

我同意 ARM 有最好的答案:测试控制器的行为,而不是内置验证。

但是,您也可以对您的模型/视图模型定义正确的验证属性进行单元测试。假设您的 ViewModel 如下所示:

public class PersonViewModel

    [Required]
    public string FirstName  get; set; 

此单元测试将测试[Required] 属性是否存在:

[TestMethod]
public void FirstName_should_be_required()

    var propertyInfo = typeof(PersonViewModel).GetProperty("FirstName");

    var attribute = propertyInfo.GetCustomAttributes(typeof(RequiredAttribute), false)
                                .FirstOrDefault();

    Assert.IsNotNull(attribute);

【讨论】:

那么我们将如何测试内置验证呢?特别是如果我们使用额外的属性、错误消息等对其进行自定义。【参考方案8】:

与ARM相比,我对挖坟没有问题。所以这是我的建议。它建立在 Giles Smith 的答案之上,适用于 ASP.NET MVC4(我知道问题是关于 MVC 2,但谷歌在寻找答案时没有区别,我无法在 MVC2 上进行测试。) 我没有将验证代码放在通用静态方法中,而是将其放在测试控制器中。控制器具有验证所需的一切。所以,测试控制器看起来像这样:

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Wbe.Mvc;

protected class TestController : Controller
    
        public void TestValidateModel(object Model)
        
            ValidationContext validationContext = new ValidationContext(Model, null, null);
            List<ValidationResult> validationResults = new List<ValidationResult>();
            Validator.TryValidateObject(Model, validationContext, validationResults, true);
            foreach (ValidationResult validationResult in validationResults)
            
                this.ModelState.AddModelError(String.Join(", ", validationResult.MemberNames), validationResult.ErrorMessage);
            
        
    

当然这个类不需要是一个受保护的内部类,这就是我现在使用它的方式,但我可能会重用那个类。如果某个地方有一个模型 MyModel 装饰有很好的数据注释属性,那么测试看起来像这样:

    [TestMethod()]
    public void ValidationTest()
    
        MyModel item = new MyModel();
        item.Description = "This is a unit test";
        item.LocationId = 1;

        TestController testController = new TestController();
        testController.TestValidateModel(item);

        Assert.IsTrue(testController.ModelState.IsValid, "A valid model is recognized.");
    

这种设置的优点是我可以重用测试控制器来测试我的所有模型,并且可以扩展它以模拟更多关于控制器的内容或使用控制器具有的受保护方法。

希望对你有帮助。

【讨论】:

【参考方案9】:

如果您关心验证但不关心它是如何实现的,如果您只关心在最高抽象级别验证您的操作方法,无论它是使用 DataAnnotations、ModelBinders 还是什至实现ActionFilterAttributes,那么您可以使用 Xania.AspNet.Simulator nuget 包,如下所示:

install-package Xania.AspNet.Simulator

--

var action = new BlogController()
    .Action(c => c.Index(new BlogPost()), "POST");
var modelState = action.ValidateRequest();

modelState.IsValid.Should().BeFalse();

【讨论】:

【参考方案10】:

基于 @giles-smith 的回答和 cmets,用于 Web API:

    public static void ValidateViewModel<TViewModel, TController>(this TController controller, TViewModel viewModelToValidate) 
        where TController : ApiController
    
        var validationContext = new ValidationContext(viewModelToValidate, null, null);
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(viewModelToValidate, validationContext, validationResults, true);
        foreach (var validationResult in validationResults)
        
            controller.ModelState.AddModelError(validationResult.MemberNames.FirstOrDefault() ?? string.Empty, validationResult.ErrorMessage);
        
    

请参阅上面的答案编辑...

【讨论】:

【参考方案11】:

@giles-smith 的回答是我的首选方法,但可以简化实现:

    public static void ValidateViewModel(this Controller controller, object viewModelToValidate)
    
        var validationContext = new ValidationContext(viewModelToValidate, null, null);
        var validationResults = new List<ValidationResult>();
        Validator.TryValidateObject(viewModelToValidate, validationContext, validationResults, true);
        foreach (var validationResult in validationResults)
        
            controller.ModelState.AddModelError(validationResult.MemberNames.FirstOrDefault() ?? string.Empty, validationResult.ErrorMessage);
        
    

【讨论】:

【参考方案12】:

您也可以将操作参数声明为FormCollection,而不是传入BlogPost。然后您可以自己创建BlogPost 并调用UpdateModel(model, formCollection.ToValueProvider());

这将触发对FormCollection 中任何字段的验证。

    [HttpPost]
    public ActionResult Index(FormCollection form)
    
        var b = new BlogPost();
        TryUpdateModel(model, form.ToValueProvider());

        if (ModelState.IsValid)
        
            _blogService.Insert(b);
            return (View("Success", b));
        
        return View(b);
    

只需确保您的测试为视图表单中您希望留空的每个字段添加一个空值。

我发现这样做会以增加几行代码为代价,使我的单元测试更类似于在运行时调用代码的方式,从而使它们更有价值。您还可以测试当有人在绑定到 int 属性的控件中输入“abc”时会发生什么。

【讨论】:

我喜欢这种方法,但它似乎是倒退了一步,或者我必须在处理 POST 的每个操作中添加至少一个额外的步骤。 我同意。但是让我的单元测试和真正的应用程序以相同的方式工作是值得的。 ARMs 方法更好恕我直言 :) 这样违背了MVC的目的。 我同意 ARM 的答案更好。与传递强类型模型/视图模型对象相比,将 FormCollection 传递给控制器​​操作是不可取的。

以上是关于MVC 验证的单元测试的主要内容,如果未能解决你的问题,请参考以下文章

.NET 单元测试的艺术&单元测试之道C#版

MVC 44.MVC 基本工具(Visual Studio 的单元测试使用Moq)

MVC 基本工具(Visual Studio 的单元测试使用Moq)

spring mvc中的单元测试

MVC 单元测试控制器

ASP.NET-MVC:数据库默认值会破坏单元测试的精神吗? [关闭]