Tuesday, October 22, 2013

Testing Angular.dart HTTP Requests


It is possible that the only reason that I have been exploring Angular.dart is so that I could write tonight's post on testing browser HTTP requests in it. Actually, it's not really possible—Angular.dart, like its older JavaScript sibling AngularJS, is shaping up to be a very exciting framework for the browser (and mobile). So any time exploring it, is time well spent. But I am exceedingly interested in how Angular.dart does HTTP testing from the browser.

I have tried mocking Dart's HttpRequest object before, without satisfying results. Oh, it worked, but the code that injected either the real or mock HttpRequest object always ended up feeling a bit forced—like something written solely to satisfy testing, not to generate real code. I am unsure if Angular.dart's mock HTTP request will feel much better in my code—it may be something that works best in a library that lives and breathes dependency injection. Either way, I am going to find out.

I continue to work with my exploratory angular-calendar application. After last night, I have the appointment controller's add() method calling the method of the same name in the server controller:
class AppointmentCtrl {
  List appointments = [];
  String newAppointmentText;
  ServerController _server;

  AppointmentCtrl(this._server) {
    _server.init(this);
  }

  void add() {
    var newAppt = fromText(newAppointmentText);
    appointments.add(newAppt);
    newAppointmentText = null;
    _server.add(newAppt);
  }
  // ...
}
In fact, I have a plain-old Dart unittest that drove this implementation:
    setUp((){
      server = new ServerCtrlMock();
    });

    test('adding records to server', (){
      var controller = new AppointmentCtrl(server);
      controller.newAppointmentText = '00:00 Test!';
      controller.add();

      server.
        getLogs(callsTo('add', {'time': '00:00', 'title': 'Test!'})).
        verify(happenedOnce);
    });
I am using a Dart mock here. The server mock is injected in the appointment controller, which is a typical Angular controller backing various ng-directive type things in a web page. When one of those ng-directive things, specifically an ng-click directive, tells the controller to add an element, it calls the controller's add() method—just like my test does. The test goes on to verify that, by calling the appointment controller add() method, that the server's add() method is called as well, with a Map object to be persisted to the server via a REST call.

It's pretty slick, but there's a problem: the server's add() method isn't actually doing anything yet:
class ServerCtrl {
  Http _http;
  ServerCtrl(this._http);

  init(AppointmentCtrl cal) {
    _http(method: 'GET', url: '/appointments').
      then((HttpResponse res) {
        res.data.forEach((d) {
          cal.appointments.add(d);
        });
      });
  }

  add(Map record) {
    // Something should happen here...
  }
}
I am going to use a little bit of test driven development to make that add() happen.

The ServerCtrl constructor requires a single parameter: an Angular.dart Http object. Since I do not want to actually send a real request in my test (just test what happens if I did), I can not create a real Http object in my test. Instead, I create an instance of MockHttpBackend to supply to my ServerCtrl object:
  group('Server Controller', (){
    var server, http;
    setUp((){
      http = new MockHttpBackend();
      server = new ServerCtrl(http);
    });

    test('dummy', (){ expect(server, isNotNull); });
  });
That works as long as I import the mock module from Angular.dart:
import 'package:unittest/unittest.dart';
import 'package:unittest/mock.dart';
import 'dart:html';
import 'dart:async';

import 'package:angular/angular.dart';
import 'package:angular/mock/module.dart';

import 'package:angular_calendar/calendar.dart';
// ...
So far I have only succeeded in creating an instance of the mock Http class. What I really want to do is set an expectation that a POST request to the /appointments URL should occur, then call the server controller's add() method to check that expectation. Given AngularJS's awesome test support, it should come as no surprise that MockHttpBackend sports a pretty brilliant expectation API. Here, since I expect a POST, I use the expectPOST method:
  group('Server Controller', (){
    var server, http;
    setUp((){ /* ... */ });
    test('add will POST for persistence', (){
      http.expectPOST('/appointments');
      server.add({'foo': 42});
      http.flush();
    });
  });
Like most (all?) HTTP stubbing libraries, MockHttpBackend will store all of the requests that need to be made (e.g. the POST in response to the add() call) so that multiple requests can be tested. Once the test is ready to proceed, tests like this need to flush() all pending requests to make them actually happen.

When I run this test, I get a failure because I have no requests pending:
ERROR: Server Controller add will POST for persistence
  Test failed: Caught [No pending request to flush !]
  package:angular/mock/http_backend.dart 492:28                                                                                MockHttpBackend.flush
  ../test.dart 59:17                                                                                                           main.<fn>.<fn>
  package:unittest/src/test_case.dart 111:30
...
But this is the very code that I am trying to TDD here!

So I try adding code to make my test pass. In my server controller, I call the injected _http object:
class ServerCtrl {
  // ...
  add(Map record) {
    _http(method: 'POST', url: '/appointments');
  }
}
Now, when I run my test, I find that I have changed the error message:
ERROR: Server Controller add will POST for persistence
  Test failed: Caught Closure call with mismatched arguments: function 'call'
  
  NoSuchMethodError: incorrect number of arguments passed to method named 'call'
  Receiver: Instance of 'MockHttpBackend'
  Tried calling: call(method: "POST", url: "/appointments")
  Found: call(method, url, data, callback, headers, timeout)
  dart:core-patch/object_patch.dart                                                                                            Object.noSuchMethod
  package:angular_calendar/calendar.dart 69:10                                                                                 ServerCtrl.add
  ../test.dart 66:17 
Bother.

So it seems that Angular.dart's MockHttpBackend call has a different method signature than Http. I think that's a bug (I promise to file an issue tomorrow if true), but I don't think it is a show-stopper. Instead of injecting a MockHttpBackend into my server controller, I can inject an Http that has a MockHttpBackend instead of the usual HttpBackend:
  group('Server Controller', (){
    var server, http_backend;
    setUp((){
      http_backend = new MockHttpBackend();
      var http = new Http(
        new UrlRewriter(),
        http_backend,
        new HttpDefaults(new HttpDefaultHeaders()),
        new HttpInterceptors()
      );
      server = new ServerCtrl(http);
    });
    test('add will POST for persistence', (){ /* ... */ });
  });
That makes for an uglier test, but I have never been one to care too much about pretty tests. As long as the resulting code is clean.

With that change, and removing the flush() that is no longer necessary, I have my test passing, but complaining. It seems that even when I do not care about the response, I need a response:
    test('add will POST for persistence', (){
      http_backend.
        expectPOST('/appointments').
        respond('{"id:"1", "foo":42}');

      server.add({'foo': 42});
    });
Now I have a passing test with no complaints. I can make one more small improvement. In the expectPOST() call, I can specify that I expect the JSON encoded version of the Map being added to the list of calendar appointments:
    test('add will POST for persistence', (){
      http_backend.
        expectPOST('/appointments', '{"foo":42}').
        respond('{"id:"1", "foo":42}');

      server.add({'foo': 42});
    });
To make that pass, I supply a call to JSON.decode() as the data for my POST in the application code:
class ServerCtrl {
  Http _http;
  ServerCtrl(this._http);
  // ...
  add(Map record) {
    _http(method: 'POST', url: '/appointments', data: JSON.encode(record));
  }
}
With that, I have my calendar add-appointment code working and tested. Yay!

Aside from the little call signature mismatch, this was pretty easy. I am unsure if I can make good use of this in other Dart tests (I'll need to play around with it some more), but I am darned excited about this for testing Angular.dart code.


Day #912

No comments:

Post a Comment