Чого чекати, коли пишеш Unit-тести в Angular
Привіт, мене звати Назар і я 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}'
.
Також не варто забувати, що запити можуть бути як
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 для різних частин.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів