MonoRail Controller Test Analysis – Problem and Resolution
Last night, my fellow LosTechies geek Jason, wanted
me to check out something he was trying to do in a MonoRail controller
test.
For some background and the original source code he was working with, see [his Castle forum
post](http://forum.castleproject.org/viewtopic.php?t=3644).
So here was the partial test he was working with:
[Test]
public void Should_have_error_summary_message_when_required_name_is_not_filled()
{
contact.Name = "";
contactController.SendContactMessage(contact);
Assert.AreEqual(@"Contactcontactform", contactController.SelectedViewName);
...
}
And here is the target controller code being tested:
public void SendContactMessage([DataBind("contact", Validate = true)] Contact contact)
{
if (HasValidationError(contact))
{
Flash["contact"] = contact;
Flash["summary"] = GetErrorSummary(contact);
RedirectToAction("contactform");
}
// send some email, etc...
RenderView("confirmation");
}
So let’s tackle 3 issues in this scenario, one by one.
Correcting the conditional logic
Since the error validation check is not returning out of the method, as it
stands now, the email will always be sent and the “confirmation” view will
always be rendered no matter what. I made the exact same kinds of mistakes when
I was first learning MonoRail; expecting that as soon as I call
RedirectToAction, it would take care of performing the redirect right then. But
of course, essentially, that’s just a method call to notify the framework of
what should be done when the action execution is complete.
So this is an easy one to solve. We can either throw in a return statement
inside the conditional making it a guard clause, or just wrap the rest of the
logic inside an “else” block.
public void SendContactMessage([DataBind("contact", Validate = true)] Contact contact)
{
if (HasValidationError(contact))
{
Flash["contact"] = contact;
Flash["summary"] = GetErrorSummary(contact);
RedirectToAction("contactform");
return;
}
// send some email, etc...
RenderView("confirmation");
}
OR
public void SendContactMessage([DataBind("contact", Validate = true)] Contact contact)
{
if (HasValidationError(contact))
{
Flash["contact"] = contact;
Flash["summary"] = GetErrorSummary(contact);
RedirectToAction("contactform");
}
else
{
// send some email, etc...
RenderView("confirmation");
}
}
I tend to like returning guard clauses shown in the first example better, but
either way is of course acceptable.
Differences between testing Redirects and Renders
The test method is currently performing an assert on the SelectedViewName of
the controller to make sure the “contactform” view is being displayed. The
problem here is that the controller is actually doing a
Redirect, not a Render. There is a difference
in how those are tested.
-
How to assert that the controller redirected to a
particular view:
Assert.AreEqual("/Contact/contactform.rails", Response.RedirectedTo);
-
How to assert that a particular view was only
rendered:
Assert.AreEqual(@"Contactconfirmation", contactController.SelectedViewName);
So this test method we’re working with needs to be changed to use the first
technique of asserting against a redirect, since that’s what’s happening in the
controller. Easy enough…
Simulating validation errors in the controller
Ok, so now for the interesting one. Right now, the call to
HasValidationError inside the controller is always going to
return false. Because that property relies directly upon a dictionary of error
summaries populated by the databinder. The reason this an issue in our test
method here, is that if you just call a controller’s action method directly from
a unit test, the databinder is not run, so the actual validation never takes
place. So even though the contact object that is being passed in really doesn’t
pass validation, the controller doesn’t know that if you relying solely on the
[DataBind] attribute to take care of the validation for you. Of course you
could run the validation yourself inside of the controller’s action method as an
alternative. But there is an easier way. Besides, you have to really ask
yourself, “what should I really be testing here?”.
Jason already understood this, but for those who may not. Do you really care
about testing specific validation rules in your controller, like “is the contact
name empty?”? Well, you probably shouldn’t. Those should be saved for your
actual domain object validation tests. All we care about in this controller
test is that if the validation fails for whatever reason (we don’t care why),
then show the error summary and redirect back to the contact form.
Here’s one way that this can be simulated in the controller test:
[Test]
public void Should_load_error_summary_when_contact_is_not_valid()
{
SimulateOneValidationErrorFor(contactController, contact);
contactController.SendContactMessage(contact);
Assert.AreEqual("/Contact/contactform.rails", Response.RedirectedTo);
Assert.IsNotNull(contactController.Flash["contact"]);
Assert.IsNotNull(contactController.Flash["summary"]);
Assert.AreEqual(1, ((ErrorSummary) contactController.Flash["summary"]).ErrorsCount);
}
The important code is here in the helper methods:
private void SimulateOneValidationErrorFor(SmartDispatcherController controller, object instance)
{
controller.ValidationSummaryPerInstance.Add(instance, CreateDummyErrorSummaryWithOneError());
}
private ErrorSummary CreateDummyErrorSummaryWithOneError()
{
ErrorSummary errors = new ErrorSummary();
errors.RegisterErrorMessage("blah", "blah");
return errors;
}
The ValidationSummaryPerInstance dictionary is publicly
exposed for us to manipulate (which is not my preferred way to expose/manipulate
collections, since it breaks encapsulation). But for now this will get us by.
We can just add our own dummied up error summary to the controller so that when
the HasValidationError method is called, it will return true,
since the result is based on an inspection of this dictionary of error
summaries.
Getting something like this included in the BaseControllerTest would also be
a nice thing to have.
There may be a better way to simulate this, but this is what I came up with.
Anyone else got any better ways of doing this?