Angular 5: Unit тести

Angular 5: Unit тести

За допомогою 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';
}

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

<h1>
  Welcome to {{ title }}!
</h1>

Файл тестів розберемо по шматочках. Для початку ставимо 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 ]
  })

Переклад статті “Angular 5: Unit тесты