-
Notifications
You must be signed in to change notification settings - Fork 0
3. Testing
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
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
WordCountPipedirective 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
wordCountmethod and remove it from theMovieItemComponentclass) - The pipe implements the
PipeTransform(imported from@angular/core) interface and need to write the processing code in atransform()method that takes the passed invalueas the first parameter. - Use the pipe in the
MovieItemcomponent template by replacing the call towordCount:
{{comment.value | wordCount}}- You can add a second argument to the
transformfunction calledcountSuffixthat will be appended to the output of the pipe (instead of the hard-codedwords) 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.
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
getMoviemethod:
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 commentshould correctly delete a movie
- 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
FormControland 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'
- valid strings:
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
dataattribute 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-testIdattributes. -
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
inputevent:
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'
});- 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
describeclause:
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
beforeEachso 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 commentbutton
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/awaitfor 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'
});
}- First, we'll update the
MovieItemComponentspecs. In themovie-item.component.specfile update thedeclarationsof theTestBedconfig and addimports:
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
describefunction):
@Component({
template: `
<ngm-movie-item
[movie]="movie"
(commentUpdate)="update($event)"
></ngm-movie-item>
`
})
class TestMovieItemComponent {
public movie = MockMovie;
public update(event) {}
}IMPORTANT: The
eventparameter in theupdatemethod is need here because later when we'll use aspyfor this method, the.toHaveBeenCalledWithmethod must have the exact same number of parameters as the method definition. In order to test this, after finishing this challenge, remove theeventparameter and see what happens.
- Next we update the
TestBedconfigs:
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,posterandplot)
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
Arrangesection where we get a reference to the clear comment button and add aspyfor theupdatemethod 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
updatemethod on the component - add a value to the text area and dispatch an
inputevent
- Act
- click the save button
- Assert
- check that the
updatemethod was called with the proper parameters (movie id and new comment)
- check that the
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-itemelements:
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
debugElementto query forMovieItemComponentsthat rendered in the template, by using theBy.directivepredicate:
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
MovieItemComponentandWordCountPipein theTestBed:
TestBed.configureTestingModule({
declarations: [
MovieListStaticComponent,
MovieItemComponent, // add this
WordCountPipe, // and this
],
...
}).compileComponents();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.tsfile, we need to import theHttpClientTestingModuleand theHttpTestingControllerin order to mock the actualHttpClientin 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
PATCHcall to update the movie entity on te server -
a
GETcall to update the movie list from the server and emit a new value on themovies$observable -
We first update the
ActandAssertparts of the test. We are now expecting that after theupdateCommentcall is made (so in thesubscribecallback) 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
itfunction body:
const reqPatch = httpTestingController.expectOne(`${service.apiUrl}/1`);
expect(reqPatch.request.method).toBe('PATCH');
reqPatch.flush(); // we don't care about this replyconst reqGet = httpTestingController.expectOne(`${service.apiUrl}?q=`);
expect(reqGet.request.method).toBe('GET');
reqGet.flush([{ id: 1, comment: 'Comment' }]); // the updated objectIf 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
DELETEcall - Mock the
GETcall and return an array of movies - Assert the correct movie list length
- Mock the
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.tsfile, add a new spec and create a newmockMoviesarray that will represent the movie list we will receive form the server. Also, we'll need to create amoviesListarray of elements that will count how manyngm-movie-itemcomponents 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
GETcall:
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-itemwe have in our template:
moviesList = fixture.nativeElement.querySelectorAll('ngm-movie-item');- And finlay we check that the number of
ngm-movie-itemelements matches the number of movie items we had in ourmockMoviesarray.
it('should correctly render the movies list', () => {
// Arrange
...
// Act
...
// Assert
expect(moviesList.length).toBe(mockMovies.length);
httpTestingController.verify();
});- Install required dependencies:
npm install jest jest-preset-angular @types/jest --save-dev
jest@types/jestjest-preset-angular
- Configure Jest
- Create
jest.config.jsin 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>/'
})
};- Update the
src/test.tsfile:
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
};
}
});- Update the content of the
tsconfig.spec.jsonfile:
{
"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"]
}- Run the tests:
- Update
package.jsontestscript:
"scripts": {
...
"test": "jest", // <- update this script
...
},- run from comand line:
npm test
- 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
testtarget fromangular.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": []
}
}