Test Isolation aka How Wrong am I Doing It?


#1

Hey Team,

I’m having a bunch of trouble with the learning curve on ember-cli testing.

Basically, it seems like Ember (ember-cli?) is super committed to isolated Unit tests - that is, tests with minimal dependencies. Definitely sounds like a recipe for speed and correct tests :smile:

Perhaps it’s because I’m used to Rails, but I’m getting the feeling that the tests are over-isolated - that is, there could/should be some pragmatic additions that wouldn’t harm speed or nicely-split up testing, but would make things Just Work™ a little more. I’m happy to be proved wrong of course!

As a concrete example, pretend you’re testing what I’m guessing is a fairly standard Route:

import Ember from 'ember';

export default Ember.Route.extend({

  model: function() {
    return this.store.find("tutorial");
  },

  actions: {
    createTutorial: function() {
      var tut = this.store.createRecord("tutorial");
      tut.save().then( () => {
        this.transitionTo('whatever-route', tut);
     });
    return tut;
    }

  }

});

So you’ve got an action, and then you want to test it (I’ll omit most of the test boilerplate)

test('I can create a tutorial', function() {
  var route = this.subject();
  this.subject.get('actions').createTutorial();
  // assert something maybe...
}

Except that doesn’t work (My Ember-fu is not strong enough to figure out why actions is undefined, no matter what way I try to access it - if someone could enlighten me I’d be very appreciative!)

The docs suggest separating concerns to define an _createTutorial method, and call that from within actions… which I’m really not sure I buy (how often are they going to be legitimately separate concerns? My example must be pretty common, and I can’t think of another reason for something else in my Route to be creating a Tutorial… when am I going to want to do something from a User action but do it a different way internally?) but anyway, I’ll do that…

 test('I can create a tutorial', function() {
  var route = this.subject();
  this.subject._createTutorial();
  // assertions?
}

Which results in:

TypeError: Cannot read property 'createRecord' of undefined

Oh. For some reason the route doesn’t have a DS.Store injected like my controllers in action do :frowning:

A quick google reveals an awesome presentation that reveals that moduleForModel adds this.store() (slide #23). But the Route test is just using moduleFor, presumably in the interests of isolation. There are also a lot of people using the dependency injection (i.e looking up store:main on App.__container__ or similar, which seems hacky to me…?)

I understand the argument, but just having an in-memory (Fixture?) store feels like it would smooth out the learning curve there a bit - given that (again, as I understand it) the role of a Route is to hook user actions into your data store.

I apologise profusely if I happen to have just messed something up, please correct me :blush:

tl;dr

  1. How do I get access to a DS.Store instance in a Unit test for a Route?
  2. Would there be an interest in having a moduleForRoute or some similar kind of “base” set of assumptions per test type that makes a simple test a bit more frictionless?

Thanks,

Nik


#2

For this kind of test I reckon the important thing is to assert that your route is sending the expected messages.

Sandi Metz says it best in Magic Tricks of Testing,

With this in mind, we want to inject mock collaborators with which we can assert that our route is sending the right messages:

test('foo', function() {
  // set up our collaborators

  var route = this.subject();

  var didCreate;
  var didSave;
  var didTransition;

  var mockRecord = {
    save: function() {
      didSave = true; // record that we called model.save
      return Promise.resolve();
    }
  };

  var mockStore = {
    createRecord: function(type) {
      didCreate = true; // record that we called store.createRecord
      equal(type, 'tutorial',
        'expected createRecord to be called with "tutorial"');
      return mockRecord;
    }
  };

  // inject our collaborators

  route.store = mockStore;

  // spy on one of our own methods

  route.transitionTo = function mockTransitionTo(route, model) {
    didTransition = true; // record that we called transitionTo
    equal(route, 'foo',
      'expected transitionTo to be called with "foo"');
    equal(model, mockRecord,
      'expected transitionTo to be called with the right model');
  }

  // call the method under test

  route.send('createTutorial');

  // verify the right things happened

  ok(didCreate, 'expected to call store.createRecord');
  ok(didSave, 'expected to call model.save');
  ok(didTransition, 'expected to transition');
});

This test is a little overwrought but demonstrates the various ways to assert behaviour.

One thing that struck me as unintuitive is that we invoke the action by calling send('createTutorial'). I guess this could do with clearer explanation but the details can be found in the docs for ActionHandler.

You have to be careful with this kind of test not to simply reproduce the internal implementation but hopefully this demonstrates some of the tools available.


#3

OK - thanks very much for that :slight_smile: The send() tip is definitely a big help.

Your example actually illustrates my argument quite well I think. I understand that the idea is complete isolation, but I think it’s gone a bit far when you have to mock out a whole bunch of common collaborators.

For instance:

  • There are already currentRoute() helpers (and variants thereof). In tests, couldn’t we just automatically mock out transitionTo to set the correct class state for us to test?
  • Similarly, given that it’s going to be very common that you’re testing some kind of storage, why not an automatic/fixture store?

In short, is it worth pulling back a little into the “integrated” direction and in the process ditching a bit of the test “bookkeeping” - It Just Works™ (for most of the people most of the time)

Maybe I should try putting it together as an add-on :wink: