Experiences upgrading an MVC 1 application to MVC 3
I have recently had to do some work on a MVC 1 application and thought it sensible to bring it up to MVC 3, you don’t want to be left behind if you can avoid it. This was a simple data capture application written in MVC1 in Visual Studio 2008 and never needed to be touched since. A user fills in a form, the controller then takes the form contents and stores it. The key point to note here is that it was using the Controller.UpdateModel
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult PostDataToApplication(FormCollection form)
{
NewApplication data = new NewApplication();
try
{
UpdateModel(data, form.ToValueProvider());
// process data object
bool success = DoSomething(data);
if (success)
{
return RedirectToAction("FormSuccess", "Home");
}
else
{
return RedirectToAction("FormUnsuccessful", "Home");
}
}
catch (InvalidOperationException)
{
return View();
}
}
This worked fine on MVC 1 on VS2008, but I wanted to move it onto MVC3 on VS2012 if possible; with a little changes as possible as this is a small web site that has very few changes so not worth a major investment in time to keep updated to current frameworks. So these were steps I took and gotcha’s I found
The upgrade itself
First I opened the VS2008 solution in VS2010 and it automatically upgraded to MVC2, a good start!
I then used the MVC2 to MVC3 tool on Codeplex, this initially failed and it took me a while to spot that you can only use this tool if your MVC2 application targets .NET 4. Once I changed the MVC2 to target .NET 4 as opposed to 3.5 this upgrade tool worked fine.
I could now load my MVC3 application in either VS2010 or VS2012.
Using the Web Site
At this point I though I had better test it, and instantly saw a problem. Pages that did not submit data worked fine, but submitting a data capture forms failed with Null Exception errors. Turns out the problem was a change in default behaviour of the models between MVC releases. On MVC1 empty fields on the form were passed as empty strings, with MVC 2(?) and later they are passed as nulls.
Luckily the fix was simple. Previously my model had been
public class SomeModel: IDataErrorInfo
{
public string AgentNumber { get; set; }
…..
}
I needed to add a [DisplayFormat(ConvertEmptyStringToNull = false)] attribute on each string property to get back to the previous behaviour my controller expected
public class SomeModel: IDataErrorInfo
{
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string AgentNumber { get; set; }
…..
}
Now my web site ran as I had expected.
Unit Tests
I had previously noticed my unit tests were failing. I had expected the change to the model would fix this too, but it did not. On the web there a good many posts as to how unit testing of MVC2 and later fails unless you mock out the controller.context. You see errors in the form
Test method Website.Tests.Controllers.HomeControllerTest.DetailsValidation_AlphaInValidAccountID_ErrorMessage threw exception:
System.ArgumentNullException: Value cannot be null.
Parameter name: controllerContext
Result StackTrace:
at System.Web.Mvc.ModelValidator..ctor(ModelMetadata metadata, ControllerContext controllerContext)
at System.Web.Mvc.ModelValidator.CompositeModelValidator..ctor(ModelMetadata metadata, ControllerContext controllerContext)
at System.Web.Mvc.ModelValidator.GetModelValidator(ModelMetadata metadata, ControllerContext context)
at System.Web.Mvc.DefaultModelBinder.OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.DefaultModelBinder.BindComplexElementalModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Object model)
at System.Web.Mvc.DefaultModelBinder.BindComplexModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.DefaultModelBinder.BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.Controller.TryUpdateModel[TModel](TModel model, String prefix, String[] includeProperties, String[] excludeProperties, IValueProvider valueProvider)
at System.Web.Mvc.Controller.UpdateModel[TModel](TModel model, String prefix, String[] includeProperties, String[] excludeProperties, IValueProvider valueProvider)
at System.Web.Mvc.Controller.UpdateModel[TModel](TModel model, IValueProvider valueProvider)
at Website.Controllers.HomeController.Details(FormCollection form)
The fix is to not just new up a controller in your unit tests like this
HomeController controller = new HomeController();
But to have a helper method to mock it all out (which is created for MVC associated test projects for you, so it is easy)
private static HomeController GetHomeController()
{
IFormsAuthentication formsAuth = new MockFormsAuthenticationService();
MembershipProvider membershipProvider = new MockMembershipProvider();
RoleProvider roleProvider = new MockRoleProvider();AccountMembershipService membershipService = new AccountMembershipService(membershipProvider, roleProvider);
HomeController controller = new HomeController(formsAuth, membershipService);
MockHttpContext mockHttpContext = new MockHttpContext();ControllerContext controllerContext = new ControllerContext(mockHttpContext, new RouteData(), controller);
controller.ControllerContext = controllerContext;
return controller;
}
However, this problem with a missing context was not my problem, I was already doing this. The error my test runner was showing did not mention the context, rather binding errors.
Test method CollectorWebsite.Tests.Controllers.HomeControllerTest.CardValidation_AlphaInValidAccountID_ErrorMessage threw exception:
System.NullReferenceException: Object reference not set to an instance of an object.
Result StackTrace:
at CollectorWebsite.Models.CardRecovery.get_Item(String columnName)
at System.Web.Mvc.DataErrorInfoModelValidatorProvider.DataErrorInfoPropertyModelValidator.Validate(Object container)
at System.Web.Mvc.ModelValidator.CompositeModelValidator.d__5.MoveNext()
at System.Web.Mvc.DefaultModelBinder.OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.DefaultModelBinder.BindComplexElementalModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Object model)
at System.Web.Mvc.DefaultModelBinder.BindComplexModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.DefaultModelBinder.BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
at System.Web.Mvc.Controller.TryUpdateModel[TModel](TModel model, String prefix, String[] includeProperties, String[] excludeProperties, IValueProvider valueProvider)
at System.Web.Mvc.Controller.UpdateModel[TModel](TModel model, String prefix, String[] includeProperties, String[] excludeProperties, IValueProvider valueProvider)
at System.Web.Mvc.Controller.UpdateModel[TModel](TModel model, IValueProvider valueProvider)
at CollectorWebsite.Controllers.HomeController.CardRecovery(FormCollection form)
I got stuck here for a good while………
Then it occurred to me if the behaviour has changed such that on the web site I see nulls when I expect empty strings, I bet the same is happening in unit tests. It is trying to iterate though what was a collection of strings and is now at best a collection of nulls or just an empty collection. The bind failed as it could not match the form to the data.
The fix was to make sure in my unit tests I passed in a FormCollection that had all the expected fields (with suitable empty values e.g string.empty). This meant my unit tests changed from
[TestMethod, Isolated]
public void ApplicationValidation_CurrentPostcodeNumbersOnly_ErrorMessage()
{
// Arrange
HomeController controller = GetHomeController(); FormCollection form = new FormCollection();
form.Add("CurrentPostcode", "12345");
// Act
ViewResult result = controller.Application(form) as ViewResult;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual("Please provide a valid postcode", result.ViewData.ModelState["CurrentPostcode"].Errors[0].ErrorMessage);
}
To
[TestMethod, Isolated]
public void ApplicationValidation_CurrentPostcodeNumbersOnly_ErrorMessage()
{
// Arrange
HomeController controller = GetHomeController(); FormCollection form = GetEmptyApplicationFormCollection();
form.Set("CurrentPostcode", "12345");
// Act
ViewResult result = controller.Application(form) as ViewResult;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual("Please provide a valid postcode", result.ViewData.ModelState["CurrentPostcode"].Errors[0].ErrorMessage);
}
where the GetEmptyApplicationFormCollection() helper method just creates a FormCollection with all the forms fields.
Once this was done my unit test passed.
Summary
So I now have an MVC3 application that works and passes unit tests. You could argue I should do more work so it does not need these special fixes, but it meets my needs for now.