Lesson 12

Injected Dependencies & Spying on Function Calls

WARNING: This module is deprecated and no longer receives updates. Protractor is likely being removed as the default from Angular applications and Protractor itself will likely stop receiving updates and development in the future. I would recommend checking out the Test Driven Development with Cypress/Jest as a replacement.

Using spies to watch objects in a test

DEPRECATED

Lesson Outline

Testing Injected Dependencies & Spying on Function Calls

In the last lesson we successfully implemented the first requirement for our application, now we are going to move onto the second requirement that we identified:

  • After selecting a specific module, the user should be able to see a list of available lessons

Assuming that in order to view a list of lessons for a specific module we would send the user to another page, this requirement is clearly going to rely on testing navigation. In the context of an E2E test this is not really an issue, an E2E test tests our application as a whole which includes navigating from page to page.

When testing navigation in the context of a unit test there are a few issues that pop up. We have been very insistent on the fact that a unit test should be isolated. In our unit tests, we use Angular's TestBed to create a completely isolated testing environment where that one component is the only thing that exists, and we would even mock the NavController using a fake class that does absolutely nothing.

On the surface, this makes it seem like it's pretty impossible to test navigation. But it's not an issue, we don't need to actually navigate to the page in the unit test, we just need to check that the appropriate call is made. This is where Jasmine's spies become extremely useful.

Spying on Function Calls

Let's say we are creating a unit test on the Home page that needs to test if the user can navigate to the LessonList page. If that functionality is working properly then at some point the code would make a call like this:

this.navCtrl.navigateForward('/module/1');

When we are testing this code, we will be using a mocked version of NavController so it won't do anything. We don't want anything to happen when the call is made, we want the unit test to be isolated, but we do want to check whether or not the call was made. Our test would look something like this:

it('the openModule() function should navigate to the LessonListPage', () => {
  component.openModule(1);

  /* expect that navigateForward was called on the nav controller */
});

We need to test that the navigateForward method of navCtrl was called at some point throughout the test. In order to do this, we can use the spyOn method that Jasmine provides. The spy will tell us whether or not the function was called at any point during the test, what information it was called with, how many times the function was called, and so on.

Once we have an object we are spying on we will be able to use the toHaveBeenCalled() and toHaveBeenCalledWith(params) matchers, so our test would look something like this:

it('the openModule() function should push the LessonListPage on to the navigation stack', () => {

	const navCtrl = /* reference to the injected NavController */;

	spyOn(navCtrl, 'navigateForward');

	comp.openModule(1);

	expect(navCtrl.navigateForward).toHaveBeenCalled();

});

We spy on the method we are interested in on the navCtrl object with spyOn, and then after we act in the test by triggering openModule we check that navCtrl.navigateForward (our spy that was set up) was called with /module/1. We're most of the way there, but we're missing one key ingredient: we need a way to get a reference to the injected NavController.

In the TestBed set up for this test, we would have to set up NavController as a provider that uses the class NavControllerMock (which we will also need to create). Before we can set up a spy on that, we need a way to grab it inside of our test. There are actually a few ways to do this, but the best way is to do this:

it('the openModule() function should navigate to the LessonListPage', () => {
  const navCtrl = fixture.debugElement.injector.get(NavController);

  spyOn(navCtrl, 'navigateForward');

  comp.openModule(1);

  expect(navCtrl.navigateForward).toHaveBeenCalledWith('/module/1');
});

You may remember that we set up fixture as part of the setup process for TestBed, and fixture is a reference to the testing environment. We are able to access the injector object through fixture which will allow us to get any injected provider that we are interested in.

At this point, we have a reference to the mock that is being used in place of the real NavController for the test. We set up a spy on that fake NavController and keep an eye on the navigateForward method. We trigger the appropriate method in the test, and then we check our navCtrl.navigateForward spy to see if it was called at some point during the test.

This same concept is not just used for navigation, you can do the same thing in any circumstance where the component is supposed to interact with some kind external service. For example, if we were testing that the save function on a particular page worked, all you would have to do is make sure that the page successfully sent the data to whatever provider is responsible for storing that data:

it('the saveContact() function should send the data to the ContactProvider', () => {
  const contactProvider = fixture.debugElement.injector.get(ContactProvider);

  spyOn(contactProvider, 'save');

  comp.saveContact('Gary');

  expect(contactProvider.save).toHaveBeenCalledwith('Gary');
});

This doesn't test that the data is actually saved, but that isn't the responsibility of this page. All this page has to do is pass the data on, the unit test for the data provider will be responsible for ensuring that the data is saved appropriately.

Testing Lessons

With that bit of necessary theory out of the way, let's continue on our Test Driven Development journey. We've got a lot of tests to create in this module, so we're going to pick the pace up a little bit this time.

Let's take another look at the requirement that we are trying to implement:

  • After selecting a specific module, the user should be able to see a list of available lessons

We will start implementing this functionality in the same way we implemented the functionality in the last lesson: with an E2E test. This test is going to involve us interacting with two separate pages, we will need to:

  • Click on a module on the home page
  • Check that a list of lessons is available on the LessonSelectPage

In order to click on a module on the home page we will need to grab a reference to it, and in order to check that lessons are available on the Lesson Select page we will need to grab a reference to that list. Grabbing the module on the home page is fine because we already have a function in our page object to do that, but we don't have a function to grab the lesson list from the (as of yet, non-existent) LessonSelectPage. Since we will be dealing with a different page, we are going to create a new page object.

Create a file at e2e/src/lesson-select.po.ts and add the following:

import { browser, by, element, ElementFinder } from 'protractor';

export class LessonSelectPageObject {
  navigateTo() {
    return browser.get('/');
  }

  getLessonListItems() {
    return element.all(by.css('.lesson-list ion-item'));
  }
}

This page object is almost identical to our Home page object, except that we are grabbing a lesson list using .lesson-list instead of grabbing the module list. We also have our navigateTo function pointing to the root URL which isn't what we will want later, but we don't know the URL of the lesson select page yet so we will just leave it for now.

We will now import this page object into our Home page's E2E test to help with creating the test.

Modify e2e/src/home.e2e-spec.ts to reflect the following:

import { HomePageObject } from './home.po';
import { LessonSelectPageObject } from './lesson-select.po';

describe('Home', () => {
  let homePage: HomePageObject;
  let lessonSelectPage: LessonSelectPageObject;

  beforeEach(async () => {
    homePage = new HomePageObject();
    lessonSelectPage = new LessonSelectPageObject();
    await homePage.navigateTo();
  });

  it('should be able to view a list of modules', async () => {
    const numberOfModulesInList = await homePage.getModuleListItems().count();
    expect(numberOfModulesInList).toBe(5);
  });

  it('the list of modules should contain the titles of the modules', async () => {
    expect(await homePage.getModuleListItems().first().getText()).toContain(
      'Module One'
    );
  });

  it('after selecting a specific module, the user should be able to see a list of available lessons', () => {
    const moduleToTest = homePage.getModuleListItems().first();

    moduleToTest.click();

    expect(lessonSelectPage.getLessonListItems().count()).toBeGreaterThan(0);
  });
});

We've imported and set up our page object as lessonSelectPage. In our new test, we first get the first module in the list on the home page and then click it using the click method. Then we grab the lesson list items using our new page object, and we check that the number of items in that list is greater than 0.

Run the following command to run the E2E tests:

npm run e2e

I've kept a mistake in the test code above to highlight the importance of actually running your tests and watching them fail first. You might be surprised to see that all of the tests pass:

  Home
    ✓ should be able to view a list of modules
    ✓ the list of modules should contain the titles of the modules
    ✓ after selecting a specific module, the user should be able to see a list of available lessons

Executed 3 of 3 specs SUCCESS in 2 secs.

Which makes no sense, because how could this:

expect(lessonSelectPage.getLessonListItems().count()).toBeGreaterThan(0);

...pass when the lesson list items don't even exist yet? Why would the count() be greater than 0? The reason this happens is because lessonSelectPage.getLessonListItems().count() returns a Promise but we are not waiting for that promise to resolve so the toBeGreaterThan(0) matcher is running on the Promise object itself (not the result).

PRO

Thanks for checking out the preview of this lesson!

You do not have the appropriate membership to view the full lesson. If you would like full access to this module you can view membership options (or log in if you are already have an appropriate membership).