
В прошлой статье, я описывал как тестировать компонент. Теперь же коснемся вопроса тестирования сервиса.
Вступление
В данном примере будем использовать так же, инструменты идущие из "коробки", такие как Karma и Jasmine.
Вкратце повторю что это за инструменты: Karma это так называемый тест раннер, который позволяет нам настраивать то на каких устройствах или браузерах будут запускаться тесты и какие тестовые фреймворки и плагины, будут участвовать в тестах. Jasmine это BDD фреймворк для тестирования javascript кода.
Начальное приложение
В это раз у нас также есть некое стартовое приложение с созданным компонентом Comments и сервисом с одноименным названием. В корне нашего приложения, все также есть предсозданный файл karma.conf.js (создается автоматически при генерации приложения через Angular CLI). В нем находится конфиг нашего тест раннера. Здесь задаются настройки того, в каком браузере будет запускается окно тест раннера (по умолчанию стоит Chrome), какие плагины подключать, и на каком порту запускать тест раннер (по умолчанию порт 9876). В src/app лежит наш компонент Comments, который выглядит следующим образом:
import { Component, OnInit } from '@angular/core';
import { CommentsService } from '../shared/comments.service';
@Component({
selector: 'app-comments',
templateUrl: './comments.component.html',
styleUrls: ['./comments.component.scss']
})
export class CommentsComponent implements OnInit {
comments: any[] = [];
constructor(private service: CommentsService) {}
ngOnInit(): void {
this.service.getComments().subscribe(c => {
this.comments = c;
});
}
add(text: string) {
const comment = {text};
this.service.create(comment).subscribe( c => {
this.comments.push(c);
}, err => this.message = err);
}
}
Наш компонент "инжектирует" в конструктор, сервис CommentService а также создает переменную comments которая является массивом в который будут помещаться все наши комментарии. При инициализации компонента, в шаге ngOnInit вызывается из сервиса, метод getComments, на результат которого мы подписывается и кладем в comments, результат вызова.
Так же присутствует метод add, который принимает в себя текст комментария и вызывает внутри себя метод create из сервиса, с помощью которого создает новый комментарий и передает его в массив comments.
Рассмотрим сервис CommentsService который лежит в папке shared:
import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class CommentsService {
constructor(private http: HttpClient) {}
create(comment: any): Observable<any> {
return this.http.post(``, comment);
}
getComments(): Observable<any[]> {
return this.http.get<any[]>(``);
}
}
Сервис принимает в конструктор, HttpClient и имеет два метода create и getComments которые отправляют POST и GET запросы. Запросы имеют пустое поле url так как по сути являются "моковыми" и наше тестирование не выходит за рамки классов.
Создание spec файла
С компонентом и сервисом разобрались теперь создадим файл comments.component.spec.ts в папке с компонентом и добавим в него следующее содержимое:
import { CommentsComponent } from './comments.component';
import { CommentsService } from '../shared/comments.service';
describe('CommentsComponent', () => {
let component: CommentsComponent;
let service: CommentsService;
});
Тут мы экспортировали наш тестируемый компонент CommentsComponent и сервис CommentsService, необходимый для работы нашего компонента. Также добавили обертку describe для нашего тестового набора и также объявили переменные component и service для инициализации компонента и сервиса в тестах.
Запустим Karma командой ng test. Откроется окно браузера с открытым тест раннером на порте 9876.

Отчет пустой, так как мы еще не добавили ни одного теста. Приступим к их написанию.
Добавление тестов
Открываем наш файл comments.component.spec.ts и добавляем конструкцию beforeEach в которой пропишем инициализацию нашего сервиса и компонента, при каждом запуске теста, что создаст изолированность для наших тестов. Также сервису необходимо передавать HttpClient так как он принимает его в качестве параметра. Так как нам не нужно делать физические http-запросы (мы тестируем только код, а не api), мы сделаем "шпиона" для HttpClient, с помощью метода createSpyObj в Jasmine, и передадим его сервису.
import { CommentsComponent } from './comments.component';
import { CommentsService } from '../shared/comments.service';
describe('CommentsComponent', () => {
let component: CommentsComponent;
let service: CommentsService;
beforeEach(() => {
const spyHttp = jasmine.createSpyObj('HttpClient', { post: of({}), get: of({}) })
service = new CommentsService(spyHttp);
component = new CommentsComponent(service);
});
});
Далее через конструкцию it добавим тест на то что наш в нашем компоненте вызывается метод getComments в момент его инициализации (ngOnInit):
import { CommentsService } from '../shared/comments.service';
import { of, EMPTY } from 'rxjs'
import { CommentsComponent } from './comments.component';
describe('CommentsComponent', () => {
let component: CommentsComponent;
let service: CommentsService;
beforeEach(() => {
const spyHttp = jasmine.createSpyObj('HttpClient', { post: of({}), get: of({}) })
service = new CommentsService(spyHttp);
component = new CommentsComponent(service);
});
it('should call getComments when ngOnInit', () => {
const spy = spyOn(service, 'getComments').and.callFake(() => {
return EMPTY;
});
component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
});
Тут мы "замокали" метод getComments (т.е. следим за вызовом этого метода), с помощью метода spyOn и положили все в переменную spy. Если мы будет вызывать реальный метод getComments то мы получим ошибку так как url у нас пустой. Собственно это нам и не нужно так как мы не тестируем api в данном случае.
Установив "слежку" за методом, мы можем им управлять. Для этого мы вызываем метод callFake в который передаем колбек. Колбек должен нам вернуть какой либо observable, согласно нашему методу getComments. В данном случае нам не важно что он будет возвращать, поэтому в return ставим константу EMPTY которую импортируем из rxjs.
Затем мы обращаемся к компоненту и вызываем у него метод ngOnInit. Далее ставим ожидание того что наш spy (там наш замоканный метод getComments) был вызван, при вызове метода ngOnInit в компоненте, проверяя это с помощью метода toHaveBeenCalled.
Проверяем окно тест раннера и наш тест должен появится в отчете с успешным статусом.

Теперь добавим второй тест, который проверит что переменной comments, в компоненте, присвоились какие либо данные в момент инициализации компонента. Для этого добавим следующий код:
describe('CommentsComponent', () => {
let component: CommentsComponent;
let service: CommentsService;
beforeEach(() => {
const spyHttp = jasmine.createSpyObj('HttpClient', { post: of({}), get: of({}) })
service = new CommentsService(spyHttp);
component = new CommentsComponent(service);
});
it('should call getComments when ngOnInit', () => {
const spy = spyOn(service, 'getComments').and.callFake(() => {
return EMPTY;
});
component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
it('should update comment length after ngOnInit', () => {
const testComments = [1, 2, 3, 4]
spyOn(service, 'getComments').and.returnValue(of(testComments))
component.ngOnInit();
expect(component.comments.length).toBe(testComments.length);
});
});
Во втором тесте, мы создали переменную testComments с массивом значений. Так же используем метод spyOn в который передаем getComments и добавляем метод returnValue так как теперь мы будем возвращать уже не пустой observable, а с массивом comments. Для этого добавим метод of из rxjs.
Затем аналогично обращаемся к компоненту и вызываем у него метод ngOnInit. Далее добавляем ожидание того что наша переменная comments в компоненте получила значения из testComments при инициализации компонента. Следовательно наш метод getComments отработал правильно.
Проверяем тест раннер и видим что второй тест успешно пройден.

Ну и добавим теперь тест добавления комментария.
import { CommentsService } from '../shared/comments.service';
import { of, EMPTY, throwError } from 'rxjs'
import { CommentsComponent } from './comments.component';
describe('CommentsComponent', () => {
let component: CommentsComponent;
let service: CommentsService;
beforeEach(() => {
const spyHttp = jasmine.createSpyObj('HttpClient', { post: of({}), get: of({}) })
service = new CommentsService(spyHttp);
component = new CommentsComponent(service);
});
it('should call getComments when ngOnInit', () => {
const spy = spyOn(service, 'getComments').and.callFake(() => {
return EMPTY;
});
component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
it('should update comment length after ngOnInit', () => {
const testComments = [1, 2, 3, 4]
spyOn(service, 'getComments').and.returnValue(of(testComments))
component.ngOnInit();
expect(component.comments.length).toBe(testComments.length);
});
it('should add new comment', () => {
const testComment = {text: 'test'}
const spy = spyOn(service, 'create').and.returnValue(of(testComment))
component.add(testComment.text)
expect(spy).toHaveBeenCalled()
expect(component.comments.includes(testComment)).toBeTruthy()
});
});
Тут мы создали переменную testComment которая содержит в себе объект нашего тестового комментария. Далее мы теперь аналогично "замокали" метод create, с помощью метода spyOn и добавили метод returnValue так как у нас здесь будет возвращаться не пустой observable а наш тестовый объект testComment, и также использовали для этого метод of из rxjs. Затем положили все это в переменную spy.
Далее вызывается метод add нашего компонента, к который передается значение ключа text нашего тестового объекта с комментарием (testObject). После проверяем ожидание того что spy (там наш замоканный метод create из сервиса) был вызван при вызове метода add в компоненте. Затем проверяем что переменная с массивом comments, в компоненте, получила наш тестовый коммент (методы includes и toBeTruthy).
Снова проверяем тест раннер и видим что тест прошел как и все остальные.

Заключение
В этой статье мы рассмотрели тестирование взаимодействия компонента с сервисом. Получилось слегка запутано и без базового понимания rxjs, будет сложновато понять происходящее.