Control an AngularJS Application From E2E Tests

November 15, 2017

By Gleb Bahmutov

Cypress runs close to your application. Only a thin iframe separates the production code from the testing code. Thus Cypress can take shortcuts and control your application code directly, bypassing always going through the DOM. For example imagine you are developing an AngularJS 1.x TodoMVC application such as http://todomvc.com/examples/angularjs/; how could you take advantage of this?

AngularJS from DevTools

You can change the data in the application’s model directly from the DevTools console. I have shown this in my personal blog. All you need is to get to the element’s scope, modify attached data and then kick off the digest cycle.

First, select the <form> element in the “Elements” panel; it is now accessible via $0 reference. Then set properties on its AngularJS scope object.

> angular.element($0).scope().newTodo = 'control it from the console'
> angular.element($0).scope().addTodo()

After these two lines, the document will update and add a new “todo” item in the rendered list. We triggered the digest cycle, which updated the HTML and rendered the new item.

AngularJS instance

But in order for your E2E test code to access the element’s scope, it first needs to get a reference to the AngularJS instance executing inside the Test Runner. This is easy to do - the “window” reference inside the Test Runner is yielded by the cy.window() command. The AngularJS library is just a property of the window object. Thus the following test dumps the angular object into the console.

it('can get angular library', () => {
  cy.window()
    .then(win => {
      console.log('got app window object', win)
      return win
    })
    .its('angular')
    .then(ng => {
      console.log('got angular object', ng.version)
    })
})

Element scope

We do in fact get a proper reference to the AngularJS v1.4.3 object in our test. Let us now get a scope associated with a particular element. I really like reusing small functions, thus I will write two helper functions. One will return an angular reference, another one will select an element using the cy.get() command, and then will call angular.element(...).scope() to yield scope object to the test.

const getAngular = () =>
  cy.window()
    .its('angular')

const getElementScope = (selector) =>
  cy.get(selector)
    .then($el =>
      getAngular()
        .then(ng => ng.element($el).scope())
    )

Now let’s write a test that adds a new todo using the DOM, but asserts the created object on the first list item element. I am using deep equality assertion because each Todo instance has both a string title and a boolean completed property.

it('has todo object in scope', () => {
  cy.focused()
    .type('new todo')
    .type('{enter}')

  getElementScope('#todo-list li:first')
    .its('todo')
    .should('deep.equal', {
      title: 'new todo',
      completed: false
    })
})

We can use direct model manipulation to quickly reset the initial data. For example, the next test sets 3 todo titles using scope of the <form> element, wiping any existing data.

it('set several todos at once', () => {
  // home app handles missing "completed" property
  const todos = [{
    title: 'first todo'
  }, {
    title: 'second todo'
  }, {
    title: 'third todo'
  }]

  getElementScope('#todo-list')
    .then(scope => {
      scope.todos = todos
      scope.$apply()
    })

  // we should have 3 elements in the list
  cy.get('#todo-list li')
    .should('have.length', 3)
})

Note that in order to update the DOM after setting an array on the scope the test needs to call the scope.$apply() method to trigger the digest cycle. Often, if the test is changing the properties of the scope objects without any effect on the DOM, it is because you forgot to call $apply().

Controlling a service

In reality, inserting new items directly into the model is more complex than just setting a “todos” array on the scope object; and this is a big disadvantage of working with the internals of the application - you now have to know how it is implemented.

In this case, the items are also stored in the localStorage to be available when you go to a different route (by clicking on “Completed” for example). We can get an instance of the factory localStorage from the DevTools console, just like we did with the scope. But our test for routing is more complex because it needs to do this.

const getElementInjector = (selector) =>
  cy.get(selector)
    .then($el =>
      getAngular()
        .then(ng => ng.element($el).injector())
    )

it('shows completed items', () => {
  // one item is already completed
  const todos = [{
    title: 'first todo'
  }, {
    title: 'second todo',
    completed: true
  }, {
    title: 'third todo'
  }]

  getElementScope('#todo-list')
    .then(scope => {
      scope.todos = todos
      scope.$apply()
    })

  getElementInjector('#todo-list')
    .then(injector => {
      const store = injector.get('localStorage')
      todos.forEach(t => store.insert(t))
    })

  cy.get('#filters')
    .contains('Completed')
    .click()

  cy.get('#todo-list li')
    .should('have.length', 1)
})

Final thought

You need to weigh carefully if additional power from direct access to production code in your end-to-end tests is worth the tight coupling of the test to the implementation.

You can find code for this blog post in the example recipes repo.