Cleaning up DOM selectors in Jasmine specs

Published by Lorenzo Planas on March 5th, 2014

Riot.js is a minimalistic MVP framework for building Javascript apps with the absolute minimum structure you need to get going.

It's just a bit over 1 KB, not considering the hard-dependency on jQuery, which is going to be removed in the forthcoming 1.0 release.

My main motivation to use Riot is to learn how to build small, reusable components, which could potentially be plugged into different applications. A side-effect of using minimalistic tools is you get to learn a lot, since there many supporting helpers you miss from heavier frameworks. Any functionality you need, you build it yourself :)

My work today got me thinking on how to avoid DOM selector dependencies in specs, which is a good thing no matter the framework you use. The problem with using DOM selectors in specs is if the DOM is refactored, the specs will break, even if the actual behavior under test hasn't changed.

Consider a login form: the basic behavior involves retrieving e-mail and password values from the form when it's submitted, rendering an error message if credentials are incorrect, or loading the next view upon a successful login.

In the login function and its associated spec below, you can find the usual jQuery selectors to implement and test that flow.

// Handle login form submit
this.logIn = function(e) {
  e.preventDefault();
  $(".error", this.$el).remove();

  options.user.login({
    email: $("input[name='email']", this.$el).val();
    password: $("input[name='password']", this.$el).val();
  })
  .fail(this.loginError.bind(this))
  .done(this.loginSuccess.bind(this));

  return this;
};
it("renders an error message if credentials incorrect", function() { 
  loginPresenter.render();
  loginPresenter.listen();

  expect($(".error", loginPresenter.$el).length).toBe(0);

  $("input[name='email']", loginPresenter.$el).val("wrong");
  $("input[name='email']", loginPresenter.$el).val("wrong");
  $("form", loginPresenter.$el).trigger("submit");

  expect($(".error", loginPresenter.$el).length).toBe(0);
});

Check the full code for the presenter and its spec.

Repeating hardcoded selectors bothers me, specially when the spec knows a bit too much of the presenter's dependencies on the DOM. I tried to refactor both the presenter and the spec, trying to extract the DOM hooks to properties. Since jQuery eagerly resolves the selectors, I wrapped them in functions with a name convention. This way I can quickly see they return jQuery elements for the revelant DOM elements in this presenter.

// DOM Hooks
this.$form = function() {
  return $("form", this.$el);
};

this.$inputEmail = function() {
  return $("input[name='email']", this.$el);
};

this.$inputPassword = function() {
  return $("input[name='password']", this.$el);
};

this.$errorMessages = function() {
  return $(".error", this.$el);
};

// Handle login form submit
this.logIn = function(e) {
  e.preventDefault();
  this.$errorMessages().remove();

  options.user.login({
    email: this.$inputEmail().val();
    password: this.$inputPassword().val();
  })
  .fail(this.loginError.bind(this))
  .done(this.loginSuccess.bind(this));

  return this;
};
it("renders an error message if credentials incorrect", function() { 
  loginPresenter.render();
  loginPresenter.bindDomEvents();

  expect(loginPresenter.$errorMessages().length).toBe(0);

  loginPresenter.$inputEmail().val("wrong");
  loginPresenter.$inputPassword().val("wrong");
  loginPresenter.$form().trigger("submit");

  expect(loginPresenter.$errorMessages().length).toBe(1);
});

Check the full code for the refactored presenter and its refactored spec.

With this change the spec now doesn't have any hardcoded selectors, the assertions are a bit more semantic, and the spec a bit less brittle.

All those DOM-related functions are still cumbersome, so I took one more step. I implemented a private function that wraps the selector search within the presenter's element. With this change, the code looks a bit cleaner:

// DOM hooks
this.$form = finder("form");
this.$inputEmail = finder("input[name='email']");
this.$inputPassword = finder("input[name='password']");
this.$errorMessages = finder(".error");

// Handle login form submit
this.logIn = function(e) {
  e.preventDefault();
  this.$errorMessages.remove();

  options.user.login({
    email: this.$inputEmail().val(),
    password: this.$inputPassword().val()
  })
  .fail(this.loginError.bind(this))
  .done(this.loginSuccess.bind(this));

  return this;
};
// Search for a selector within $el
function finder(selector) {
  return (function() {
    return $(selector, this.$el);
  })
}

Check the full code for the presenter with DOM hooks.

Even after this last step, I'm not 100% happy with accessing the DOM hooks through a function call. If you know of a better way to do this, please let me know on Twitter.

And if you like my musings, follow me for future updates!

You may find these other posts interesting: