Here's the story. In order to be able to drop well-formed Bootstrap controls into my MVC forms, I'm building an HtmlHelper extension method that generates the following structure with a single command:
<div class="control-group">
@Html.LabelFor(m => m.UserName, new { @class = "control-label" })
<div class="controls">
<div class="input-prepend">
<span class="add-on"><i class="icon-user"></i></span>
@Html.TextBoxFor(m => m.UserName, new { @class = "input-xlarge" })
</div>
@Html.ValidationMessageFor(m => m.UserName)
</div>
</div>
The method itself isn't that hard to write. What is considerably more difficult is the unit testing. To make my extension method testable, I need to create an instance of HtmlHelper<T> using the appropriate mocks. To that end, I have tweaked the answer to an old StackOverflow question and come up with this:
public static HtmlHelper<TModel> CreateHtmlHelper<TModel>(bool clientValidationEnabled, bool unobtrusiveJavascriptEnabled, ViewDataDictionary dictionary = null)
{
if (dictionary == null)
dictionary = new ViewDataDictionary { TemplateInfo = new TemplateInfo() };
var mockViewContext = new Mock<ViewContext>(
new ControllerContext(
new Mock<HttpContextBase>().Object,
new RouteData(),
new Mock<ControllerBase>().Object),
new Mock<IView>().Object,
dictionary,
new TempDataDictionary(),
new Mock<TextWriter>().Object);
mockViewContext.SetupGet(c => c.UnobtrusiveJavaScriptEnabled).Returns(unobtrusiveJavascriptEnabled);
mockViewContext.SetupGet(c => c.FormContext).Returns(new FormContext { FormId = "myForm" });
mockViewContext.SetupGet(c => c.ClientValidationEnabled).Returns(clientValidationEnabled);
mockViewContext.SetupGet(c => c.ViewData).Returns(dictionary);
var mockViewDataContainer = new Mock<IViewDataContainer>();
mockViewDataContainer.Setup(v => v.ViewData).Returns(dictionary);
return new HtmlHelper<TModel>(mockViewContext.Object, mockViewDataContainer.Object);
}
So far so good. Now that I can create an HtmlHelper object, I can execute my test as follows:
// ARRANGE
ModelMetadataProviders.Current = new DataAnnotationsModelMetadataProvider();
var helper = MvcMocks.CreateHtmlHelper<TestModel>(true, true);
helper.ViewData.Model = new TestModel { Field = null };
helper.ViewData.ModelState.AddModelError("Field", "The field must be assigned.");
// ACT
var controlGroup = helper.ControlGroupFor(m => m.Field, CssClasses.IconUser).ToHtmlString();
Here is the problem. Within ControlGroupFor, whose signature is
public static HtmlString ControlGroupFor<TModel, TValue>(this HtmlHelper<TModel> html, Expression<Func<TModel, TValue>> expression, string iconClass)
and which I haven't completed yet (being a good little TDD developer), I'm invoking var validationMessage = html.ValidationMessageFor(expression). Despite my use of AddModelError, the ValidationMessageFor method seems to think that html.ViewData.ModelState["Field"] is either null or its ModelErrors collection is empty. I am inferring this because the value of validationMessage is
<span class="field-validation-valid" data-valmsg-for="Field" data-valmsg-replace="true"></span>
and according to Resharper, the ValidationMessageFor method calls down into this method:
private static MvcHtmlString ValidationMessageHelper(this HtmlHelper htmlHelper, ModelMetadata modelMetadata, string expression, string validationMessage, IDictionary<string, object> htmlAttributes)
{
string modelName = htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(expression);
FormContext formContext = htmlHelper.ViewContext.GetFormContextForClientValidation();
if (!htmlHelper.ViewData.ModelState.ContainsKey(modelName) && formContext == null)
{
return null;
}
ModelState modelState = htmlHelper.ViewData.ModelState[modelName];
ModelErrorCollection modelErrors = (modelState == null) ? null : modelState.Errors;
ModelError modelError = (((modelErrors == null) || (modelErrors.Count == 0)) ? null : modelErrors.FirstOrDefault(m => !String.IsNullOrEmpty(m.ErrorMessage)) ?? modelErrors[0]);
if (modelError == null && formContext == null)
{
return null;
}
TagBuilder builder = new TagBuilder("span");
builder.MergeAttributes(htmlAttributes);
builder.AddCssClass((modelError != null) ? HtmlHelper.ValidationMessageCssClassName : HtmlHelper.ValidationMessageValidCssClassName);
if (!String.IsNullOrEmpty(validationMessage))
{
builder.SetInnerText(validationMessage);
}
else if (modelError != null)
{
builder.SetInnerText(GetUserErrorMessageOrDefault(htmlHelper.ViewContext.HttpContext, modelError, modelState));
}
if (formContext != null)
{
bool replaceValidationMessageContents = String.IsNullOrEmpty(validationMessage);
if (htmlHelper.ViewContext.UnobtrusiveJavaScriptEnabled)
{
builder.MergeAttribute("data-valmsg-for", modelName);
builder.MergeAttribute("data-valmsg-replace", replaceValidationMessageContents.ToString().ToLowerInvariant());
}
else
{
FieldValidationMetadata fieldMetadata = ApplyFieldValidationMetadata(htmlHelper, modelMetadata, modelName);
// rules will already have been written to the metadata object
fieldMetadata.ReplaceValidationMessageContents = replaceValidationMessageContents; // only replace contents if no explicit message was specified
// client validation always requires an ID
builder.GenerateId(modelName + "_validationMessage");
fieldMetadata.ValidationMessageId = builder.Attributes["id"];
}
}
return builder.ToMvcHtmlString(TagRenderMode.Normal);
}
Now, according to everything I've done, the validationMessage should give me a span with a class of field-validation-error and an error message reading "The field must be assigned." In my watch window, html.ViewData.ModelState["Field"].Errors has a count of 1. I must be missing something. Can anyone see what it is?
I modified the test to use ViewContext.ViewData rather than ViewData directly:
// ARRANGE
ModelMetadataProviders.Current = new DataAnnotationsModelMetadataProvider();
var helper = MvcMocks.CreateHtmlHelper<TestModel>(true, true);
helper.ViewContext.ViewData.Model = new TestModel { Field = null };
helper.ViewContext.ViewData.ModelState.AddModelError("Field", "The field must be assigned.");
// ACT
var controlGroup = helper.ControlGroupFor(m => m.Field, CssClasses.IconUser).ToHtmlString();
This has fixed my issue, but it's still not clear to me why helper.ViewContext.ViewData and helper.ViewData should be pointing to different instances, given the way the mocks were set up.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With