Angular IDPStrong Junior
Модулі / Юніт-тести / Тест сервісу з потоками
🔥 0

Модуль 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);
}));
Що робить fakeAsync

Він підміняє таймери всередині тесту й дає керувати часом вручну: 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, і тепер він зафіксований тестом.