Thursday, July 4, 2013

Getting Started Testing Web Connections in Dart

‹prev | My Chain | next›

I am a huge fan of testing in Dart. The Dart developers would have done the web a huge service simply by introducing a browser-based language with built-in testing, but they went above and beyond. The built-in unittest library makes for elegant, maintainable tests that work quite well as either unit or functional tests.

Of late, I have been more focused on the functional variety, but with a twist. The ICE Code Editor is an entirely browser-based application, affording me several conveniences while testing. Setup and teardown in the tests is self-contained since persistence is maintained in the browser. Furthermore, I have no need for testing any kind of network connections. Frankly, it is a pleasure. But…

There is the nagging feeling that Dart testing will be just as woeful for testing HTTP applications as are the JavaScript counterparts. Worse, at least with JavaScript, the dynamic nature of the language allows for stubbing out HTTP request (see sinon.js). What are my options in Dart?

For tonight, I am going to play with more straight-forward methods of testing HTTP connections in Dart: unit testing callbacks and full-blown connections to a test server. I just so happen to have a bug in Hipster MVC that ought to let me do this.

Hipster MVC is a Backbone.js knock-off written in Dart and serving as the main foil for exploring Dart in the Dart for Hipsters book. The bug in question is in the data syncing class, HipsterSync. It seems that parsing empty responses is broken.

Currently, the default syncing method in HipsterSync is a rather large HTTP request:
class HipsterSync {
  // ...
  static _defaultSync(_method, model) {
    // ...
    var request = new HttpRequest(),
        completer = new Completer();

    request.
      onLoad.
      listen((event) {
        HttpRequest req = event.target;
        // Exception code here...
        var json = JSON.parse(req.responseText);
        completer.complete(json);
      });

    // Send the request here...
    return completer.future;
  }
}
I have no tests whatsoever for this code, so I am going to start with a test that describes how that JSON parsing works. For that, I need a little code re-organization, which I drive with a test:
    test("parsing JSON", (){
      expect(
        HipsterSync.parseJson('{"foo": 1}'),
        {'foo': 1}
      );
    });
This fails since I still need to refactor:
FAIL: Hipster Sync parsing JSON
  Caught No static method 'parseJson' declared in class 'HipsterSync'.
I make that pass by extracting the JSON parsing code out into its own method and calling it from the current code:
class HipsterSync {
  // ...
  static _defaultSync(_method, model) {
    // ...
    var request = new HttpRequest(),
        completer = new Completer();

    request.
      onLoad.
      listen((event) {
        HttpRequest req = event.target;
        // Exception code here...
        completer.complete(parseJson(req.responseText));
      });

    // Send the request here...
    return completer.future;
  }

  static parseJson(json) {
    return JSON.parse(json);
  }
}
With that, I have a passing test describing the existing behavior:
PASS: Hipster Sync parsing JSON
Now, to fix the bug I write another test that attempts to mimic the behavior reported in the issue:
    test("can parse empty responses", (){
      expect(
        HipsterSync.parseJson(''),
        {}
      );
    });
That fails just as described in the ticket (well, not exactly since there was no stack trace included):
FAIL: Hipster Sync parsing empty responses
  Caught FormatException: Unexpected character at 0: ''
  JsonParser.fail                                                dart:json 466:5
  JsonParser.parse                                               dart:json 274:33
  _parse                                                         dart:json 29:39
  parse                                                          dart:json-patch/json_patch.dart 2:16
  HipsterSync.parseJson                                          package:hipster_mvc/hipster_sync.dart 82:22
I can make that pass by converting my newly minted HipsterSync.parseJson() method to handle the empty string case:
  static parseJson(json) {
    if (json.isEmpty) return {};
    return JSON.parse(json);
  }
With that, I have two passing tests describing the JSON parsing that HipsterSync uses by default to persist data:
PASS: Hipster Sync can parse regular JSON
PASS: Hipster Sync can parse empty responses
I have fixed the bug, but have not really done anything to test the HTTP connection. Instead I have whittled away at the code around the HTTP connection so that more of it is tested, but I have none of the HTTP request and resulting behavior tested.

For that, I am going to try starting up a simple test server, written in Dart:
import 'dart:io';
import 'dart:json' as JSON;

main() {
  var port = Platform.environment['PORT'] == null ?
    31337 : int.parse(Platform.environment['PORT']);

  HttpServer.bind('127.0.0.1', port).then((app) {

    app.listen((HttpRequest req) {
      req.response.write(JSON.stringify({'foo': 1}));
      req.response.close();
    });

    print('Server started on port: ${port}');
  });
}
No matter the request to this server, it will always respond with the JSON string: {'foo': 1}. I start the server:
➜  test git:(master) ✗ dart test_server.dart
Server started on port: 31337
With that, I am ready to write a HipsterSync test that probes this server. I will write a test against the high-level HispterSync.call() which expects two arguments: a CRUD action name and a HipsterModel instance. Since I am just getting started with testing, I will use “get” as the CRUD action. For the model, I create a fake model class just for this test:
class FakeModel {
  String url = 'http://localhost:31337/test';
}
The only property used by the default HipsterSync implementation is the url property, so this ought to suffice. Using that, I can write a test that expects the {'foo': 1} response:
      test("it can parse responses", (){
        _test(response) {
          expect(response, {'foo': 1});
        }

        var model = new FakeModel();
        HipsterSync.call('get', model).then(expectAsync1(_test));
      });
I have to use unittest's expectAsync1() method (poll for an asynchronous call that will take one argument). Since the underlying HttpRequest is asynchronous, I have to accommodate it in the test. Happily, Dart's expectAsyncX methods make that easy.

And, with that, I have a fully functional test to go along with my two unit tests:
PASS: Hipster Sync can parse regular JSON
PASS: Hipster Sync can parse empty responses
PASS: Hipster Sync HTTP get it can parse responses
There are still numerous questions that I need to explore with this. Will that work under continuous integration? Can I reliably start and stop that server as part of a command-line run? Can I easily avoid CORS when running from the browser? And, most importantly, is there some way that I can fake this without an actual server?

Even with those unanswered questions, this is some good progress for tonight. I will pick back up with the first of those unanswered questions tomorrow.


Day #802

No comments:

Post a Comment