[Из песочницы] Angular 5: Unit тесты

th5wkzcngzltvpocakdmigx54ye.png С помощью unit тестов мы можем удостовериться, что отдельные части приложения работают именно так, как мы от них ожидаем.

Это в некоторой степени спасает от поломок существующий код, помогает прояснить — как он будет работать в тех или иных случаях. И, в конце концов, позволяет посмотреть на код, так скажем, со стороны, чтобы увидеть его слабые стороны.

Даже существует мнение, что сложно тестируемый код — претендент на переписывание.

Цель данной статьи — помочь в написании unit тестов для Angular 5+ приложения. Пусть это будет увлекательный процесс, а не головная боль.

Изолированные или Angular Test Bed?


Что касается unit тестирования Angular приложения, то можно выделить два вида тестов:

  • Изолированные — те, которые не зависят от Angular. Они проще в написании, их легче читать и поддерживать, так как они исключают все зависимости. Такой подход хорош для сервисов и пайпов.
  • Angular Test Bed — тесты, в которых с помощью тестовой утилиты TestBed осуществляется настройка и инициализация среды для тестирования. Утилита содержит методы, которые облегчают процесс тестирования. Например, мы можем проверить, создался ли компонент, как он взаимодействует с шаблоном, с другими компонентами и с зависимостями.


Изолированные


При изолированном подходе мы тестируем сервис как самый обыкновенный класс с методами.

Сначала создаем экземпляр класса, а затем проверяем, как он работает в различных ситуациях.

Прежде чем переходить к примерам, необходимо обозначить, что я пишу и запускаю тесты с помощью jest, так как понравилась его скорость. Если вы предпочитаете karma + jasmine, то примеры для вас также будут актуальны, поскольку различий в синтаксисе совсем немного.

Jasmine/jest различия
jasmine.createSpy ('name') --> jest.fn ()
and.returnValue () --> mockReturnValue ()
spyOn (…).and.callFake (() => {}) --> jest.spyOn (…).mockImplementation (() => {})


Рассмотрим пример сервиса для модального окна. У него всего лишь два метода, которые должны рассылать определенное значение для переменной popupDialog. И совсем нет зависимостей.

import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs/ReplaySubject';

@Injectable()
export class PopupService {

  private popupDialog = new ReplaySubject<{popupEvent: string, component?, options?: {}}>();

  public popupDialog$ = this.popupDialog.asObservable();

  open(component, options?: {}) {
    this.popupDialog.next({popupEvent: 'open', component: component, options: options});
  }

  close() {
    this.popupDialog.next({popupEvent: 'close'});
  }

}


При написании тестов не нужно забывать о порядке выполнения кода. Например, действия, которые необходимо выполнить перед каждым тестом, мы помещаем в beforeEach.
Так, созданный в коде ниже экземпляр сервиса нам понадобится для каждой проверки.

import { PopupService } from './popup.service';
import { SignInComponent } from '../components/signin/signin.component';

describe('PopupService', () => {
  let service: PopupService;
  // создаем экземпляр PopupService
  beforeEach(() => { service = new PopupService(); });
  // done нужно, чтобы тест не завершился до получения данных
  it('subscribe for opening works', (done: DoneFn) => {
    // вызываем метод open
    service.open(SignInComponent, [{title: 'Попап заголовок', message: 'Успешно'}]);
    // при изменении значения popupDialog$ должен сработать subscribe
    service.popupDialog$.subscribe((data) => {
      expect(data.popupEvent).toBe('open');
      done();
    });

  });
  it('subscribe for closing works', (done: DoneFn) => {
    service.close();
    service.popupDialog$.subscribe((data) => {
      expect(data.popupEvent).toBe('close');
      done();
    });
  });
});


Angular Test Bed тесты


Простой компонент


А теперь посмотрим на всю мощь утилиты TestBed. В качестве примера для начала возьмем простейший компонент:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
}


Файл шаблона:

Welcome to {{ title }}!


Файл тестов разберем по кусочкам. Для начала задаем TestBed конфигурацию:

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));


compileComponents — метод, делающий вынесенные в отдельные файлы стили и шаблон встроенными.

Этот процесс является асинхронным, так как компилятор Angular должен получить данные из файловой системы.

Иногда compileComponents не нужен
Если вы используете WebPack, то этот вызов и метод async вам не нужен.
Дело в том, что WebPack автоматически перед запуском тестов встраивает внешние стили и шаблон.

Соответственно, и при прописывании стилей и шаблона внутри файла компонента компилировать самостоятельно не надо.


Для тестов необходимо, чтобы компоненты скомпилировались до того, как через метод createComponent () будут созданы их экземпляры.

Поэтому тело первого BeforeEach мы поместили в asynс метод, благодаря чему его содержимое выполняется в специальной асинхронной среде. И пока не будет выполнен метод compileComponents (), следующий BeforeEach не запустится:

beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp = fixture.componentInstance;
  });


Благодаря вынесению в beforeEach всех общих данных, дальнейший код получается значительно чище.

Для начала проверим создание экземпляра компонента и его свойство:

it('should create the comp',  => {
  expect(comp).toBeTruthy();
});
it(`should have as title 'app'`, () => {
   expect(comp.title).toEqual('app');
});


Далее мы хотим проверить, что переменная компонента title вставляется в DOM. При этом мы ожидаем, что ей присвоено значение 'app'. А это присваивание происходит при инициализации компонента.

Запустив с помощью detectChanges CD цикл, мы инициализируем компонент.
До этого вызова связь DOM и данных компонента не произойдет, а следовательно тесты не пройдут.

  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent)
      .toContain('Welcome to app!');
  });


Полный код теста компонента
import { TestBed, async, ComponentFixture } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {

  let comp: AppComponent;
  let fixture: ComponentFixture;
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp = fixture.componentInstance;
  });

  it('should create the comp', () => {
    expect(comp).toBeTruthy();
  });
  it(`should have as title 'app'`, () => {
    expect(comp.title).toEqual('app');
  });
  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent)
      .toContain('Welcome to app!');
  });
});


Компонент с зависимостями


Давайте усложним наш компонент, внедрив в него сервис:

export class AppComponent {
  constructor(private popup: PopupService) { }
  title = 'app';
}


Вроде бы пока не особо усложнили, но тесты уже не пройдут. Даже если вы не забыли добавить сервис в providers AppModule.

Потому что в TestBed эти изменения тоже нужно отразить:

TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
      providers: [PopupService]
    });


Мы можем указать сам сервис, но обычно лучше заменить его на класс или объект, который описывает именно то, что нам необходимо для тестов.

Почему?

А вы представьте сервис с кучей зависимостей и вам все придется при тестировании прописать. Не говоря уже о том, что мы тестируем в данном случае именно компонент. Вообще, тестировать что-то одно — это как раз про unit тесты.

Итак, прописываем стаб следующим образом:

const popupServiceStub = {
    open: () => {}
};


Методы задаем только те, которые тестируем.

Если хотим описать стаб как класс
class popupServiceStub {
    open() {}
}

providers: [{provide: PopupService, useClass: popupServiceStub } ]


В TestBed конфигурацию добавляем providers:

providers: [{provide: PopupService, useValue: popupServiceStub } ]


Не стоит путать PopupService и PopupServiceStab. Это разные объекты: первый — клон второго.

Отлично, но мы же сервис внедряли не просто так, а для использования:

ngOnInit() {
    this.popup.open(SignInComponent);
}


Теперь стоит убедиться, что метод действительно вызывается. Для этого сначала получим экземпляр сервиса.

Так как в данном случае сервис задан в providers корневого модуля, то мы можем сделать так:

popup = TestBed.get(PopupService);


А как еще?
Если бы речь шла о сервисе, который прописан в providers компонента, то пришлось бы получать его так:
popup = fixture.debugElement.injector.get(PopupService);


Наконец сама проверка:

it('should called open', () => {
    const openSpy = jest.spyOn(popup, 'open');
    fixture.detectChanges();
    expect(openSpy).toHaveBeenCalled();
  });


Наши действия:

  1. Устанавливаем шпиона на метод open объекта popup.
  2. Запускаем CD цикл, в ходе которого выполнится ngOnInit с проверяемым методом
  3. Убеждаемся, что он был вызван.


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

Сервис с http


Совсем недавно (в Angular 4) файл тестов сервиса с запросами мог выглядеть воистину устрашающе.

Вспомнить, как это было
beforeEach(() => TestBed.configureTestingModule({
    imports: [HttpModule],
    providers: [
      MockBackend,
      BaseRequestOptions,
      {
        provide: Http,
        useFactory: (backend, defaultOptions) => new Http(backend, defaultOptions),
        deps: [MockBackend, BaseRequestOptions]
      },
      UserService
    ]
  }));


Впрочем, и сейчас в интернете полно статей с этими примерами.

А меж тем разработчики Angular не сидели сложа руки, и мы теперь можем писать тесты намного проще. Просто воспользовавшись HttpClientTestingModule и HttpTestingController.

Рассмотрим сервис:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';

import { Game } from '../models/gameModel';
import { StatisticsService } from './statistics.service';

@Injectable()
export class GameService {
  gameData: Array;
  dataChange:  ReplaySubject;
  gamesUrl = 'https://any.com/games';

  constructor(private http: HttpClient, private statisticsService: StatisticsService) {
    this.dataChange  = new ReplaySubject();
  }

  getGames() {
    this.makeResponse()
      .subscribe((games: Array) => {
        this.handleGameData(games);
      });
  }

  makeResponse(): Observable {
    return this.http.get(this.gamesUrl);
  }
  handleGameData(games) {
    this.gameData = games;
    this.doNext(games);
    this.statisticsService.send();
  }

  doNext(value) {
    this.dataChange.next(value);
  }

}


Для начала описываем всех наших глобальных героев:

let http: HttpTestingController;
let service: GameService;
let statisticsService: StatisticsService;
const statisticsServiceStub = {
    send: () => {}
};


Тут из интересного — стаб statisticsService. Мы по аналогии с компонентом стабим зависимости, так как тестим сейчас только конкретный сервис.

Как видите, я просто прописала именно то, что понадобится в этом тесте. Просто представьте, что в StatisticsService на самом деле огромное количество методов и зависимостей, а используем в данном сервисе мы только один метод.

Далее объявим данные, которые будем подкидывать в ответ на запрос:

  const expectedData = [
    {id: '1', name: 'FirstGame', locale: 'ru', type: '2'},
    {id: '2', name: 'SecondGame', locale: 'ru', type: '3'},
    {id: '3', name: 'LastGame',  locale: 'en', type: '1'},
  ];


В TestBed необходимо импортировать HttpClientTestingModule и прописать все сервисы:

TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
      ],
      providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }]
    });


Следующий шаг — получение экземпляров всех сервисов, которые нам понадобятся:

    service = TestBed.get(GameService);
    statisticsService = TestBed.get(StatisticsService);
    http = TestBed.get(HttpTestingController);


Не помешает сразу же прописать в afterEach проверку на то, что нет отложенных запросов:

afterEach(() => {
    http.verify();
});


И переходим к самим тестам. Самое простое, что мы можем проверить — создался ли сервис. Если вы забудете в TestBed указать какую-либо зависимость, то этот тест не пройдет:

 it('should be created', () => {
    expect(service).toBeTruthy();
  });


Дальше уже интереснее — проверяем, что по ожидаемому запросу получим определенные данные, которые сами же и подкидываем:

it('should have made one request to GET data from expected URL', () => {

    service.makeResponse().subscribe((data) => {
      expect(data).toEqual(expectedData);
    });

    const req = http.expectOne(service.gamesUrl);
    expect(req.request.method).toEqual('GET');
    req.flush(expectedData);
  });


Не помешает проверить еще и как работает ReplaySubject, то есть будут ли отлавливаться у подписчиков полученные игры:

it('getGames should emits gameData', () => {

    service.getGames();

    service.dataChange.subscribe((data) => {
      expect(data).toEqual(expectedData);
    });
    
    const req = http.expectOne(service.gamesUrl);
    req.flush(expectedData);

  });


И наконец последний пример — проверка, что statisticsService метод send будет вызван:

 it('statistics should be sent', () => {
    const statisticsSpy = jest.spyOn(statisticsService, 'send');
    service.handleGameData(expectedData);
     expect(statisticsSpy).toHaveBeenCalled();
  });


Полный код тестов
import { TestBed } from '@angular/core/testing';

import { GameService } from './game.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { StatisticsService } from './statistics.service';

import 'rxjs/add/observable/of';

describe('GameService', () => {
  let http: HttpTestingController;
  let service: GameService;
  let statisticsService: StatisticsService;
  const statisticsServiceStub = {
    send: () => {}
  };

  const expectedData = [
    {id: '1', name: 'FirstGame', locale: 'ru', type: '2'},
    {id: '2', name: 'SecondGame', locale: 'ru', type: '3'},
    {id: '3', name: 'LastGame',  locale: 'en', type: '1'},
  ];


  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
      ],
      providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }]
    });

    service = TestBed.get(GameService);
    statisticsService = TestBed.get(StatisticsService);
    http = TestBed.get(HttpTestingController);

  });

  afterEach(() => {
    http.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should have made one request to GET data from expected URL', () => {

    service.makeResponse().subscribe((data) => {
      expect(data).toEqual(expectedData);
    });

    const req = http.expectOne(service.gamesUrl);
    expect(req.request.method).toEqual('GET');
    req.flush(expectedData);
  });


  it('getGames should emits gameData', () => {

    service.getGames();

    service.dataChange.subscribe((data) => {
      expect(data).toEqual(expectedData);
    });
    
    const req = http.expectOne(service.gamesUrl);
    req.flush(expectedData);

  });

  it('statistics should be sent', () => {
    const statisticsSpy = jest.spyOn(statisticsService, 'send');
    service.handleGameData(expectedData);
    expect(statisticsSpy).toHaveBeenCalled();
  });


});


Как облегчить тестирование?


  1. Выбирайте тот тип тестов, который подходит в данной ситуации и не забывайте про суть unit тестов
  2. Убедитесь, что знаете все возможности вашей IDE в плане помощи при тестировании
  3. При генерации сущностей с помощью Angular-cli автоматически генерируется и файл тестов
  4. Если в компоненте множество таких зависимостей, как директивы и дочерние компоненты, то можно отключить проверку их определения. Для этого в TestBed конфигурации прописываем NO_ERRORS_SCHEMA:
    TestBed.configureTestingModule({
        declarations: [ AppComponent ],
        schemas:      [ NO_ERRORS_SCHEMA ]
      })


Без послесловия не обойтись


Охватить в одной статье все моменты, чтобы она при этом не стала устрашающей (а-ля документация), довольно сложно. Но мне кажется, главное — понять, какие у вас есть инструменты и что с ними нужно делать, а дальше уже бесстрашно сталкиваться на практике как с банальными, так и с нетривиальными случаями.

Если после прочтения статьи вам стало что-то немного понятнее — ура!
У вас есть что добавить? Вы с чем-то не согласны?

Что ж, может быть, ради ваших ценных комментариев это статья и затевалась.

P.S. Ах да, вот ссылка на все примеры.

© Habrahabr.ru