Чого чекати, коли пишеш Unit-тести в Angular

Усі статті, обговорення, новини про тестування — в одному місці. Підписуйтеся на QA DOU!

Привіт, мене звати Назар і я Front-end розробник в ISsoft Ukraine. Протягом своєї кар’єри я використовую фреймворк Angular і часто після старту проєкту постає питання написання unit-тестів: як їх писати, що саме покривати і скільки саме коду повинно бути покрито.

В цій статті я не хочу розбирати всі pros і cons написання unit-тестів, а покажу, з чим ви стикнетесь, якщо все ж таки виберете написання тестів. Все, що буде нижче описано — буде базуватись на моєму досвіді, я не претендую на best practice, але такі підходи до тестування зрозумілі і не вимагають багато часу.

Перш ніж почати, введімо поняття unit-a, оскільки зустрічав багато конфузів з трактуванням цього терміну. Отож під unit-ом будемо розуміти найменшу ізольовану частину коду, тобто функцію, метод чи навіть змінну.

Раз з unit-ом розібрались — тепер напишемо компонент, який будемо тестувати.

Для прикладу, я обрав компонент, який буде схожий на логін. В темплейті буде форма, два інпути для логіну і паролю, чекбокс і дві кнопки submit/reset, але їх зробимо, як кастомні компоненти. (ngSubmit) приберу і поставлю submit форми на клік по кнопці (для більшої кількості сценаріїв).

login.component.html

<div class="form-wrap">
   <form [formGroup]="form">
       <input matInput type="text" class="field-input" formControlName="login">
       <input matInput type="password" class="field-input" formControlName="password">
       <mat-checkbox formControlName="remember" 
              ngDefaultControl
 >Remember Me</mat-checkbox>
       <my-button type="submit"
           (clicked)="submitForm()"
>Submit</my-button>
       <my-button type="reset" 
                  (clicked)="resetForm()"
       >Cancel</my-button>
   </form>
</div>

login.component.ts

export class LoginComponent implements OnInit {
 form: FormGroup;
 value: string;


 constructor(
   private readonly router: Router,
   private readonly fb: FormBuilder,
   private readonly http: HttpService,
   private readonly cdr: ChangeDetectorRef,
   private readonly errorHandler: ErrorHandler,
   private readonly lsService: LocalStorageService
 ) {}


 ngOnInit(): void {
   this.initForm();
   this.registerFormChanges();
 }


 submitForm(): void {
   this.http.attemptLogin(this.form.value)
     .pipe(map(user => JSON.stringify(user)))
     .subscribe(
       (user: string) => {
         this.doSmthWithUser(user);
         this.router.navigate(['home']);
         },
       (err) => {
           this.errorHandler.handleError(err);
       }
     );
 }


 resetForm(): void {
   this.form.reset();
 }


 private doSmthWithUser(user: string) {
   this.lsService.setItem('user', user);
 }


 private doSmthWithForm(state: string) {
   this.lsService.setItem('form', state);
 }


 private initForm(): void {
   this.form = this.fb.group({
     login: null,
     password: null,
     remember: null
   })
 }


 private registerFormChanges(): void {
   this.form.valueChanges.pipe(delay(300)).subscribe(formValue => {
    this.doSmthWithForm('saved');
   })
 }
}

Тепер вже ближче до тіла.

1. TestBed vs new LoginComponent()

Перше, з чим ви зіштовхнетесь, це створення TestBed чи звичайного інстанса класу. Два різні підходи зі своїми плюсами і мінусами. TestBed — буде завантажувати і парсити темплейт під час кожного тесту, на відміну від new LoginComponent() (інстанс класу).

Особисто я надаю перевагу TestBed — створюється більш тестове середовище і якщо треба  можна і в темплейті щось протестувати (до прикладу якийсь *ngFor чи пайп). Використовуючи інстанс класу, тести будуть проходити швидше, що також гарний аргумент, але це вже тестування суто класу і, якщо в класу є залежності, то треба написати більше коду для симуляції залежностей (про це нижче).

Розберімо трохи детальніше, як працювати з кожним з них.

1.1. TestBed

Його концепція полягає в тому, щоб створити саме тестове середовище. Тобто не потрібно створювати всі інстанси які є, а тільки перевіри, чи всі unit-и працюють так, як це очікується при умові, що сторонні сервіси (все, що в конструкторі) будуть працювати коректно. Тобто для всіх залежностей треба створити імітацію роботи, або ж mock.

TestBed буде виглядати наступним чином:

describe('LoginComponent', () => {
 let component: LoginComponent;
 let fixture: ComponentFixture<LoginComponent>;
 let httpService: HttpService;
 let lsService: LocalStorageService;
 let errorHandler: ErrorHandler;


 beforeEach(async () => {
   await TestBed.configureTestingModule({
     declarations: [LoginComponent],
     providers: [
       { provide: HttpService, useValue: {attemptLogin: () => jasmine.createSpy()} },
       { provide: LocalStorageService, useValue: {setItem: () => jasmine.createSpy()} },
       { provide: ErrorHandler, useValue: {handleError: () => jasmine.createSpy()} },
     ],
     imports: [
       RouterTestingModule,
       ReactiveFormsModule
     ],
     schemas: [NO_ERRORS_SCHEMA]
   }).compileComponents();


   fixture = TestBed.createComponent(LoginComponent);
   component = fixture.componentInstance;
   fixture.detectChanges();


   httpService = TestBed.inject(HttpService);
   lsService = TestBed.inject(LocalStorageService);
   errorHandler = TestBed.inject(ErrorHandler);
 })


 it('should create', () => {
   expect(component).toBeTruthy();
 })
});

TestBed модуль схожий на звичайний модуль в Angular, але давайте по порядку:

  • declarations: сюди записуємо нашу компонету (можуть бути і інші компонети, але нас цікавить тільки одна);
  • providers: оскільки нас цікавить тільки тестове середовище, то логікою, яка буде в сервісах, ми можемо знехтувати, і тому робимо mock на ці сервіси. Якщо ми будемо передавати сервіси звичним способом, то вони будуть створюватись повністю, а це займає більше часу ніж mock (note: mock можна сторювати різними способаим, я обрав цей, бо я художник, я так бачу :));
  • imports: сюди передаємо все, що йде з «коробки» і використовується в компоненті (деякі модулі мають «тестовий» варіант, що полегшує життя). Ми звісно можемо і в providers їх замокати, але тоді всю логіку треба буде переписувати (уявіть тільки скільки логіки треба буде переписати для реактивної форми(!));
  • schemas: дає нам можливість нехтувати компонентами, атрибутами, які ми перевикористовуємо в темплейті класу. В цьому випадку це <my-button> і <mat-checkbox>;

Нам не потрібно створювати ці компоненти в темплейті і тестувати їх, тому за допомогою NO_ERRORS_SCHEMA ми нехтуємо їхньою логікою і виграємо кілька мілісекунд.

❗️ Note: в такій ситуації <mat-checkbox> є частиною форми, тому в темплейті потрібно додати атрибут ngDefaultControl інакше будуть помилки.

Далі йде стандартна процедура створення тестового середовища. fixture.detectChanges() варто не забувати, це ознака того, що Angular оновив відображення компонента з оновленими даними, і тоді вже рухатись далі, інакше будуть попередження в терміналі, що якась дія виконалась швидше ніж створився модуль.
Оскільки наші сервіси (які Injected в конструкторі) мають private access, а нам вони будуть потрібні, то до них ми можемо отримати доступ за допомогою TestBed.inject

Ось так ми можемо створювати тестове середовище через TestBed.

1.2. new LoginComponent(...)

Ціль такого способу створення умов для тестування класу, а не компоненти. На практиці буде виглядати наступним чином:

describe('LoginComponent', () => {
 let component: LoginComponent;
 let httpService = {attemptLogin: () => jasmine.createSpy()} as any;
 let lsService = {setItem: () => jasmine.createSpy()} as any;
 let errorHandler = {handleError: () => jasmine.createSpy()} as any;
 let router = {navigate: () => jasmine.createSpy()} as any;
 let fb = new FormBuilder();
 let cdr = {detectChanges: () => jasmine.createSpy()} as any;


 beforeEach(() => {
   component = new LoginComponent(
router,
fb,
httpService,
cdr,
errorHandler,
lsService
   );
 })


 it('should create', () => {
   expect(component).toBeTruthy(); // always true
 })
});

Як бачимо, створення такого класу набагато простіше і зрозуміліше, але мінус в тому, що всі сторонні сервіси треба буде створювати «з нуля» — створювати mock-и. Звісно, що на практиці для загальних класів (як Router чи ChangeDetectionRef) створюється один classMock і просто використовується повторно кожен раз.

Можна помітити, що вкінці кожного mock є as any, не рекомендовано спільнотою, але це ціна за те, щоб не створювати кожен інстанс окремо, бо ж ці залежності також можуть мати свої залежності.

В підсумку можна сказати, що тести з використанням TestBed є інтегрованішими та забезпечують можливість тестувати компоненти та сервіси в контексті Angular. Вони можуть бути зручнішими для тестування складних компонентів, що включають залежності, такі як сервіси, пайпи, директиви і тп.

З іншого боку, тести з використанням звичайного інстансу класу швидші та менш залежні від Angular, оскільки вони не використовують TestBed. Ці тести можуть бути корисними для тестування простих класів, які не мають залежностей від Angular.

В будь-якому випадку, важливо мати на увазі мету тестування та вибрати підхід, який найкраще підходить для конкретної ситуації.

2. Тестування форм

Завдяки TestBed на ngOnInit буде виклик двох методів registerFormChanges() і initForm(). Відповідно одразу буде створена форма і підписка на зміну значень форми. Оскільки ці два методи є приватні, то в нас немає до них доступу, але через виклик метода, який вже їх викликає — ми і тестуємо їх.

Напишемо один тест для перевірки форми в загальному, а інший буде тестувати логіку після зміни форми із затримкою в 300 мілісекунд:

it('should test form', () => {
   expect(component.form.value).toEqual({
    login: null, 
    password: null, 
    remember: null
   });


   component.form.setValue({
    login: '[email protected]', 
    password: 'stepan0101', 
    remember: true
   });
   expect(component.form.value).toEqual({
    login: '[email protected]', 
    password: 'stepan0101', 
    remember: true
  });
})

Для перевірки на зміну форми треба в тесті додати затримку в 300 мс і поставити spy на виконання «тіла» метода, в нашому випадку — збереження значень в localStorage. fakeAsync допоможе нам поставити затримки в часі, а tick — вказує на час затримки, бажано ставити час затримки трохи більший ніж у формі. spyOn — нам допоможе поставити «шпигуна» на метод, який нас цікавить.

toHaveBeenCalledWith — перевірка на виклик «шпигуна» з параметрами:

it('should test form changes', fakeAsync(() => {
 const spy = spyOn(lsService, 'setItem');
 component.form.patchValue({login: '[email protected]'});
 tick(301);
 expect(spy).toHaveBeenCalledWith('form', 'saved')
}));

І на останок reset форми:

it('should reset form', () => {
  component.form.setValue({
   login: '[email protected]',
   password: 'stepan0101',
   remember: true
  });
  expect(component.form.value).toEqual({
    login: '[email protected]',
    password: 'stepan0101',
    remember: true
  });
  component.resetForm();
  expect(component.form.value).toEqual({
    login: null,
    password: null,
    remember: null
  });
})

Тепер тестування форми в контексті інстансу компоненту. В даному випадку тести будуть такими самими, але потібно вручну викликати ngOnInit():

it('should test form values', () => {
 component.ngOnInit();
 expect(component.form.value).toEqual({
   login: null,
   password: null,
   remember: null
 });
})

3. Тестування сервісів

Для submit форми нам треба зробити mock для http сервісу і поставити «шпигуни» на router і localStorage.

it('should submit form', () => {
 const httpSpy = spyOn(httpService, 'attemptLogin').and .returnValue(of({submitted: true}));
 const routerSpy = spyOn(router, 'navigate');
 const lsSpy = spyOn(lsService, 'setItem');


 component.submitForm();


 expect(httpSpy).toHaveBeenCalled();
 expect(routerSpy).toHaveBeenCalledWith(['home']);
 expect(lsSpy).toHaveBeenCalledWith('user', '{"submitted":true}');
})
  • returnValue() - дозволяє підставляти дані, які нам потрібні, коли метод щось вертає (в цьому випадку нам потрібно вернути Observable — тому of());
  • spyOn(router, ’navigate’) - можна і професійніше протестити через ngZone.run(), але елегантніші методи тестування я опишу в наступній статті.

Також після успішного повернення, дані проходять через map() і трансформуються в string. Написати окремий тест на трансформацію не вийде, але можна просто перевірити чи трансформація відбулась — це '{"submitted":true}'.

Також не варто забувати, що запити можуть бути як 200-ті, так і 400-ті, багато хто забуває про тестування помилок. В нашому випадку throwError допомагає легко імітувати помилковий респонс:

it('should handle error on submit form', () => {
 const httpSpy = spyOn(httpService, 'attemptLogin').and
           .returnValue(throwError('my error' ));
 const errorSpy = spyOn(errorHandler, 'handleError');


 component.submitForm();


 expect(httpSpy).toHaveBeenCalled();
 expect(errorSpy).toHaveBeenCalledWith('my error');
})

Висновок

У висновку хотів би сказати, що тестування є надзвичайно важливою частиною розробки програмного забезпечення, особливо в Angular. Воно дозволяє перевірити, чи працює ваш код правильно та чи задовольняє він усі вимоги.

У статті я хотів показати основні методи тестування в Angular. Надіюсь ці приклади допоможуть читачам краще зрозуміти, як можна застосовувати методи тестування в Angular для різних частин.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Замість купи непотрібних тестів давайте глянемо на ті які дійсно вам потрібні і почнемо з позитивних сценаріїв
1) Ввести дані на форму і натиснути кнопку Submit. Expected Result
Мок для this.http.attemptLogin викликався і спрацював this.lsService.setItem та this.router.navigate (також замокані)
2) Нічого не вводити і натиснути кнопку Submit (у вас немає валідації, але цей кейс потрібно обробити, ви ж протестувати повинні що написали все вірно)
3) Натискання на клавішу Cancel. Перевірка що форма перейшла до початкового стану
4) Написати тест на те що при нажатті на Submit упала помилка і викликався метод this.errorHandler.handleError

Пропущено: Немає дійсно потрібних тестів на валідацію. Наприклад обмежити кількість символів для логіна та пароля. По факту у вас один валідний тест який щось і перевіряє should submit form, а потрібно мінімум 4ри теста, все інше — непотрібна мішура.

Дозвольте дати пораду як людині яка пише тести на Angular на прикладі який ви привели нижче

let cdr = {detectChanges: () => jasmine.createSpy()} as any;

Перше — jasmine дуже повільний і його не використовують майже для написання тестів. Я б пропонував поглянути в сторону jest. Але що jest що jasmine не підходять для тестування ReactiveForms (ваш приклад). Зверніть увагу на те, що ви по факту в тестування форми включаєте деталі реалізації ваших компонентів, замість того щоб зосередилися на тому, що ваші тести виконують те для чого вони призначені. По факту нам потрібні тут component або integration tests, і тут нам на допомогу приходить Angular Testing Library testing-library.com/...​lar-testing-library/intro Ця бібліотека побудована поверх DOM Testing Library і чудово дружить з jest. Ось чудова стаття про те як почати працювати з цією бібліотекою timdeschryver.dev/...​rary#clicking-on-elements Вона дозволяє покрити всі кейси з фашими реактивними формами включаючи валідацію і т.д. Аналогічна бібліотека React Testing Library уже наприклад дефакто стандарт для проектів на react.
Наступне, викиньте тести

should create

, вони не роблять нічого корисного і тільки займають лишнє місце.
Спробуйте переглянути свій подхід до тестування форм. По факту ви спочатку пишете купу boilerplate code для того щоб промокати внутрішню імплементацію, потім ви таким самим чином перевіряєте що все замокано. Ось візьмемо ваш приклад, в якому ви викликаєте component.form.setValue а потім перевіряєте що це значення проставлене expect(component.form.value). У мене запитання, а що може якось це значення бути не проставлене? Ви ж це вручшу все робите. Ваш компонент не приймає ніякої участі у цьому. Використовуйте завжди по можливості ААА паттерн (Arrange, Act, Assert). Якщо ще якось component.form.setValue можна притягнути як Arrange, то перевірка що форма проставилась точне не можна трактувати як Act, так як Act у вас це component.resetForm(). Або гляньте на інший тест

should test form

який нічого не тестує і не перевіряє. Це просто тест заради тесту. Перегляньте свій підхід до тестування, на жаль у вас багато проблем помітно з коду.
it('should reset form', () => {   component.form.setValue({    login: '[email protected]',    password: 'stepan0101',    remember: true   });   expect(component.form.value).toEqual({     login: '[email protected]',     password: 'stepan0101',     remember: true   });   component.resetForm();   expect(component.form.value).toEqual({     login: null,     password: null,     remember: null   }); })

Підписатись на коментарі