Testing with Opinionated ASP.NET MVC
Before joining them at Dovetail, I had heard Chad and Jeremy talk over lunch of how they were building their product with Microsoft's ASP.NET MVC. While all of the published literature still left me with questions on how to best handle URLs or test drive controller actions, they spoke of it as a solved problem. They'd say they were using MVC in a very "opinionated" way by hiding or replacing most of the underlying framework. They spoke of "one in, one out" state based testing, invocation registries, yada yada yada, but I couldn't really grasp the impact without seeing the code. Since they haven't yet published any of their work, I figured I'd kick it off with a post about our controller action tests. If you are interested in more details, let us know, and I'm sure one of us can get a few more blog posts out of it. Even better, come talk to us at the Continuous Improvement in .NET Software Development conference.
Have an opinion
The concepts of "opinionated frameworks" and "convention over configuration" have gained a large following from people rejecting the tedious and noisy declarative code typically associated with shaping a one-size-fits-all framework. Regardless of whether you are trudging through XML, or using a fancier programmatic fluent interface, having to repeatedly "state the obvious" in your code will cloud its true intentions and slow you down. Where a one-size-fits-all framework says "we can't make that assumption, because the developer may want to do x, y, or z", the opinionated framework says "we're going to make some assumptions that fit the most common scenarios, and the developer should bend their design to fit those assumptions" (although the good ones will always allow you to deal with edge cases through some sort of overriding mechanism).
I was reminded of this today when reading Phil Haack's post on How a Method Becomes an Action. He made the side remark: "This is one reason you've seen the MVC team resistant to including helper methods, such as Url<T>(…)
, that use an expression to define the URL of an action. The action is not necessarily equivalent to a method on the class with the same name". Its probably a wise move that the Microsoft MVC team is not embedding "opinions" in such a young platform that hasn't had enough real-world usage to drive out the common scenarios. However, within the confines of an individual development team, you can get a lot of value by using the underlying framework in a more opinionated way. By simply deciding, "an action name IS necessarily equivalent to a method name on a controller class", you can safely use a compiler-checked UrlFor<ProductsController>( c=>c.Edit ) helper method throughout your codebase.
Our opinion on controller actions
At Dovetail, we have simplified our development and testing of controller actions by codifying our opinions and conventions. For example:
- All controller actions have a single input parameter. All of the data provided by the client that is needed by the action is exposed as properties on this view model object.
- All controller actions return a single view model object that contains all of the data needed to render the view.
- Specifically, actions do NOT return instances of ActionResult. Since a large majority of actions display a view, we move the ViewResult creation into the ControllerActionInvoker. We include a mechanism for actions to override this behavior and return a specific ActionResult when needed.
By adhering to these conventions, we get a lot of commonality in our tests that we can pull up into a base class. The individual tests themselves become much easier to write, and much clearer to read, in my opinion. As a point of comparison, I'll take an example from codecampserver, an open source application used to teach concepts with ASP.NET MVC. Let's look at the codecampserver tests for the List action on the SpeakerController (see the original at SpeakerControllerTester):
[Test] public void ShouldListSpeakersForAConference() { var p = new Person("joe", "dimaggio", "jd@baseball.com"); var p2 = new Person("marilyn", "monroe", "m@m.com"); _conference.AddSpeaker(p, "joedimaggio", "bio here...", "avatar.jpg"); _conference.AddSpeaker(p2, "marilynmonroe", "bio here...", "avatar.jpg"); using (_mocks.Record()) { SetupResult.For(_conferenceRepository.GetConferenceByKey("austincodecamp2008")) .IgnoreArguments() .Return(_conference); } using (_mocks.Playback()) { SpeakerController controller = createSpeakerController(); var actionResult = controller.List("austinCodeCamp2008", 0, 0) as ViewResult; Assert.That(actionResult, Is.Not.Null); Assert.That(actionResult.ViewName, Is.Null, "expected default view"); var speakersPassedtoView = controller.ViewData.Get<Speaker[]>(); Assert.That(speakersPassedtoView, Is.Not.Null); Assert.That(speakersPassedtoView.Length, Is.EqualTo(2)); } }
Using a test base class built around our conventions we can specify the same behavior with:
[TestFixture] public class When_listing_the_speakers : ControllerActionTest<SpeakerController, SpeakerListRequest, SpeakerListViewModel> { public When_listing_the_speakers() : base((c, input) => c.List(input)) { } protected override void underTheseConditions() { Conference conference = new Conference("austincodecamp2008", "Austin Code Camp"); var p = new Person("joe", "dimaggio", "jd@baseball.com"); var p2 = new Person("marilyn", "monroe", "m@m.com"); conference.AddSpeaker(p, "joedimaggio", "bio here...", "avatar.jpg"); conference.AddSpeaker(p2, "marilynmonroe", "bio here...", "avatar.jpg"); Input = new SpeakerListRequest { ConferenceKey = "austincodecamp2008" }; GivenThat<IConferenceRepository, Conference>(r => r.GetConferenceByKey(null)).IgnoreArguments().Return(conference); } [Test] public void Should_display_the_default_view() { Output.Override.ShouldBeNull(); } [Test] public void Should_provide_the_speakers_related_to_the_conference() { Output.Speakers.ShouldNotBeNull(); Output.Speakers.Length.ShouldEqual(2); } }
As you can see, the assertions are more easily broken out into individual tests. The test methods themselves are very simple, usually consisting of just the assertions on the output. You could also test slightly different scenarios by making changes to the Input property within your test method. I know some people don't like to use a test base class, but I think those objections stem from the idea that they don't want to hide the details of a test. However, in our case, since all of our controller action tests can use the same base class, it can simply be considered part of our test runner infrastructure. Once its functionality is understood for one test, you understand how it works with all of the other tests, unlike if you had to create one-off test base classes for each action or scenario.
In case it isn't clear what is going on here:
- The controller under test, and the type of the action's input parameter and return value are specified as generic parameters on the ControllerActionTest base class.
- The method being tested is declared via the lambda passed to the base class's constructor.
- The controller under test is created in the base class's [SetUp] method using StructureMap's RhinoAutoMocker.
- The underTheseConditions method is called as part of the [SetUp] method on the base class.
- Input and Output are properties on the base class that hold an instance of the input parameter and return value types, respectively.
- GivenThat() is a method on the base class which stubs behavior using Rhino Mocks.
- The Should*() methods used on the Output properties perform NUnit assertions under the hood, care of Scott Bellware's specification extensions
Comments
I would like to see more of the code to achieve the nice BDD style tests.
On a side note, I have never quite understood the fondness for AutoMockingContainers.
I think it hides the clarity and advertising of dependency creation.
http://flimflan.com/blog/SampleOpinionatedController.aspx
It includes a link to download the working code I used for these posts, including the full test base class which enables the BDD style tests.
I was skeptical of auto-mocking, mostly because I heard all the arguments against before I had tried it. Now that I've used it, I'm definitely a fan. I don't WANT to know about dependency creation, I just want to concentrate on the behavior of the object being tested.