Send to Kindle

Monday, July 8, 2013

Testing Error Conditions with Dart HttpRequest


I will not explore server-side HttpRequest behavior. I will not explore server-side HttpRequest behavior. I will not explore server-side HttpRequest behavior.

After a frustrating and completely unnecessary detour to explore how to break things in weird ways by omitting a single return statement yesterday, I am committing myself to the task at hand today. And that task is a unit test verifying the ability to delete records over HTTP in Hipster MVC.

After a little testing re-organization, I have a “pre-existing record” group in my test suite that manually creates a record over HTTP:
    group('pre-existing record', (){
      var model, model_id;

      setUp((){
        var completer = new Completer();

        model = new FakeModel()
          ..url = 'http://localhost:31337/widgets'
          ..attributes = {'test': 1};

        HipsterSync.
          call('create', model).
          then((rec) {
            model_id = rec['id'];
            completer.complete();
          });

        return completer.future;
      });
      // tests run once creation is confirmed...
    });
Having already tested record creation via HTTP POST elsewhere in my test suite, I use the same strategy in the group's setup here. In Hipster MVC, all data synchronization goes through the HipsterSync class. In this case, I am using the default behavior, which is to sync over HTTP (the sync strategy can be overridden to work with other storage as well).

The url attribute in my “fake model” is not just testing ambiance. I still have not figured out how to stub out HTTP calls in Dart, so that points to an actual server URL that is behaving in a REST-like manner. In this case, once it responds OK to a POST creation, I store the model_id for later use and complete the “completer” object.

The completer object is there so that I can return its future from the setUp() method. In Dart's unittest, this is a signal that the setup is asynchronous and that the other tests in this group need to wait until the setup is ready—when the completer completes.

Most of that has already been working. The new stuff today is a test that verifies that a record is successfully deleted. I start with a test that says that a delete operation on the model_id should be successful:
    group('pre-existing record', (){
      var model, model_id;
      setUp((){ /* ... */ });

      test("HTTP DELETE: it can delete existing records", (){
        model.url = 'http://localhost:31337/widgets/${model_id}';

        HipsterSync.
          call('delete', model).
          then(
            expectAsync1((response) {
              expect(response.statusCode, 204);
            })
          );
      });
    });
I set the model's URL to the REST-like widgets/model_id so that HipsterSync will know where to send the HTTP DELETE request. Then I invoke HipsterSync.call(), which is the nexus point for all data synchronization. I do not want multiple methods for the different CRUD operations—that would complicate changing the synchronization strategy (and that's also how Backbone.js does it).

Anyhow, since HipsterSync.call() will, by default, create an HTTP request, which is asychronous, HipsterSync.call() is asynchronous as well. In testing practice, this means that I have to tell my unit test not to check actual values against expected values until after the HipsterSync.call() has completed. Fortunately, Dart's unittest provides expectAsync0(), expectAsync1(), and expectAsync2() (depending on the number of arguments the wrapped callback takes) to flag tests as needing to poll until the asynchronous call is made.

In the case of HipsterSync.call(), a future is returned. When the future completes, the callback in the then() method is invoked. HipsterSync.call() supplies one argument to this callback: the response from the server. Once I have that, I check my expectation—that the response code is 204 (success with no response body).

Since I have not added delete behavior to HipsterSync, I expect this test to fail. But the error message that I get baffles me a bit:
CONSOLE MESSAGE: Exception: Class '_AsyncCompleter' has no instance method 'completeException'.

NoSuchMethodError : method not found: 'completeException'
Receiver: Instance of '_AsyncCompleter@0x2900bd4a'
Arguments: ["That ain't gonna work: 404"]
Eventually, I realize that Dart's completer error-handling mechanism has been renamed from completeException() to completeError(). I am somewhat surprised that dartanalyzer did not catch that. Even though I declare the completer variable with var instead of Completer, there seems to be enough information for dartanalyzer to figure out the type:
    // ...
    var request = new HttpRequest(),
        completer = new Completer();

    request.
      onLoad.
      listen((event) {
        HttpRequest req = event.target;

        if (req.status > 299) {
          completer.
            completeException("That ain't gonna work: ${req.status}");
        }
        else { /* ... */ }
      });
    // ...
Regardless, I clean up the testing message by renaming completer.completeException() as completer.completeError() and re-run to find:
CONSOLE MESSAGE: Uncaught Error: That ain't gonna work: 404
In other words, my test_server.dart code is returning a 404 upon the delete request (it turns out that Hipster MVC supports DELETE enough to get this far).

So I teach test_server.dart how to delete:
handleWidgets(req) {
  var id = // ..

  if (req.method == 'POST') return createWidget(req);
  if (req.method == 'GET' && id != null) return readWidget(id, req);
  if (req.method == 'PUT' && id != null) return updateWidget(id, req);
  if (req.method == 'DELETE' && id != null) return deleteWidget(id, req);

  notFoundResponse(req);
}
// ...
deleteWidget(id, req) {
  if (!db.containsKey(id)) return notFoundResponse(req);

  db.remove(id);

  HttpResponse res = req.response;
  res.statusCode = HttpStatus.NO_CONTENT;
  res.close();
}
Then I re-run my test to find that HipsterSync.call() does not complete with the request, but with the JSON parsed data returned from the server. In this case, the body is empty, which would make for a dull test. So I refactor, adding another group and setup to delete the record. The test then becomes a check to verify that the record is, indeed, deleted:
    group('pre-existing record', (){
      var model, model_id;
      setUp((){ /* ... */ });
      group("HTTP DELETE:", (){
        setUp((){
          model.url = 'http://localhost:31337/widgets/${model_id}';
          return HipsterSync.call('delete', model);
        });

        test("will remove the record from the store", (){
          HipsterSync.
            call('read', model).
            catchError(
              expectAsync1((error) {
                expect(error, "That ain't gonna work: 404");
              })
            );
        });
      });
    });
That passes and has the benefit of being a much stronger test. The setUp() makes use of the fact that HipsterSync.call() returns a future, signalling that the test will poll until the future completes (i.e. when the HTTP DELETE request returns successfully). The actual test then expects an asynchronous error / exception. In the necessary expectAsync1(), I verify that the message is my silly “ain't gonna work” message with the HTTP status code. At some point I will have to convert that error to a legitimate error or exception object, but that will suffice for now.

At this point, I have successfully tested creating, reading, updating and deleting records in HipsterSync. I still need to test collection retrieval. I should also review my tests to be sure that I am applying lessons learned to previous tests. And I still want to be able to stub HttpRequest so that I do not need test_server.dart to be running. Despite all that remains, this was good progress and makes for a fine stopping point.

Day #806

No comments:

Post a Comment