Модуль 8 · урок 5 з 9
Тест сервісу з потоками
Головний тест модуля: «unit tests for Data Services to verify RxJS mock data emissions and state mutations». Тобто перевірити, що стор віддає правильну послідовність значень і правильно змінює стан. Тут потрібен один новий інструмент — керування часом у тесті.
Найпростіший випадок: синхронний потік
it('віддає початковий стан одразу', () => {
const emissions: Filters[] = [];
store.filters$.subscribe(v => emissions.push(v));
expect(emissions.length).toBe(1); // BehaviorSubject видає одразу
expect(emissions[0]).toEqual({ status: 'all', query: '' });
});
Тут нічого асинхронного: BehaviorSubject віддає поточне значення в момент
підписки, тож перевірка йде одразу після неї. Це найзручніший вид тесту потоку — і ще одна
причина тримати стан саме в BehaviorSubject.
Перевірка послідовності емісій
it('видає нове значення після зміни фільтра', () => {
const emissions: string[] = [];
store.filters$.subscribe(f => emissions.push(f.status));
store.setStatus('done');
store.setStatus('blocked');
expect(emissions).toEqual(['all', 'done', 'blocked']); // разом із початковим
});
Збирати значення в масив і порівнювати весь масив — найчитабельніший спосіб перевірити потік. Він одразу ловить і зайві емісії: якщо стор випадково видасть значення двічі, тест впаде й покаже, що саме прийшло.
Асинхронність: fakeAsync і tick
import { fakeAsync, tick } from '@angular/core/testing';
it('завантажує список і знімає індикатор', fakeAsync(() => {
api.list.and.returnValue(of(page).pipe(delay(300))); // затримка як у моці
const states: boolean[] = [];
store.loading$.subscribe(v => states.push(v));
store.load();
expect(states).toEqual([false, true]); // індикатор увімкнувся
tick(300); // «промотати» час на 300 мс
expect(states).toEqual([false, true, false]);
expect(store.snapshot().length).toBe(2);
}));
Він підміняє таймери всередині тесту й дає керувати часом вручну: tick(300)
миттєво «промотує» триста мілісекунд, не чекаючи їх насправді. Тест із затримкою в дві
секунди виконується за мілісекунди.
flush() — те саме, але промотує всі відкладені таймери одразу, скільки б їх
не було. Зручно, коли точна затримка неважлива.
Пастка: якщо після fakeAsync-тесту лишились незавершені таймери,
тест впаде з «X timer(s) still in the queue». Це не прикрість, а корисне попередження:
у коді щось не прибирається.
Тест debounce
it('не смикає API на кожну літеру', fakeAsync(() => {
api.list.and.returnValue(of(page));
store.setQuery('k');
store.setQuery('ky');
store.setQuery('kyiv');
tick(299);
expect(api.list).not.toHaveBeenCalled(); // ще рано
tick(1);
expect(api.list).toHaveBeenCalledTimes(1); // один запит на всі три літери
}));
Ось тест, який справді щось доводить: він фіксує саме ту поведінку, заради якої додавали
debounceTime у модулі 4. Якщо колись хтось прибере цей оператор,
тест упаде — і поясниться причина.
Тест помилки
it('показує помилку й не ламає потік', fakeAsync(() => {
api.list.and.returnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
store.load();
tick();
expect(store.errorSnapshot()).toBe('Сервер тимчасово недоступний.');
expect(store.loadingSnapshot()).toBe(false); // finalize спрацював
// найважливіше: після помилки стор іще працює
api.list.and.returnValue(of(page));
store.load();
tick();
expect(store.snapshot().length).toBe(2);
}));
Останні три рядки — головна цінність цього тесту. Вони перевіряють те, про що йшлося в модулі 4: помилка не має вбивати потік назавжди. Такий баг неможливо помітити оком, але тест ловить його одразу.
Тест мутації стану
it('оптимістично оновлює статус і відкочує при помилці', fakeAsync(() => {
store.setTasks([{ id: '1', status: 'todo' }] as Task[]);
api.patch.and.returnValue(throwError(() => new Error('boom')));
store.updateStatus('1', 'done');
expect(store.snapshot()[0].status).toBe('done'); // одразу змінилось
tick();
expect(store.snapshot()[0].status).toBe('todo'); // відкотилось
expect(store.errorSnapshot()).toBeTruthy();
}));
Незмінність теж перевіряється
it('не мутує наявні обʼєкти при оновленні', () => {
const first = { id: '1', status: 'todo' } as Task;
const second = { id: '2', status: 'todo' } as Task;
store.setTasks([first, second]);
store.updateStatusLocal('1', 'done');
const rows = store.snapshot();
expect(rows[0]).not.toBe(first); // змінений — новий обʼєкт
expect(rows[1]).toBe(second); // незмінений — те саме посилання
expect(first.status).toBe('todo'); // оригінал не постраждав
});
Тут toBe замість toEqual — навмисно: перевіряється саме
посилання. Це той самий інваріант, від якого залежить OnPush із модуля 3,
і тепер він зафіксований тестом.