July 28, 2014
Hot Topics:
RSS RSS feed Download our iPhone app

Test-Driving a Java Command Line Application

  • April 3, 2007
  • By Jeff Langr
  • Send Email »
  • More Articles »

System.exit Challenges

The next part of writing a test is a bit more challenging. You need to verify that an appropriate error code is returned to the operating system. Right now, all three of the abort conditions should return an error code of 1. You can use the System.exit() method to accomplish this need. Unfortunately, calling System.exit() at any point will cause your JUnit run to fail as well.

Somehow, you need to override System.exit(), or otherwise intercept a call to it, capturing the argument passed to it. Unfortunately, it's a static method. The most effective way is probably to encapsulate the call to System.exit() in an instance-side method, and override it in the test. Unfortunately again, you're doing all this from the main method, which is itself static. Statics in Java just make for more difficult testing!

A bit of rethinking about what you are testing is in order. Your goal is really not to put a bunch of code in the main method and then validate it. Your real goal is to ensure that you can execute the application from the command line, and that you can verify that your application guards against invalid command line arguments. Your realization should be, then, that you don't really need all that code in the main method itself.

Instead, you can put all of the initial logic into App instance-side methods. Your final goal, then, would be to verify that the main method initialized the logic defined in App.

Making the first part of the change is easy. In the test, you create a field of App type and initialize it in the @Before method. You also change invocations of App.main() to instance-side calls to app.execute().

...
public class AppTest {
   ...
   private App app;

   @Before
   public void setUp() {
      consoleText = new ByteArrayOutputStream();
      console = System.out;
      System.setOut(new PrintStream(consoleText));
      app = new App();
   }
   ...
   @Test
   public void testAbortWhenInsufficientArgumentsSupplied() {
      app.execute();
      assertAbort(App.MSG_TOO_FEW_ARGUMENTS);
   }

   @Test
   public void testAbortWhenTooManyArgumentsSupplied() {
      app.execute("a", "b", "c");
      assertAbort(App.MSG_TOO_MANY_ARGUMENTS);
   }

   @Test
   public void testAbortWhenPortNotInteger() {
      app.execute("server", "port not a number");
      assertAbort(App.MSG_PORT_MUST_BE_NUMBER);
   }
   ...
}

In the production code, you create execute() and change validate to be an instance-side method. You also delete main because it's no longer tested. All tests still pass!

public class App {
   ...
   private void validate(String[] args) {
      ...
   }

   public void execute(String... args) {
      validate(args);
   }
}

Now, you're ready to verify the return code. You create an override of the App class in the @Before method. This override supplies a definition for a to-be-created exit method. The override simply traps the error code passed to it.

public class AppTest {
   ...
   private int errorCode;

   @Before
   public void setUp() {
      consoleText = new ByteArrayOutputStream();
      console = System.out;
      System.setOut(new PrintStream(consoleText));
      app = new App() {
         @Override
         public void exit(int errorCode) {
            AppTest.this.errorCode = errorCode;
         }
      };
   }
   ...
   private void assertAbort(String expectedMessage) {
      assertEquals(expectedMessage + EOL + App.USAGE + EOL,
                   consoleText.toString());
      assertEquals(App.ERROR_CODE_BAD_ARGUMENTS, errorCode);
   }
}

Having refactored the individual tests to use assertAbort means that you were able to add this new assertion in one place!

The production code that gets this to pass:

public void execute(String... args) {
   validate(args);
   exit(ERROR_CODE_BAD_ARGUMENTS);
}

public void exit(int errorCode) {
   System.exit(errorCode);
}

The last statement in execute() delegates to the exit() method defined on App. In production, exit simply delegates to System.exit(), which terminates the application. When AppTest executes, it uses the overridden version of exit() instead.

A key point: this stubbing-out of exit implies that the real production code it contains is not actually getting tested. The implication is that you have some form of integration or system test that will verify whether or not the application works in a deployed environment. Unit testing is never a complete solution to testing!

Final Steps

You can quickly write a positive test against App, and then come back and figure out how to test-drive the main method.

@Test
public void testExecuteWhenArgumentsValid() {
   app.execute("server", "8080");
   assertEquals("server", app.server());
   assertEquals(8080, app.port());
   assertEquals(0, errorCode);
}

After adding the production code that makes this test pass, you now can refactor to a cleaner solution:

public class App {
   static final String USAGE = "Usage:ntjava App server port";
   static final String MSG_TOO_FEW_ARGUMENTS   = "too few arguments";
   static final String MSG_TOO_MANY_ARGUMENTS  = "too many arguments";
   static final String MSG_PORT_MUST_BE_NUMBER = "port not numeric";
   static final int ERROR_CODE_BAD_ARGUMENTS   = 1;

   private int port;
   private String server;
   private String errorMessage;

   private boolean validate(String[] args) {
      if (args.length < 2) {
         errorMessage = MSG_TOO_FEW_ARGUMENTS;
         return false;
      }
      if (args.length > 2) {
         errorMessage = MSG_TOO_MANY_ARGUMENTS;
         return false;
      }

      try {
         port = Integer.parseInt(args[1]);
      } catch (NumberFormatException e) {
         errorMessage = MSG_PORT_MUST_BE_NUMBER;
         return false;
      }

      server = args[0];
      return true;
   }

   public void execute(String... args) {
      if (!validate(args)) {
         System.out.println(errorMessage);
         System.out.println(USAGE);
         exit(ERROR_CODE_BAD_ARGUMENTS);
      }
   }

   public void exit(int errorCode) {
      System.exit(errorCode);
   }

   public String server() {
      return server;
   }

   public int port() {
      return port;
   }
}

The final test:

@Test
public void testExecuteFromMain() {
   App.main("server", "8080");
   App app = App.current();
   assertEquals("server", app.server());
   assertEquals(8080, app.port());
}

And the production code in App:

public class App {
   ...
   private static App app = null;

   public static App current() {
      return app;
   }

   public static void main(String... args) {
      app = new App();
      app.execute(args);
   }
   ...

The minor concession to testing is that the main() method stores the App instance in a static variable, and makes it available via the current() method.

Getting the App main class under test represented a bit of effort and some small convolutions. Is it worth it? Absolutely, in my experience. I've encountered many difficult main methods. Often, they contain a lot of ugly code to validate arguments. This ugly code easily masks defects. Sure, you can find open source libraries that can help simplify parsing of command-line args. But, you also can test-drive your own main methods with a reasonable amount of effort. You don't have to give up on TDD.

Another reason to consider this approach is that it represents a more flexible design. Imagine a second class requiring a main method, or an entire suite of command-line applications. A reusable structure easily emerges from our finished code, and new command-line apps are even simpler to put together. New tests become easier to write, too!

About the Author

Jeff Langr is a veteran software developer with a score and more years of experience. He's authored two books and dozens of published articles on software development, including Agile Java: Crafting Code With Test-Driven Development (Prentice Hall) in 2005. You can find out more about Jeff at his site, http://langrsoft.com, or you can contact him directly at jeff@langrsoft.com.





Page 2 of 2



Comment and Contribute

 


(Maximum characters: 1200). You have characters left.

 

 


Sitemap | Contact Us

Rocket Fuel