Tuesday, November 06, 2007

TestNG and Expected Exceptions

I've started reading Next Generation Java Testing which was just released last week. So far it's interesting enough. I've seen multiple presentations on TestNG, and read Cedric Beust's blog posts that describe the impetus for a new Java software testing approach, so the first chapter hasn't told me much I hadn't already known.

If this sounds like a lackluster introduction, I apologize. It's wonderfully written and informative so far.

Still, I've come across something that bothers me. Consider this example from the book:
public class BookingTest {
  private Plane plane;

  @BeforeMethod
  public void init() {
    plane = createPlane();
  }

  @Test(expectedExceptions = PlaneFullException.class)
  public void shouldThrowIfPlaneIsFull() {
    plane.bookAllSeats();
    plane.bookPlane(createValidItinerary(), null);
  }
}
(One Very Nice Thing about TestNG is that it's easier to read than JUnit, even if you don't know either testing API.)

My specific concern is the notion of expected exceptions in TestNG. The book explains that the expectedExceptions annotation is a reasonable replacement for the try/fail/catch pattern you find in JUnit:
public void testShouldThrowIfPlaneIsFull() {
  plane.bookAllSeats();
  try {
    plane.bookPlane(createValidItinerary(), null);
    fail("Exception expected");
  } catch(PlaneFullException expected) {
    // do nothing; exception expected.
  }
}
(Who puts semicolons in comments? I only put them in example comments. That makes them exemplary comments.)

I agree that the TestNG example is still easier to read than the JUnit example, but the problem is they're not effectively testing the same thing. Here's a more correct translation of the TestNG test using JUnit:
public void testShouldThrowIfPlaneIsFull() {
  try {
    plane.bookAllSeats();
    plane.bookPlane(createValidItinerary(), null);
    fail("Exception expected");
   } catch(PlaneFullException expected) {
     // do nothing; exception expected.
   }
 }
Did you catch the difference? The difference is in how exceptions thrown from plane.bookAllSeats() re handled. In the first JUnit example, if plane.bookAllSeats() throws any exception, the test will fail. In both the TestNG example and the second JUnit example, if plane.bookAllSeats() throws PlaneFullException, the test will still pass.

This bothered me all day. Let's say the engineer was very careful when he constructed this TestNG test. He would have known knew that plane.bookAllSeats() did not throw a PlaneFullException, so he could accept this mild semantic difference for the sake of readability. If, some time later, plane.bookAllSeats() is refactored to throw a PlaneFullException, and in addition, is accidentally broken, the TestNG test will falsely pass, and my JUnit test will correctly fail.

Here we have our problem: the expectedExceptions annotation attribute can't be used on the statement level. If I could annotate on the statement level, I could have this:
@Test
public void shouldThrowIfPlaneIsFull() {
  plane.bookAllSeats();
  @ExceptionExpected(class = PlaneFullException.class) {
    plane.bookPlane(createValidItinerary(), null);
  }
}
Semantically, you can't put annotations on the statement level, it doesn't exist in Java (though I have seen JLS proposals for this.)

Of course, I could simply use the try/fail/catch pattern in TestNG, but that's not The TestNG Way. (I want to say it's not "TestEnGee-Eee" or "TestNGish", but they both sound stupid.)

Let's analyze this test a little further. The first statement in the test is actually just more setup before calling plane.bookPlane(). I could move the set-up code to a specialized method annotated with @BeforeMethod, but given that that kind of set-up operations specialized for each test, readability reduces as the number of @BeforeMethod methods grows. Besides, it's nice to read each specific test as a sequence of statements.

Finally I realized an easy way to make this work:
public class BookingTest {
   private Plane plane;
   private boolean setupComplete;

   @BeforeMethod
   public void init() {
     plane = createPlane();
   }

  private void setupComplete() {
    this.setupComplete = true;
  }

  @AfterMethod
  public void verify() {
    if (!setupComplete) {
      throw new RuntimeException(
        "exception thrown before running critical test statement");
    }
  }

  @Test(expectedExceptions = PlaneFullException.class)
    public void shouldThrowIfPlaneIsFull() {
      plane.bookAllSeats();
      setupComplete();
      plane.bookPlane(createValidItinerary(), null);
    }
  }
In this case, setupComplete() operates as a gate. Now your test doesn't just pass if a PlaneFullException is thrown, you have to get past the setupComplete() method to be considered a successful test.

This solution works, but I don't quite love it. What about using Closures?
@Test
public void shouldThrowIfPlaneIsFull() {
  plane.bookAllSeats();
  expectException(PlaneFullException.class) {
    plane.bookPlane(createValidItinerary(), null);
  }
}

This looks both correct and concise. I love it.

Post-script

1. The authors are sensible enough to point out that there are plenty of times when using the expectedExceptions annotation attribute should be avoided, and they cover those cases. I just don't think I like expectedExceptions at all.

2. Would adding closures to the Java language obviate the need for statement-level annotations? It does here.

3. Those of you paying close attention are screaming that I missed something. Who said createValidItinerary() doesn't throw PlaneFullException?
@Test
public void shouldThrowIfPlaneIsFull() {
  plane.bookAllSeats();

  Itinerary itinerary = createValidItinerary();
  expectException(PlaneFullException.class) {
    plane.bookPlane(itinerary, null);
  }
}
4. This is yet another good argument for writing specialized exceptions for your use cases. It's somewhat unlikely that plane.bookAllSeats() and createValidItinerary() would throw PlaneFullException, but consider the difficulties if a Lazy Programmer designed the Plane API and used ArrayIndexOutOfBoundsException everywhere instead of PlaneFullException. Separating exeptions in a test method are just the beginning of that guy's troubles.

4 comments:

Cedric said...

Rob,

A better way to implement your setUpIsComplete() approach is to make it a method on which the real test depends.

rwoo said...

There is an alternative to the statement-level annotations we probably never get.

You can use proxies to test for expected exceptions. Have a look at this little helper:
http://code.google.com/p/catch-exception/

Rod

konberg said...

Rod, that's a clever idea.

Jackie Co Kad said...
This comment has been removed by a blog administrator.