Skip to content

3. Testing

Andrei Antal edited this page Sep 25, 2021 · 1 revision

Before working on the challenges, make sure you checkout the start-testing branch from the git repo:

git checkout start-testing

To run the tests (and keep the test runner going during development), enter the following command in a new terminal window:

ng test

If you run the tests, all test should pass. You can also check the coverage of your tests by running the following command:

ng test --codeCoverage=true

7.1 Isolated Tests

Challenge 10 - Testing Pipes

We will create a pipe to count the words for the comment, removing one more part of logic from the MovieItemComponent.

  • Let's create a WordCountPipe directive using the CLI command:
ng generate pipe movies/pipes/word-count --module movies
  • The pipe should transform the text into word count (you can use the code from the wordCount method and remove it from the MovieItemComponent class)
  • The pipe implements the PipeTransform (imported from @angular/core) interface and need to write the processing code in a transform() method that takes the passed in value as the first parameter.
  • Use the pipe in the MovieItem component template by replacing the call to wordCount:
{{comment.value | wordCount}}
  • You can add a second argument to the transform function called countSuffix that will be appended to the output of the pipe (instead of the hard-coded words) and contain variants for no words, singular and plural. It can also have a default value so you don't have to add it every time you use the pipe.

In the pipe test file src/app/movies/pipes/word-count.pipe.spec.ts

  • We create a new clause to test the pipe functionality:
  • It should correctly count the number of words in a text with a few words
  • Create the clause
it('should correctly count the number of words in a text with a few words', () => {
  ...
})
  • Set up the test: instantiate the pipe class and create a test string
it('...', () => {
  // Arrange
  const pipe = new WordCountPipe();
  const testString = 'This is a comment.';
});
  • Test that the pipe correctly applies the transformation:
it('...', () => {
  // Arrange
  ...
  // Act + Assert
  expect(pipe.transform(testString)).toBe('4 words');
});
  • Implement the next test cases yourself:
  • It should correctly calculate words when multiple spaces are present between words
  • We need to add one last test, with strings that contain newlines:
  • It should correctly handle texts with newlines
it('...', () => {
  // Arrange
  const pipe = new WordCountPipe();
  const testString = 'A text with\nnew   \n\n\nlines';
  // Act + Assert
  expect(pipe.transform(testString)).toBe('5 words');
});
  • We see that the test is failing, so we need to fix the pipe to handle new lines:

In src/app/movies/pipes/word-count.pipe.ts

...
else {
  countValue = value
    .trim()
    .replace(/\n+/g, ' ') // <- add this to handle new lines
    .replace(/  +/g, ' ')
    .split(' ').length;
}
  • Now test should pass.

Diffs

Challenge 11 - Testing services (isolated)

In src/app/movies/services/movie-static.service.spec.ts

  • We first create a mock movies array that we're going to use in the tests:
const moviesMock: Movie[] = [
  {
    id: '1',
    comment: 'comment1',
    genre: 'genre1',
    plot: 'plot1',
    title: 'title1',
    year: 1111
  },
  {
    id: '2',
    comment: 'comment2',
    genre: 'genre2',
    plot: 'plot2',
    title: 'title2',
    year: 2222
  },
  {
    id: '3',
    comment: 'comment3',
    genre: 'genre3',
    plot: 'plot3',
    title: 'title3',
    year: 3333
  }
];
  • And a single movie mock object:
const movieMock: Movie = {
  id: null,
  comment: 'comment4',
  genre: 'genre4',
  plot: 'plot4',
  title: 'title4',
  year: 4444
};
  • We test that the service correctly returns the movie list once it's set:
  • should return the movies list
it('should return the movies list', () => {
  // Arrange
  service = new MovieStaticService();
  service.setMovies(moviesMock);
});
it('should return the movies list', () => {
  // Arrange
  ...
  // Act + Assert
  expect(service.movies).toEqual(moviesMock);
});
  • Write the test for the getMovie method:
  • should return one movie by id
expect(service.getMovie('1')).toEqual(moviesMock[0]);
  • should correctly create a movie
it('should correctly create a movie', () => {
  // Arrange
  service = new MovieStaticService();
  service.setMovies(moviesMock);
});
it('should correctly create a movie', () => {
  // Arrange
  ...
  // Act
  const newMovie = service.createMovie(movieMock);
});
it('should correctly create a movie', () => {
  // Arrange
  ...
  // Act
  ...
  // Assert
  expect(service.movies.length).toBe(moviesMock.length + 1);
  expect(newMovie.title).toBe(movieMock.title);
  expect(newMovie.genre).toBe(movieMock.genre);
  ...
});
  • should correctly update a movie
it('should correctly update a movie', () => {
  // Arrange
  service = new MovieStaticService();
  service.setMovies(moviesMock);
  // Act
  service.updateMovie({
    ...moviesMock[0],
    title: 'new title' // update the title
  });
});
it('should correctly update a movie', () => {
  // Arrange
  ...
  // Act
  ...
  // Assert
  expect(service.movies.length).toBe(moviesMock.length);
  expect(service.getMovie('1').title).toBe('new title'); // title should have changed
  expect(service.getMovie('1').genre).toBe(moviesMock[0].genre); // genre should not have changed
});
  • Write the following tests:
  • should correctly update a comment
  • should correctly delete a movie

Diffs

Challenge 12 - Testing validators

  • In order to test the validators, we need to instantiate the service. Since we're only testing the sync validator, we don't actually need the real MovieService, we can just mock it out with an empty object.
  • Also, we'll ned to programmatically create a FormControl and add the validator in the validators list:
describe('MoviesValidatorsService - static', () => {
  it('should validate genre', () => {
    // Arrange
    const validator = new MoviesValidatorsService({} as MovieService);
    const fc = new FormControl();
    fc.setValidators(validator.genreSync);
  });
});
  • Next we can set up the value of the form control with valid/invalid values and test that the form control has the correct state after the validator runs:
it('should validate genre', () => {
  // Act
  fc.setValue(/* tested value */);
  // Assert
  expect(fc.status).toBe(/* expected validity: 'VALID'/'INVALID');
});
  • Test the following
    • valid strings: 'action, thriller', 'comedy'
    • invalid strings: 'action, thrlr', 'cmdy'

Diffs

7.2. Interaction testing

Challenge 13 - Testing bindings

We'll now write some functional tests for our MovieItemSimpleComponent. in src/app/movies/components/movie-item-simple/movie-item-simple.component.spec.ts

  • At the moment, the component is just displaying the value of the properties declared in the component class. We can start writing tests to check if the value of the properties are placed and displayed correctly. In order to ease our work we can add a data attribute on the element that we can use to target that specific element. Later, we can remove this attribute when we build our application for production.

  • Add the attribute in the template element:

<h5 class="card-title font-weight-bold">
    <span data-testId="title">{{ movie.title }}</span> <!-- wrap title in a <span> -->
    <h6 class="movie-year font-weight-normal">({{ movie.year }})</h6>
</h5>
it('should correctly display the movie title', () => {
  // Arrange + Act
  const titleElement: HTMLHeadingElement = movieItemDe.query(
    By.css('[data-testId=title]')
  ).nativeElement;
  // Assert
  expect(titleElement.textContent).toContain(
    'Star Wars Episode IX: The Rise of Skywalker'
  );
});
  • should correctly display the movie year

  • should correctly display the movie genre

  • should correctly display the movie poster

  • should correctly display the movie plot

  • should correctly change word count color

  • First of all, make sure that the text area and the static comments container have corresponding data-testId attributes.

  • Next we select and save those elements in variables:

it('should correctly change word count color', () => {
  // Arrange
  wordsElement = movieItemDe.query(By.css('[data-testId=words]')).nativeElement;
  commentElement = movieItemDe.query(By.css('[data-testId=comment]'))
    .nativeElement;
  expect(wordsElement.style.color).toBe('darkred');
});
  • Then we add a value to the text area, and trigger an input event:
it('should correctly change word count color', () => {
  // Arrange
  ...
  // Act
  commentElement.value = 'Comment';
  commentElement.dispatchEvent(new Event('input'));
  fixture.detectChanges();
});
  • Then we check that the comment word color is correctly set:
it('should correctly change word count color', () => {
  // Arrange
  ...
  // Act
  ...
  // Assert
  expect(wordsElement.style.color).toContain('darkgreen');
});
  • As an exercise, in the same text, change the text back to empty and check that the color has changed correctly:
it('should correctly change word count color', () => {
  // Arrange
  ...
  // Act
  ...
  // Assert
  ...

  // TODO: Act - make comment text empty again

  // TODO: Assert - check if words color is back to 'darkred'
});

Diffs

Challenge 14 - Testing Events

  • In the same test file of the MovieItemSimpleComponent, we add a sub-describe clause in which we'll write the editable/static comment behaviour
  • Movie comment
  • First, create the describe clause:
describe('Movie comment', () => {
  ...
})
  • Next we correctly set up the component instance to have a comment:
describe('Movie comment', () => {
  beforeEach(() => {
    // Arrange + Act
    component.movie.comment = 'The comment';
    component.commentSaved = true;
    fixture.detectChanges();
  });
});

Next we write the first test:

  • should correctly show a readonly comment
describe('Movie comment', () => {{
  ...
  it('should correctly show a readonly comment', () => {
    ...
  });
)}
  • We already have the setup from beforeEach so we just need to make the element selections that we'll use in the test:
    • The readonly and editable containers and the save comment button:
describe('Movie comment', () => {
  ...
  it('should correctly show a readonly comment', () => {
    // select elements on page
    // TODO: add `data-testId` to elements in the template
    readonlyCommentElement = movieItemEl.querySelector('[data-testId=readonly-comment]');
    editableCommentElement = movieItemEl.querySelector('[data-testId=editable-comment]');
    editCommentBtn = movieItemEl.querySelector('[data-testId=save-comment]');
  });
)}
describe('Movie comment', () => {
  ...
  it('should correctly show a readonly comment', () => {
    // Assert
    // TODO: assertions
    // readonly comment to be defined and to contain the text 'The comment'
    // editable comment to not be displayed
    // button text should be 'Edit comment'
  });
)}
  • Finally we test that once the edit button is clicked the switch between editable and static content happens
  • should correctly transit the comment from readonly to editable
  • We simulate a click on the Edit comment button
describe('Movie comment', () => {
  ...
  it('should correctly transit the comment from readonly to editable', () => {
    // Act
    editCommentBtn = movieItemEl.querySelector('[data-testId=save-comment]');
    editCommentBtn.click();
    fixture.detectChanges();
    fixture.whenStable().then(() => { // we need to wait for the changes in the template to take place
      // Assertions
    })
  });
}
  • We can also use async/await for better code readability:
describe('Movie comment', () => {
  ...
  it('should correctly transit the comment from readonly to editable', async () => {
    // Act
    editCommentBtn.click();
    fixture.detectChanges();
    await fixture.whenStable()

    // Assert

    // select elements
    editableCommentElement = movieItemEl.querySelector(
      '[data-testId=editable-comment]'
    );
    commentElement = movieItemEl.querySelector('[data-testId=comment]');
    readonlyCommentElement = movieItemEl.querySelector(
      '[data-testId=readonly-comment]'
    );
    editCommentBtn = movieItemEl.querySelector('[data-testId=save-comment]');

    // TODO assertions
    // editable comment should be defined
    // the textarea should contain 'The comment'
    // the readonly comment should not be displayed
    // the button should write 'Save comment'
  });
}

Diffs

7.3 Integration testing

Challenge 15 - Testing inputs and outputs

  • First, we'll update the MovieItemComponent specs. In the movie-item.component.spec file update the declarations of the TestBed config and add imports:
describe('MovieItemComponent', () => {
  ...

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [MovieItemComponent, WordCountPipe],
      imports: [RouterTestingModule, FormsModule],
      schemas: [CUSTOM_ELEMENTS_SCHEMA],
    }).compileComponents();
  }));

Because we're now testing a component that has inputs and outputs, we need to create a test host component that passes down data and listens o events of our component.

We start by creating a mock movie that will be used as an input for our component. We'll later use this object to test our assumptions about data displaying correctly in the component.

  • Add the following constant object at the beginning of the test file.
const MockMovie: Movie = {
  id: '1',
  title: 'Star Wars Episode IX: The Rise of Skywalker',
  year: 2019,
  genre: 'Action, Adventure, Fantasy',
  plot:
    'The surviving Resistance faces the First Order once more in the final chapter of the Skywalker saga.',
  poster:
    'https://images-na.ssl-images-amazon.com/images/I/91rKEgY1qDL._SY679_@@._V1_SX300.jpg',
  comment: ''
};
  • Now that we have our mock movie, let's create the host component and wire the inputs and outputs (just before the describe function):
@Component({
  template: `
    <ngm-movie-item
      [movie]="movie"
      (commentUpdate)="update($event)"
    ></ngm-movie-item>
  `
})
class TestMovieItemComponent {
  public movie = MockMovie;
  public update(event) {} 
}

IMPORTANT: The event parameter in the update method is need here because later when we'll use a spy for this method, the .toHaveBeenCalledWith method must have the exact same number of parameters as the method definition. In order to test this, after finishing this challenge, remove the event parameter and see what happens.

  • Next we update the TestBed configs:
describe('MovieItemComponent', () => {
  // update definitions for test variables with the new test component
  let component: TestMovieItemComponent;
  let fixture: ComponentFixture<TestMovieItemComponent>;
  let movieItemElement: HTMLElement;
  ...
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [
        TestMovieItemComponent, // <- add test component to declarations
        MovieItemComponent,
        WordCountPipe
      ],
      ...
    });
    // we create the new test component that contains our target component
    fixture = TestBed.createComponent(TestMovieItemComponent);
    ...
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TestMovieItemComponent); // update the fixture definition 
    component = fixture.componentInstance;
    movieItemElement = fixture.nativeElement;
    fixture.detectChanges();
  });
  ...
});
  • Finally we update the tests that check if inputs are set correctly:
  • should correctly display the movie title
it('should correctly display the movie title', () => {
    const titleElement = movieItemElement.querySelector('[data-testId=title]');
    expect(titleElement.textContent).toContain(MockMovie.title); // <- remove static string and check property of mock movie input
});
  • should correctly dispatch an commentUpdate event when comment is cleared
  • Do this for the other 4 test (checking year, genre, poster and plot)

Next we'll create 'should correctly dispatch an commentUpdate event when comment is cleared'. What this test needs to check is that if we get a movie with a set comment and we click on Clear Comment we should dispatch a commentUpdate event containing the id of the movie and an empty comment.

  • Let's start with the Arrange section where we get a reference to the clear comment button and add a spy for the update method on the test component. We also set the input of the movie item component to contain a comment (we need to make an immutable change -create a new object - so that angular change detection can pick this up):
it('should correctly dispatch an commentUpdate event when comment is cleared', () => {
  // Arrange
  clearCommentBtn = movieItemElement.querySelector(
    '[data-testId=clear-comment]'
  );
  component.movie = {
    ...component.movie,
    comment: 'Comment'
  };
  fixture.detectChanges();
  spyOn(component, 'update');
});
  • Next we click the Clear button:
it('should correctly dispatch an commentUpdate event when comment is cleared', () => {
  // Arrange
  ...
  // Act
  clearCommentBtn.click();
  fixture.detectChanges();
});
  • And in the end we're checking that the output event triggered with the right parameters:
it('should correctly dispatch an commentUpdate event when comment is cleared', () => {
  // Arrange
  ...
  // Act
  ...
  // Assert
  expect(component.update).toHaveBeenCalledWith({
    id: MockMovie.id,
    newComment: '',
  });
});

Now, as an exercise create a test spec titled: 'should correctly dispatch an commentUpdate event when comment is saved'

  • Arrange
    • create variables for the comment textarea and the save button
    • add a spy for the update method on the component
    • add a value to the text area and dispatch an input event
  • Act
    • click the save button
  • Assert
    • check that the update method was called with the proper parameters (movie id and new comment)

Diffs

Challenge 16 - Testing dependencies - mocking a service

In movie-list-static.component.spec.ts file, create the following test:

  • should properly display a list of movies

  • Create a service mock (only mock out needed properties):

const mockMovieService = {
  movies: [{ comment: '' }, { comment: '' }, { comment: '' }],
};
  • check that the component properly displays the 3 movies by counting the number of ngm-movie-item elements:
it('should properly display a list of movies', () => {
  // Arrange
  movieItemsElements = movieListElement.querySelectorAll('ngm-movie-item');
  // Assert
  expect(movieItemsElements.length).toBe(moviesMock.length);
});
  • Alternatively, we can also use debugElement to query for MovieItemComponents that rendered in the template, by using the By.directive predicate:
const moviesList = fixture.debugElement.queryAll(By.directive(MovieItemComponent));

NOTE: now that we're doing deep render of the movie list component, we need to include the MovieItemComponent and WordCountPipe in the TestBed:

TestBed.configureTestingModule({
  declarations: [
    MovieListStaticComponent,
    MovieItemComponent,  // add this
    WordCountPipe,       // and this
  ],
  ...
}).compileComponents();

Diffs

Challenge 17 - Testing dependencies - mocking HTTP calls

Now we just need to fix the existing tests and adapt them to our new HTTP calls. We'll tackle the MovieService first.

  • in the movie.service.spec.ts file, we need to import the HttpClientTestingModule and the HttpTestingController in order to mock the actual HttpClient in our TestBed. Update the test file:
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
...

describe('MovieService', () => {
    let httpTestingController: HttpTestingController;

    beforeEach(() =>
        TestBed.configureTestingModule({
            imports: [ HttpClientTestingModule ], // <- add import
        })
        // get a reference to the mock http service
        httpTestingController = TestBed.inject(HttpTestingController);
    );

    afterEach(() => {
        // make sure there are no outstanding calls
        httpTestingController.verify();
    });
  ...
})

Next we need to update the 'should correctly update a movie comment' spec. The update function makes 2 http calls:

  • a PATCH call to update the movie entity on te server

  • a GET call to update the movie list from the server and emit a new value on the movies$ observable

  • We first update the Act and Assert parts of the test. We are now expecting that after the updateComment call is made (so in the subscribe callback) our movies list is updated:

// Act
service.updateComment(1, 'Comment').subscribe(async () => {
    // Assert
    const movies = await service.movies$.pipe(first()).toPromise();
    expect(movies.find(m => m.id === 1).comment).toBe('Comment');
});
  • We now need to add 2 mocks for the calls inside the it function body:
const reqPatch = httpTestingController.expectOne(`${service.apiUrl}/1`);
expect(reqPatch.request.method).toBe('PATCH');
reqPatch.flush(); // we don't care about this reply
const reqGet = httpTestingController.expectOne(`${service.apiUrl}?q=`);
expect(reqGet.request.method).toBe('GET');
reqGet.flush([{ id: 1, comment: 'Comment' }]);  // the updated object

If we run the tests now, this first spec should pass.

  • Next, fix the 'should correctly delete a movie' spec, in the same way:

    • Mock the DELETE call
    • Mock the GET call and return an array of movies
    • Assert the correct movie list length

Now to add one final test for the movies list components, an integration test that verifies that the correct number of movies renderers for a given response.

  • In the movie-list.component.spec.ts file, add a new spec and create a new mockMovies array that will represent the movie list we will receive form the server. Also, we'll need to create a moviesList array of elements that will count how many ngm-movie-item components are rendered in the template.
it('should correctly render the movies list', () => {
    // Arrange
    const mockMovies = [{ comment: '' }, { comment: '' }, { comment: '' }];
    let moviesList: HTMLElement[];
});
  • Since we're only getting the film, we need to mock the GET call:
it('should correctly render the movies list', () => {
    // Arrange
    ...

    // Act
    const reqGet = httpTestingController.expectOne(`${service.apiUrl}?q=`);
    expect(reqGet.request.method).toBe('GET');
    reqGet.flush(mockMovies);
    fixture.detectChanges();
});
  • Next, we need to query the DOM of the component to see how many ngm-movie-item we have in our template:
moviesList = fixture.nativeElement.querySelectorAll('ngm-movie-item');
  • And finlay we check that the number of ngm-movie-item elements matches the number of movie items we had in our mockMovies array.
it('should correctly render the movies list', () => {
  // Arrange
  ...

  // Act
  ...

  // Assert
  expect(moviesList.length).toBe(mockMovies.length);

  httpTestingController.verify();
});

Diffs

DOCUMENTATION

Testing HTTP Requests

-Optional- Configure Jest in an Angular project

  1. Install required dependencies:
npm install jest jest-preset-angular @types/jest --save-dev
  • jest
  • @types/jest
  • jest-preset-angular
  1. Configure Jest
  • Create jest.config.js in the root directory of the project
const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tsconfig');

module.exports = {
  preset: 'jest-preset-angular',
  roots: ['<rootDir>/src/'],
  testMatch: ['**/+(*.)+(spec).+(ts)'],
  setupFilesAfterEnv: ['<rootDir>/src/test.ts'],
  collectCoverage: true,
  coverageReporters: ['html'],
  coverageDirectory: 'coverage/my-app',
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, {
    prefix: '<rootDir>/'
  })
};
  1. Update the src/test.ts file:
import 'jest-preset-angular/setup-jest';

Object.defineProperty(window, 'CSS', { value: null });
Object.defineProperty(window, 'getComputedStyle', {
  value: () => {
    return {
      display: 'none',
      appearance: ['-webkit-appearance']
    };
  }
});
  
Object.defineProperty(document, 'doctype', {
  value: '<!DOCTYPE html>'
});
Object.defineProperty(document.body.style, 'transform', {
  value: () => {
    return {
      enumerable: true,
      configurable: true
    };
  }
});
  1. Update the content of the tsconfig.spec.json file:
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jest", "node"],
    "esModuleInterop": true,
    "emitDecoratorMetadata": true
  },
  "files": ["src/test.ts", "src/polyfills.ts"],
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
  1. Run the tests:
  • Update package.json test script:
"scripts": {
  ...
  "test": "jest", // <- update this script
  ...
},
  • run from comand line:
npm test
  1. Remove Karma libs and configuration:
  • Remove Karma npm dependencies:
npm uninstall karma karma-chrome-launcher karma-coverage-istanbul-reporter karma-jasmine karma-jasmine-html-reporter

Eventually we can remove Jasmine dependencies as well, but they are needed if your project uses Protractor for e2e tests:

npm uninstall jasmine-core jasmine-spec-reporter @types/jasmine @types/jasminewd2

We'll come back to this step in the e2e section.

  • Remove Karma config file:
rm karma.conf.js
  • Remove test target from angular.json:
// remove this section
"test": {
  "builder": "@angular-devkit/build-angular:karma",
  "options": {
    "main": "src/test.ts",
    "polyfills": "src/polyfills.ts",
    "tsConfig": "tsconfig.spec.json",
    "karmaConfig": "karma.conf.js",
    "assets": [
      "src/favicon.ico",
      "src/assets"
    ],
    "styles": [
      "src/styles.css"
    ],
    "scripts": []
  }
}

Diffs