Saturday, November 2, 2013

Acceptance Testing of Angular.dart Routes


Single-page application routes are not fun to test. This is mostly because the router has to be working, but not actually causing the test page to navigate, which would halt a test run. I am hopeful that the test bed in Angular.dart can solve that problem.

If possible, this would be very exciting as I have not really figured out how to do it well in AngularJS. The tutorial includes a section on routing, which include some simple testing. But acceptance testing is a trickier beast.

As I have been doing these past few posts, I continue to use scheduled_test rather than the built-in (and very excellent unittest). The scheduled_test library helps to schedule what would otherwise be asynchronous actions, in serial order. In highly asynchronous code like Angular applications, this helps immensely.

Since this will be a multi-page application, I will need an <ng-view element to which the application can bind itself. This means a slightly different setUp() block of which I have grown rather fond. The overall structure is the same as any test—I declare variables that will be initialized in the setUp() block and used in the tests, perform the setup, and run tests:
  group('Multi-page', (){
    var tb, http;
    setUp((){
      // ...
    });
    test('Access to the day view', (){
      // ...
    });
  });
The setup starts the same as all Angular.dart test setup seems to start—establishing a test injector and ensuring that it is torn down in between tests:
    setUp((){
      // 1. Setup and teardown test injector
      setUpInjector();
      currentSchedule.onComplete.schedule(tearDownInjector);
      // ...
    });
Next, I establish my module with all of the components used in the actual application plus some test versions of common Angular components:
    setUp((){
      // ...
      // 2. Build test module, including application classes
      module((Module _) => _
        ..type(TestBed)
        ..type(MockHttpBackend)
        ..type(AppointmentBackend)
        ..type(AppointmentController)
        ..type(DayViewController)
        ..type(RouteInitializer, implementedBy: CalendarRouter)
      );
      // ...
    });
New today are the day view controller, which will bind to a details view of my calendar, and the router that is driving the multi-page application.

Next, I inject some of the things that will be tested and/or stubbed under test into existence:
    setUp((){
      // ...
      // 3. Inject test bed and Http for test expectations and HTTP stubbing
      var router;
      inject((TestBed _t, HttpBackend _h, Router _r, TemplateCache _cache) {
        tb = _t; http = _h; router = _r;

        _cache.put('partials/day_list.html', new HttpResponse(200, LIST_HTML));
        _cache.put('partials/day_view.html', new HttpResponse(200, VIEW_HTML));
      });
      // ...
    });
The test bed will hold the application and the view, so it is very much needed to test expectations. The HTTP backend will be used to stub HTTP requests so that a test server is not needed. New today are the template cache and the router.

Angular.dart facilitates code organization by supporting partial views in separate files. The template cache lets me specify how I want those to look under test. I am not 100% sold on this approach because now I have to keep my test views in sync with my application views. On the other hand, it does solve the problem of accessing files from the test's file:// browser context.

Next, I compile the view into the test bed:
    setUp((){
      // ...
      // 4. Associate test module with HTML
      tb.compile('<ng-view></ng-view>');
      // ...
    });
With the multi-view Angular application ready, I can navigate to the default route:
    setUp((){
      // ...
      // 5. Access the default application route
      schedule(()=> router.route(''));
      // ...
    });
The rest of the setup is the same as previous days in which I need to stub out the HTTP request from the default view and make sure bound variables are immediately available:
    setUp((){
      // ...
      // 6. Stub HTTP request made on module initialization
      http.
        whenGET('/appointments').
        respond(200, '[{"id":"42", "title":"Test Appt #1", "time":"00:00"}]');

      // 7. Flush the response stubbed for the initial request
      schedule(()=> http.flush());

      // 8. Trigger updates of bound variables in the HTML
      schedule(()=> tb.rootScope.$digest());
    });
So that is 8 steps of setup, which is admittedly longish. Then again, this is a full-blown acceptance test with routing, so it is hard to see how that could be cut down much. As things evolve, perhaps some of that is a candidate for refactoring into helper methods. For now, I am eager just to see if this will actually work.

My first test is to attempt to navigate to the details view by clicking on an appointment in the list view:
  group('Multi-page', (){
    var tb, http;
    setUp((){ /* ... */ });
    test('Access to the day view', (){
      schedule((){
        var el = tb.rootElement.
          queryAll('a').
          where((_)=> _.text.contains('Test Appt #1')).
          first;

        el.click();
      });
    });
  });
In this test, I find the first <a> tag that contains the appointment title from the setup. When I click it, however, I see exactly what I had feared—my test navigates from my test page context to file:///days/42.

Bother.

So was all of this for naught? Possibly, but I doubt it. I think this is probably a case of my working against a framework instead of with the framework. High quality frameworks and libraries should facilitate testing. Even when doing fairly complex testing, a quality framework will strive to make testing possible. And even in its current pre-alpha state, Angular.dart has the feel of a quality framework.

The question then becomes, where am I fighting the framework? I suspect that it is in the view that builds the <a> tag that the test clicks:
const LIST_HTML =
'''<div appt-controller>
     <ul>
       <li ng-repeat="appt in day.appointments">
         <a href="/days/{{appt.id}}">{{appt.time}} {{appt.title}}</a>
         <a ng-click="day.remove(appt)">
           <i class="icon-remove" ></i>
         </a>
       </li>
     </ul>
     <!-- ... -->
   </div>''';
The other tags in there, including the <a> tag on the very next line, use the ng-* directives. But on this link tag, I am explicitly pointing the href value to the route.

Instead, I need another ng-click directive that will, in turn, makes a call that routes with the framework instead of outside. The ng-click directive is then:
const LIST_HTML =
'''<div appt-controller>
     <ul>
       <li ng-repeat="appt in day.appointments">
         <a ng-click="day.navigate(appt)">{{appt.time}} {{appt.title}}</a>
         <a ng-click="day.remove(appt)">
           <i class="icon-remove" ></i>
         </a>
       </li>
     </ul>
     <!-- ... -->
   </div>''';
The controller then needs to inject the router and use it in the navigate() method:
@NgDirective(
  selector: '[appt-controller]',
  publishAs: 'day'
)
class AppointmentController {
  AppointmentBackend _server;
  Router _router;
  // ...
  AppointmentController(this._server, this._router) {
    _server.init(this);
  }
  // ...
  void navigate(Map appointment) {
    _router.route('/days/${appointment["id"]}');
  }
  // ...
}
Now that the view is working with the framework instead of against it, I can finish the test:
    test('Access to the day view', (){
      http.
        whenGET('/appointments/42').
        respond(200, '{"id":"42", "title":"Test Appt #1", "time":"00:00"}');
      schedule((){
        tb.rootElement.
          queryAll('a').
          where((_)=> _.text.contains('Test Appt #1')).
          first.
          click();
      });
      schedule(()=> http.flush());
      schedule(()=> tb.rootScope.$digest());
      schedule((){
        expect(
          tb.rootElement.query('dl').text,
          contains('Test Appt #1')
        );
      });
    });
After clicking the link, the HTTP request will retrieve the stubbed response. The next two schedules flush the response and digest the results, updating bound variables. And finally, it verifies that the updated view, which is organized with definition lists, contains the appointment detail.

Brilliant!

I absolutely cannot wait for Angular.dart to reach 1.0 (or even 0.9). Aside from a few rough edges—and let's face it pre-alpha usually means worse than rough edges—it is already very nice to write applications. And, more importantly, it already sports some serious testing capabilities. Not only is it fun to code, it is easy to maintain as the code evolves. Kudos to the developers on this project!


Day #923

2 comments:

  1. A few comments:

    1) When you do: 'tb.compile('')' angular routing system initializes and calls Router.listen() which would automatically call router.route(window.location.pathname), which is unpredictable in case of a test, because that URL would be the location of the test entry point. Angular allows you to provide a mock implementation of Window, just do myTestModule.type(Window, implementedBy: MockWindow). Angular testbed is currently lacking convenient mocks for routing, but you will need to mock window.onPopState, window.onClick, window.location.pathname, window.location.hash

    You can get a better idea of what's involved here:
    https://github.com/dart-lang/route/blob/experimental_hierarchical/lib/client.dart#L557

    Also, some ideas how to mock window and control the router:
    https://github.com/dart-lang/route/blob/experimental_hierarchical/test/client_test.dart#L313

    2) Don't call router.route() directly, instead use router.go(): https://github.com/dart-lang/route/blob/experimental_hierarchical/lib/client.dart#L431

    router.go() causes the browser URL to properly update, while router.route() only changes the internal routing state. Note: router.go() accepts route ('foo.bar.baz') path instead of the URI ('/foo/bar/baz').

    ReplyDelete
    Replies
    1. Huge thanks for the tips -- both here an on my other posts -- they are very much appreciated!

      I never did play with the pushstate setting. This might be the perfect excuse to do so. Thanks :)

      Delete