Sunday, August 18, 2013

Exceptions vs. Errors in Dart (and Testing)


Tonight, I am going to explore some (hopefully) simple testing of exception handling in Dart. After last night, I have the happy path for keyboard shortcuts working with syntax like the following:
      Keys.shortcuts({
        'Esc':          (){ /* ... */ },
        'Ctrl+N':       (){ /* ... */ },
        'Ctrl+O, ⌘+O':  (){ /* ... */ },
        'Ctrl+Shift+H': (){ /* ... */ }
      });
I have tests that describe establishing those shortcuts and verifying that a generated keyboard event will result in calling the supplied callback method. What I do not have are tests that describe errors: incorrect modifier keys (Ctl+O), incorrect key names (Esca), incorrect formatting of individual shortcuts (Ctrl+O+Shift) and incorrect combinations (e.g. missing a comma in Ctrl+O, ⌘+O).

I believe that these fall under the heading of “errors” in Dart, rather than “exceptions.” The latter are used for runtime situations that are out-of-the-ordinary, but still to-be-expected. That is something of a weak definition as it is something akin to saying that exceptions are extraordinary and ordinary at the same time. Still, they are off the happy path of execution (out-of-the-ordinary), but still need to be handled in some way to prevent an application from crashing. Good examples of those are built right into Dart core: divide-by-zero, problems reading a file, etc.

Errors, on the other hand, are largely compile time problems that can only be addressed by the programmer changing code. A keyboard shortcut of Esca can only be corrected by a programmer changing the code to read Esc.

Complicating this way of looking at exceptions vs. errors in Dart is the FormatException class. It almost seems like it applies to my invalid shortcut keys examples even though it is an exception, not an error. But FormatException is really only meant to be thrown when user or file input is invalid—not when the program itself is invalid. At least, that is my assumption—feel free to correct me if I am mistaken.

In other words, I am not trying to catch invalid I/O or user input, I am trying to catch programming errors. The first error for which I want to test is invalid key names. For those, I test at the individual ShortCut level rather that the higher-level Keys interface that generates instances of Shortcut:
    test("throws an error for invalid key name (e.g. Esca)", (){
      try { new ShortCut('Esca', (){}); }
      catch (e) {
        expect(e, new isInstanceOf<Error>());
      }
    });
Unfortunately, there is no built-in test matcher throwsError—just matchers for subclasses like throwsNoSuchMethodError. This is why my test explicitly uses a try-catch, leaving it usable, but a little ugly.

As it is, that test actually passes, but for the wrong reason. Currently ShortCut will throw a noSuchMethodError at an obscure place, making it very difficult for programmers to figure out where they have gone wrong. Instead, I would like to throw a custom InvalidKeyName error, which I define in the same file as ShortCut as:
class InvalidKeyName extends Error {
  final message;
  InvalidKeyName(this.message): super();
}
Then I can rewrite my test as:
    test("throws an error for invalid key name (e.g. Esca)", (){
      try { new ShortCut('Esca', (){}); }
      catch (e) {
        expect(e, new isInstanceOf<InvalidKeyName>('InvalidKeyName'));
      }
    });
That fails due to the added specificity in the expected error:
FAIL: ShortCut throws an error for invalid key name (e.g. Esca)
  Expected: an instance of InvalidKeyName
    Actual: NoSuchMethodError:<The null object does not have a method 'replaceFirst'.
I can make that pass with a conditional check that the supplied key name is known:
  void _createStream() {
    var key = char;
    if (char.length > 1) {
      if (!KeyIdentifier.containsKey(char)) {
        throw new InvalidKeyName("$char is not recognized");
      }
      key = KeyIdentifier.keyFor(char);
    }
    // ...
  }
With that, my specific error test is passing:
PASS: ShortCut throws an error for invalid key name (e.g. Esca) 
The last thing that I would like to do for this test is clean up some of the noise.

Instead of the try-catch block, I would rather say:
    test("throws an error for invalid key name (e.g. Esca)", (){
      expect(()=> new ShortCut('Esca', (){}), throwsInvalidKeyName);
    });
Here, I supply an anonymous function that creates an invalid ShortCut. The expectation is simply that it “throwsInvalidKeyName”. To make that work, I copy-paste-and-modify internal Dart matchers to get:
/** A matcher for InvalidKeyNames. */
const isInvalidKeyName = const _InvalidKeyName();

/** A matcher for functions that throw InvalidKeyName. */
const Matcher throwsInvalidKeyName =
    const Throws(isInvalidKeyName);

class _InvalidKeyName extends TypeMatcher {
  const _InvalidKeyName() : super("InvalidKeyName");
  bool matches(item, Map matchState) => item is InvalidKeyName;
}
The instance of _InvalidKeyName and the throwsInvalidKeyName do not strictly need to be compile time constants, but this is the convention in the Matcher library. Besides, it makes little sense to use an ordinary, mutable variable for a matcher.

With that, I have a nice, clear test matcher to go along with my specific and semantically meaningful error. This seems a fine stopping point for tonight. Hopefully the remaining cases will be similarly clean. If not, I will pick back up with them tomorrow.


Day #847

No comments:

Post a Comment