Friday, August 2, 2013

Outside-In TDD with Dart and HTTP Libraries


I am in the process of building a web server in Dart that I can use with the code samples in Dart for Hipsters. In the last edition, I added tests for nearly all of the code samples. I ended up doing a lot of duck typing and mocking in those tests and it really ended up burning me. Many of the duck typed examples still pass even though Dart's event system has switched from event to streams. The HTTP tests still mostly work, but they are a pain to maintain.

My test server has really helped out with the HTTP tests (I am just going to have to rewrite the events chapter). But as much as the test server helps, I still have a problem that my REST-like backend serves a /widgets resource. That is fine in most cases, but looks out of place in a comic book collection class:
class ComicsCollection implements Iterable {
  // ...
  String get url => '/widgets';
  // ...
}
I have many similar code samples that hit against the /comics resource instead of /widgets. So I am going to drive a new feature that allows me to tell the server to add an arbitrary URL resource as an alias for /widgets. Clearly this is not something that a real web server would ever do, but it should make my tests much easier to include directly in the book.

Futhermore, I would like a nice dart:html (browser side) interface to allow me to set this alias. I do believe that will require a little outside-in test driven development. Gods, but I do love me some good buzz word driven development. Wheee!

Ahem.

From a test, I would like to be able to alias routes as Kruk.alias(existing_url, alias_url). In my code sample tests, that would end up as Kruk.alias('/widgets', '/comics').

Hrm... you know what? I think I will make use of optional parameters to aid in readability: Kruk.alias('/widgets', as: '/comics'). I have long since given up trying to remember how different languages and libraries handle argument order for aliasing methods. With an as: optional parameter, I won't have to remember. It will cost me an additional test, but I can live with that.

I start with a test for the Kruk.alias() method. Eventually, I have to write a test on the server itself to establish this test routing, but I need to start somewhere. That somewhere is on the outermost reaches of the code—the Kruk interface that make a call into the server. The test is:
    test("Can alias a route in the server", (){
      schedule(()=> post('${Kruk.SERVER_ROOT}/widgets', '{"name": "Sandman"}'));
      schedule(()=> Kruk.alias('/widgets', as: '/comics'));

      var ready = schedule(()=> get('${Kruk.SERVER_ROOT}/comics'));
      schedule(() {
        ready.then((json) {
          var list = JSON.parse(json);
          expect(list.first['name'], 'Sandman');
        });
      });
    });
I am still using the Scheduled Test package to test asynchronous code. In Scheduled Test, each async operation is wrapped in a “schedule.” This effectively runs each asynchronous operation synchronously ensuring that asynchronous tests are deterministic.

In this test, I POST a record to the /widgets resource on the test server. In the next schedule, I create an alias with the Kruk.alias() method that I am trying to create. The last two schedules make an HTTP request and parse that request. I set the expectation in the last schedule—that the response from the /comics resource will include the name of a comic book even though I originally created the comic book on the /widgets resource.

I think the following alias() method ought to do the trick:
class Kruk {
  static String SERVER_ROOT = 'http://localhost:31337';

  static alias(old_url, {as}) {
    return HttpRequest.request(
      '${SERVER_ROOT}/alias',
      sendData: Uri.encodeQueryComponent('old=${old_url}&new=${as}')
    );
  }
}
I am posting to the /alias resource and am URL encoding the query parameters that describe to the server what I want to create. When I run the test with that code, the HTTP server responds:
GET http://localhost:31337/alias 404 (Not Found) 
In other words, it is time for me to work my way into the server code to build this resource in the server.

In the server test, I first create a test to ensure that the server resource is in place:
    test("POST /alias with old and new URLs responds OK", (){
      var responseReady = schedule(() {
        return new HttpClient().
          postUrl(Uri.parse("http://localhost:31337/alias")).
          then((request) {
            request.write('old=/widgets&new=/foo');
            return request.close();
          });
      });

      schedule(() {
        responseReady.then((response)=> expect(response.statusCode, 204));
      });
    });
There are two schedules in there. First, I use the HttpClient from the dart:io (server side) package to POST to the server (the server is already started in setup). The second schedule then checks that the server responds successfully.

This inside test fails just as the outside test did, though now I have a single point of failure with which to work:
Server started on port: 31337
[2013-08-02 23:30:54.620] "POST /alias" 404
Uncaught Error: Expected: <204>
  Actual: <404>
I could likely get this whole thing working with just the outside test, but this single point of failure makes for far fewer moving parts. It should also make it easier to probe boundary conditions later.

I get that (and the rest of the aliasing) passing with:
Map aliases = {};
addAlias(req) {
  req.toList().then((list) {
    var body = new String.fromCharCodes(list[0]);
    var alias = Uri.splitQueryString(body);
    aliases[alias['new']] = alias['old'];

    HttpResponse res = req.response;
    res.statusCode = HttpStatus.NO_CONTENT;
    res.close();
  });
}
After parsing the URI encoded body, I add to the server's list of aliases. Then, in the request processing, I muck with the path if it matches an alias:
      var path = req.uri.path;
      if (aliases.containsKey(path)) {
        path = aliases[path];
      }
Back out in the Kruk interface, I find that things are not working. But, since I know from my tests that the server can create aliases, my call must be wrong. Indeed, it turns out that I need to be more fine grained about URI encoding my POST body:
  static Future<HttpRequest> alias(String old_url, {as: String}) {
    return HttpRequest.request(
      '${SERVER_ROOT}/alias',
      method: 'post',
      sendData: 'old=${Uri.encodeComponent(old_url)}&' +
                'new=${Uri.encodeComponent(as)}'
    );
  }
With that, I have a test verifying that I can proceed making the example in Dart for Hipsters a little clearer and, more importantly, easier to maintain. Speaking of which, I'd best be getting to that now...


Day #831

No comments:

Post a Comment