Friday, November 1, 2013

User Input in Angular.dart Acceptance Tests


At the risk of self-bikeshedding, I continue to refine the setup of my Angular.dart test code. Actually, I don't think it is really bikeshedding, testing is a very big factor that I use in evaluating new languages, libraries and tools. Crazy as it might sound to others, it is probably the biggest factor for me. Long after the shiny fades, I am left with the need to maintain and build real code. Without strong testing, that is just impossible.

So I take one more day to refine the setup of my tests and use my updated approach to test something new. I am still using the scheduled_test package (instead of the built-in unittest) for async goodies—mostly the ability to serialize normally asynchronous activities inside of “schedules.”

The complete setup that I am now favoring looks like:
  group('Appointment Application', (){

    var tb, http;

    setUp((){
      // 1. Setup and teardown test injector
      setUpInjector();
      currentSchedule.onComplete.schedule(tearDownInjector);

      // 2. Build test module, including application classes
      module((Module _) => _
        ..type(TestBed)
        ..type(MockHttpBackend)
        ..type(AppointmentBackend)
        ..type(AppointmentController)
      );

      // 3. Inject test bed and Http for test expectations and HTTP stubbing
      inject((TestBed _t, HttpBackend _h) {tb = _t; http = _h;});

      // 4. Associate test module with HTML
      tb.compile(HTML);

      // 5. Stub HTTP request made on module initialization
      http.
        whenGET('/appointments').
        respond(200, '[{"id":"42", "title":"Test Appt #1", "time":"00:00"}]');

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

      // 7. Trigger updates of bound variables in the HTML
      schedule(()=> tb.rootScope.$digest());
    });
    // The actual tests go here...
  });
With comments and spacing, it is admittedly longish, but only steps 1-4 (or some variation thereof) will be common to most Angular.dart acceptance tests. The setup and teardown in #1 will appear verbatim in all tests. The application classes will change in #2, but the module creation and the injection of the test bed and mock HTTP class will be typical. Similarly, obtaining the test bed and mock HTTP instances in step 3 will almost always be needed. Lastly, compiling test HTML to attach the module will almost always take place.

The last three steps are specific to my angular caledar example application. Upon initialization, it makes an HTTP request for the current list of appointments to populate the calendar. Steps 5-7 ensure that a mock HTTP response is ready, responds, and triggers bound variable updates as a result. These steps could go elsewhere (sub-setup, helper function), but seem fine here.

For reference, the HTML that I am using is a slightly stripped down version of my application's HTML:
const HTML =
'''<div appt-controller>
     <ul>
       <li ng-repeat="appt in day.appointments">
         {{appt.time}} {{appt.title}}
         <a ng-click="day.remove(appt)">
           <i class="icon-remove" ></i>
         </a>
       </li>
     </ul>
     <form onsubmit="return false;">
       <input ng-model="day.newAppointmentText" type="text">
       <input ng-click="day.add()" type="submit" value="add">
     </form>
   </div>''';
With all of that in place, my test to verify the initial load is quite compact:
  group('Appointment Application', (){
    // ...
    setUp((){/* ... */});
    test('Retrieves records from HTTP backend', (){
      schedule((){
        expect(
          tb.rootElement.query('ul').text,
          contains('Test Appt #1')
        );
      });
    });
  });
I could see code relying on tests like that thriving, which is why I am so keen on Angular.dart—even in its current pre-pre-alpha state.

That is all well and good, but real acceptance tests need to be able to mimic user actions. If Angular.dart is as cool as I suspect, then I ought to be able to create an acceptance test that describes adding a new appointment, let's say “Go to bed” at 23:59. That encompasses filling in a text field, submitting a form, processing an HTTP POST to create the record and verifying that the UI is properly updated. There are a number of moving, intertwined pieces involved. Let's see if it's doable.

I am reusing the same setUp() from above. With that in place, I start this test describing how the POST request should look and what I expect the response from the server to be:
    test('Add a record', (){
      http.
        whenPOST('/appointments', '{"time":"23:59","title":"Go to bed"}').
        respond(201, '{"id":"42", "title":"Go to bed", "time":"23:59"}');
      // ...
    });
Next up, I need to fill out the text field and click submit. I do this in another schedule to ensure that the setUp() asynchronous actions have completed:
    test('Add a record', (){
      http.
        whenPOST('/appointments', '{"time":"23:59","title":"Go to bed"}').
        respond(201, '{"id":"42", "title":"Go to bed", "time":"23:59"}');

      schedule((){
        var el = tb.rootElement.query('input[type=text]')
          ..value = '23:59 Go to bed';
        tb.triggerEvent(el, 'change');

        tb.rootElement.
          query('input[type=submit]').
          click();
      });
      // ...
    });
The only tricky part in there is the need to generate the change event. It is not sufficient in Angular to set the value of my ng-model text field. Angular needs that change event to know that there is data to process. Happily, the TestBed class supports a triggerEvent convenience method to facilitate this.

Next, I have to process the HTTP request and digest variable bindings:
      // ...
      schedule(()=> http.flush());
      schedule(()=> tb.rootScope.$digest());
      // ...
And last, but not least, I have to set my expecations:
      // ...
      schedule((){
        expect(
          tb.rootElement.query('ul').text,
          contains('Go to bed')
        );
      });
      // ...
The entire test is then:
    test('Add a record', (){
      http.
        whenPOST('/appointments', '{"time":"23:59","title":"Go to bed"}').
        respond(201, '{"id":"42", "title":"Go to bed", "time":"23:59"}');

      schedule((){
        var el = tb.rootElement.query('input[type=text]')
          ..value = '23:59 Go to bed';
        tb.triggerEvent(el, 'change');

        tb.rootElement.
          query('input[type=submit]').
          click();
      });
      schedule(()=> http.flush());
      schedule(()=> tb.rootScope.$digest());
      schedule((){
        expect(
          tb.rootElement.query('ul').text,
          contains('Go to bed')
        );
      });
    });
And that does the trick. There really is something to this Angular.dart stuff. Not only is it a pleasure to code, but the testing is extremely powerful—and actually tests valuable swaths of functionality. The Angular.dart folks are doing an amazing job with this. I can't believe what's available in a supposedly pre-alpha version of the library. I cannot wait for the future!


Day #922

No comments:

Post a Comment