Effective UI Testing in JS

(tools & patterns)

(short version)

By Enrique Amodeo / @eamodeorubio

Enrique Amodeo

(who is this guy?)

[Enrique Amodeo, circa 2015]
  • Programming since 1984
  • Currently Software Engineer at SoundCloud
  • Has loved JS since 2005
  • Test infected
  • Enthusiast of the Agile/Lean way
  • Follow me at @eamodeorubio
[Learning Behavior-driven Development with JavaScript]

Tests that matter

Original By "Touring Club Suisse/Schweiz/Svizzero TCS": http://www.flickr.com/photos/touring_club/6073241403/

TDD is UNDEAD !!

Original by Bjoern Maletz: https://www.flickr.com/photos/bjoernmaletz/8585617590

TDD, you know, don't you?

Yes I know, test first, red/green/refactor, but...

Should I test the behavior of each class?

or

Should I test the whole system together?

None of them! Just BDD!

BDD

(a.k.a. Just TDD done right)

  • Slice into problem domains
  • Each domain has its own stakeholders
  • Each domain has its own language
  • Test the behaviors of each domain

Testing behaviors

  • 1 behavior == 1 cluster of objects
  • Test that cluster together
  • Isolate tests using test doubles
  • Stub other domains & external systems
  • Do not forget to refactor

The art of testing the UI

UI responsabilities?

  • Not the business rules
  • Not the HTTP connection details
  • Is just rendering, right?

Modern UI is more complex

  • Presentation domain, or how to deal with the DOM
  • UX domain, or how to interact with the user

Two domains, two subsystems

Testing the UX logic

Testing the UX logic

Advantages

  • Plain old test
  • Fast
  • Test first
  • Most of the UI code is here!

Testing the UX logic

Tools

  • NPM
  • Mocha or Jasmine or CucumberJS
  • Chai
  • Sinon

UX logic's responsabilities

formatting


  Feature: Display order price

  Background:
    Given that the order total price is "1000" "USD"

  Scenario: USA user
    Given that the user is from "USA"
    When the user displays the order
    Then "$1,000.00" will be shown as total price
  

UX logic's responsabilities

validating & formatting


  Feature: Validate add product form

  Scenario: success
    When the user fills " 1000   " in the quantity field
    Then the quantity field will not be highlighted as erroneous
    And the quantity field will show "1,000"

  Scenario: invalid quantity
    When the user fills "one hundred" in the quantity field
    Then the quantity field will be highlighted as erroneous
    And the quantity field will show "one hundred"
    And an "invalid quantity (one hundred)" message will appear
  

UX logic's responsabilities

server request & view model update


  Feature: User adds a product

  Scenario:
    When the user adds "2 cappucino" to the order
    Then an add product "2 cappucino" request will be sent to the server
    And the add product form will be disabled
    And a progress bar will be shown
  

Should I test the view?

No, don't do it!!

  • Most of the code alredy tested
  • Browser interaction -> Slow
  • DOM coupling -> Brittle
  • Technologically complex
  • Hard to test first

Should I test the view?

Yes of course!!

  • If it is really a source of bugs
  • If it is has complex visualization
  • If it is has complex user gestures
  • Crossbrowser problems

WebDriver API

Original by jeffedoe: https://www.flickr.com/photos/jeffedoe/506027963

What is it?

  • Standard: http://www.w3.org/TR/webdriver/
  • Platform and language-neutral wire protocol
  • Remote control of the browser
  • Native DOM events
  • DOM inspection
  • Remote scripting

For JavaScript too?

View's responsabilities

rendering


Feature: Render order

Scenario:
  When the following order is rendered:
    | contents                 | total price | enabled forms             |
    | 2 Cappucinos, 1 Expresso | Ten dollars | addProduct, completeOrder |
  Then the total price field will contain "Ten dollars"
  And the following items will be shown:
    | product    | quantity |
    | Cappucinos | 2        |
    | Expresso   | 1        |
  And the "addProduct, completeOrder" forms will be enabled
  And the "removeProduct, cancelOrder" forms will be disabled
  

Testing rendering

Testing rendering

set up


  before(function () {
    this.driver.get('http://localhost:8888/testpage.html');

    return this.driver.executeAsyncScript(function () {
      var done = arguments[0];
      window.view = require('order-view')('.container', done);
    });
  });
  

Testing rendering

clean up


    afterEach(function() {
      return this.driver.executeScript(function () {
        document.querySelector('.container').innerHTML = '';
      });
    });
  

Testing rendering

act


  describe('when an order is rendered', function () {
    var orderViewModel = { price: 'Ten dollars' /*, more stuff */  };

    beforeEach(function () {
      return this.driver.executeAsyncScript(function () {
        var viewModel = arguments[0],
            done = arguments[1];

        view.render(viewModel, done);
      }, orderViewModel);
    });
    //  we will see in a moment
  });

Testing rendering

assert


  describe('when an order is rendered', function () {
    //  the set up we just saw
    it('the total price field will contain "Ten dollars"', function () {
      var priceElement = this.driver.findElement({
        css: '.container .order .price'
      });

      return expect(priceElement.getText())
          .to.eventually.be.equal(viewModel.price);
    });
    // the rest of the tests
  });

View's responsabilities

user gestures


    Feature: Request to add a product

    Background:
      Given that the "product" input has been filled with "Cappucino"
      And that the "quantity" input has been filled with "error"

    Scenario: using enter
      Given that the "product" input has focus
      When the user press "ENTER"
      Then the user request to add "error Cappucino"

    Scenario: using button
      When the user clicks the "submit" button
      Then the user request to add "error Cappucino"
  

Testing user gestures

Testing user gestures

set up the spy


  before(function () {
    return this.driver.executeAsyncScript(function () {
      var newOrderView = require('order-view'),
          someTestOrderViewModel = arguments[0],
          cb = arguments[1];

      window.uxLogic = {
        addBeverage: sinon.spy()
      };

      newOrderView('.container', window.uxLogic)
          .render(someTestOrderViewModel, cb);
    }, someTestOrderViewModel);
  });
  

Testing user gestures

fill the form


  beforeEach(function () {
    this.driver.findElement({
      css: '.container .order .add-product input[name="beverage"]'
    }).sendKeys('Cappuccino');

    return this.driver.findElement({
      css: '.container .order .add-product input[name="quantity"]'
    }).sendKeys('2');
  });
  

Testing user gestures

clean up


  afterEach(function () {
    driver.findElement({
      css: '.container .order .add-product input[name="beverage"]'
    }).clear();

    driver.findElement({
      css: '.container .order .add-product input[name="quantity"]'
    }).clear();

    return driver.executeScript(function () {
      uxLogic.addBeverage.reset();
    });
  });
  

Testing user gestures

act (press ENTER)


  this.driver.findElement({
    css: '.container .order .add-product input[name="quantity"]'
  }).sendKeys(Key.ENTER);
  

Testing user gestures

act (press the button)


  this.driver.findElement({
    css: '.container .order .add-product button[name="addToOrder"]'
  }).click();
  

Testing user gestures

assert


  return this.driver.executeScript(function () {
    var expectedParameters = arguments[0];

    expect(uxLogic.addBeverage)
        .to.have.been.calledWith(expectedParameters);
  }, expectedParameters);
  

Advantages & disadvantages

  • Different bugs in different browsers
  • Not many alternatives
  • Grid can speed up tests!

The Page Object Pattern

Original by hauskapellmeister: https://www.flickr.com/photos/hauskapellmeister/3216153514

Better tests

  • Cleaner: encapsulate WebDriver details
  • Less brittle: Encapsulate DOM details

Encapsulating DOM access

simple


  function newOrderPageObject(driver, containerSel) {
    return {
      totalPrice: function () {
        return driver.findElement({
          css: containerSel + ' .order .price'
        }).getText();
      }
    };
  };
  

Encapsulating DOM access

complex

  function newProductLinePageObject(webElement) {
    return {
      info: function () {
        return Promise.all([
          webElement.findElement({ css: '.name' }).getText(),
          webElement.findElement({ css: '.quantity' }).getText()
        ]).then(function (fields) {
          return {
            name: fields[0],
            quantity: fields[1]
          };
        });
      }
    };
  };

Encapsulating DOM events

  function newFormPageObject(webElement) {
    return {
      typeText: function (fieldName, text) {
        return webElement.find({css: '[name="' + fieldName + ']"'})
            .sendKeys(text);
      },
      pressKey: function (fieldName, keyName) {
        return webElement.find({css: '[name="' + fieldName + '"]'})
            .sendKeys(webdriver.Keys[keyName]);
      },
      clickSubmit: function () {
        return webElement.find({css: '[type="submit"]'}).click();
      }
    };
  };

Reusing

  function newOrderPageObject(driver, containerSel) {
    return {
      totalPrice: function () { /* ...skipped for brevity */ },
      addProductForm: function () {
        return newFormPageObject(driver.findElement({
          css: containerSel + ' .order .add-product'
        }));
      },
      productLine: function (i) {
        return newProductLinePageObject(driver.findElement({
          css: containerSel + '.product-line:nth-of-type(' + (i + 1) + ')'
        }));
      }
    };
  };

Best practices

  • One object per UI block not per page!
  • Reuse through composition
  • API in terms of UX not DOM
  • Favor less specific CSS selectors
  • No asserts
  • No navigation

The End

Some questions?

Original by Ethan Lofton: http://bit.ly/1arHGj