Test-Driving the Design of MVC Based Apps

Posted on by in Process

Using tests to drive out the design of objects is an effective way to write code. By taking the perspective of a client, your objects will develop simple, and intuitive interfaces. In addition, the tests act as both documentation and an automated, regression test suite. In this post, we’ll outline a basic test-driven workflow for MVC based applications. Our testing won’t be too exhaustive, but the workflow can be extended with additional, finer-grained tests, as you become more comfortable with test-driving code.

Start With a Failing Integration Test

Our sample application will be a command-line based text translator. Given a file of text, the app should be able to translate it into various languages. We’ll package up the app using Ruby gems.

Our first feature will be to support translating English text to Pig Latin.
We’ll use Cucumber to write an end-to-end integration test to capture this initial feature (specifically aruba, which adds command-line steps to Cucumber).

features/pig_latin.feature

Running this feature gives us our first failure.

Let’s implement these steps using aruba‘s command-line helpers.

features/step_definitions/pig_latin_steps.rb

First, we write out a file of English text (aruba will put this file in a tmp directory), then we execute a command to translate the file (a command that doesn’t yet exist), and finally we verify the output. Running the test again, tells us that the translator command doesn’t exist.

Let’s create an empty executable file in order to get a different failure.

We can now move on to handling this request.

Implement a Minimal Controller

Our command-line app will use an MVC based design. The first step in MVC is handling a request. Let’s flesh out our ideal controller interface from the executable translator file.

bin/translator

Translator is our app’s namespace. We can use a module method to handle a Pig Latin request. ARGF is an io stream representing the file passed in from the command-line. ARGF and STDOUT act as the request and response. We’ll read from ARGF and we’ll write to STDOUT.

Cucumber tells us to implement our controller.

lib/translator.rb

This empty Translator.pig_latin method fixes the NoMethodError, moving us further along.

For simplicity, we didn’t design the interface of the controller using its own unit tests. Usually, controllers do all their work by delegating to other objects. Unit testing a controller typically requires stubbing/mocking these collaborating objects. These tests often end up being ugly and hard to understand and maintain.

However, unit testing controllers can be beneficial, e.g., testing edge-cases, or when your test suite is painfully slow due to too many comprehensive integration tests. But when you’re just starting out, don’t worry about it. As you become more comfortable testing, explore unit testing your controllers to see if it’s worth it.

Use the View to Drive Out the Model

With a basic Pig Latin request handling controller in place, we can now move on to the next piece of MVC, the view. The view represents the user interface. It gets its data from a model. By implementing the view first, we can design our ideal model interface.

lib/translator.rb

Translator.pig_latin now asks a Translator::PigLatinView object to render itself. The Translator::PigLatinView object is created with some Pig Latin, which it renders onto an io object (this is the response, specifically STDOUT, that was passed in from the translator command).

Again, we skipped unit tests. Views, like controllers, rely on a lot of objects. As a result, their unit tests typically require extensive stubbing/mocking, and will be similar to controller tests. Skip them for now, and instead rely on integration tests for coverage. Like controllers, when you become more comfortable with testing, explore unit testing views to see if they provide any value.

Cucumber tells us to implement our model.

lib/translator.rb

Our Translator::PigLatin model will process the request (the file of English text) and translate it to Pig Latin.

Drop Down and Unit Test the Model

Although we skipped unit testing our controller and view, we should unit test the model. In MVC, the model represents domain logic, the most important part of an app; in our case, Pig Latin translation. A high-level integration test failure could mean there’s a problem in a controller, a view, or even a model. Model-level tests can help narrow down these failures, eliminating tedious debugging. They’re also useful in situations where there’s a change in domain logic but not in the user interface.

spec/pig_latin_spec.rb

We can get this failure to pass with the following implementation (for Pig Latin translation, we’ll use the igpay_atinlay gem, which adds String#to_pig_latin).

lib/pig_latin.rb

Come Back Up to the Integration Test

After adding a lower-level model test, we can come back up to our failing integration test; which should now be passing.

Find What Works for You

This basic test-driven workflow works for any MVC based application, command-line or web. High-level integration tests coupled with lower-level model tests are a great way to get started. As test-driving code becomes second nature to you, explore unit testing your controllers and views. It’s important to try out various testing workflows to find out what works for you and your project.


Feedback

  Comments: 4

  1. Michael Wynholds


    Another good post Jared. A few things…

    1. I’d love to hear more about your experiences unit testing your controllers and views. In this post you tell the readers to find out for themselves, but I’m sure you have your own thoughts. Perhaps a new post on the topic!

    2. I enjoyed the example of MVC being a command line app. It’s nice to see everyone now and again that request/response MVC is not only applicable to web applications.

    3. I’m glad you used the igpay_atlinlay gem, but you should know that it can handle translating entire sentences, not just words. So you can simplify your model, if you want.

    -mike


    • Thanks for the feedback Mike.

      I used to unit test my controllers religiously, in addition to having higher-level integration tests. For a lot of use cases, e.g., “happy paths”, I feel having coverage at both the integration and controller level is redundant. However, I do find myself going to controller tests for edge cases or error conditions. I often find it easier to simulate error conditions in a controller test, than in an integration test.

      I’ve only experimented with unit testing views a few times. I wrote up some of my thoughts here http://blog.carbonfive.com/2011/03/02/a-look-at-specifying-views-in-rspec/ . I’m still not entirely sold on view tests, but they can be fun when doing an extremely baby-step, test-driven workflow.

      MVC can really help add structure to command-line apps, which usually just end up as a bunch of scripts. It’s sometimes hard to picture MVC outside of a web environment, but it’s just as effective in non-web environments.

      Thanks for the igpay_atinlay tip. I updated the code samples to take advantage of this feature.


  2. Two things come to mind when I read this.

    1. I generally avoid writing files to the file system as part of my test suite. This is comparable to using a third party api that gets mocked out during testing of a web app. Counting on the ability to write to a tempfile is unreliable (especially if you are working on multiple os’s). I typically use StringIO objects instead and stub out the File.open() (or whatever method you choose to use to read the file).

    2. Regarding command-line MVC… I have started playing with classes that load configuration files. These classes validate data using ActiveModel::Validations and program execution is adjusted accordingly. I haven’t used this pattern for command-line parameters, but it wouldn’t be difficult to do it.


    • I don’t mind writing to the filesystem with aruba. It takes care of all the file system details, e.g., ensuring the “tmp” directory exists, creating files, and deleting files. I hope it does handle cross-platform filesystem differences but I don’t know for sure. I can see how this is an issue for you. It might be nice if aruba provided a hook for substituting its default file reading algorithm. Maybe swap in a StringIO based implementation for testing.

      Your command-line design sounds like some interesting metaprogramming. Care to share it on the blog?

Your feedback