Тестування в Angular (модульні та інтеграційні тести)
Тестування в Angular - це перевірка того, що компоненти, сервіси та їх взаємодія працюють правильно: в ізоляції (модульні тести) і разом (інтеграційні тести), за допомогою Jasmine-специфікацій і системи DI у TestBed.
Теорія
TL;DR
- Модульні тести перевіряють один клас або функцію, замінюючи всі залежності моками
- Інтеграційні тести дозволяють реальним залежностям взаємодіяти і ловлять помилки на межах
TestBed- це тестовий інжектор Angular, він замінює реальну систему DI і контролює що куди підставляється- Модульні тести швидкі і вказують на точне місце помилки; інтеграційні повільніші, але ловлять контрактні баги
- Починай з модульних тестів для логіки, додавай інтеграційні там де взаємодіють форми, HTTP і роутинг
Швидкий приклад
// Модульний тест - AuthService замоканий, перевіряємо виклик
it('should call login with credentials', () => {
const authService = jasmine.createSpyObj('AuthService', ['login']);
authService.login.and.returnValue(of({ token: 'abc123' }));
component.form.patchValue({ email: 'user@test.com', password: 'pass' });
component.onSubmit();
expect(authService.login).toHaveBeenCalledWith({
email: 'user@test.com', password: 'pass'
});
});
// Інтеграційний тест - реальний UserService, HTTP перехоплений
it('should load and display users on init', fakeAsync(() => {
fixture.detectChanges(); // Запускає ngOnInit
const req = httpMock.expectOne('/api/users');
req.flush([{ id: 1, name: 'Alice' }]);
tick();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.user-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Alice');
}));В модульному тесті сервіс є spy-об'єктом і ми перевіряємо виклик. В інтеграційному - реальний сервіс, але HTTP перехоплений.
Головна різниця
Модульний тест ізолює одну частину і замінює все інше моками. Він виконується за мілісекунди і при падінні вказує прямо на зламаний рядок. Інтеграційний тест дозволяє реальним сервісам взаємодіяти з реальними компонентами, ловлячи баги що з'являються тільки на межі - наприклад, коли компонент не обробляє фактичний формат відповіді від сервісу.
Потрібні обидва. Модульні ловлять баги в логіці. Інтеграційні ловлять баги в контрактах між частинами.
Коли що використовувати
Модульний тест:
- Логіка методу сервісу або обчислення
- Поведінка Input/Output компонента
- Трансформація pipe або рішення guard
- Будь-який код без зовнішніх залежностей
Інтеграційний тест:
- Форма відправляє дані через сервіс до API
- Компонент завантажує і відображає дані з сервісу
- Роутинг з guards
- Поведінка HTTP-інтерсептора
E2E тести (Cypress, Playwright) - окремий шар для повних сценаріїв в браузері з реальним бекендом.
Порівняння
| Аспект | Модульний тест | Інтеграційний тест |
|---|---|---|
| Область | Один клас або функція | Кілька компонентів і сервісів |
| Залежності | Замоковані | Реальні або напів-реальні |
| Швидкість | Мілісекунди | Сотні мілісекунд |
| Чіткість помилки | Вказує на конкретний юніт | Вказує на проблему взаємодії |
| Налаштування | Просте | Потребує конфігурації TestBed |
| Найкраще для | Бізнес-логіка, обчислення | Потоки даних, lifecycle компонента |
Як працює TestBed
TestBed.configureTestingModule() налаштовує тестовий інжектор, який відтворює реальну систему DI Angular. Ти вказуєш які providers, imports і компоненти використовувати. Коли викликаєш TestBed.createComponent(), Angular інстанціює компонент через цей інжектор і повертає ComponentFixture - обгортку з доступом до екземпляра компонента, DOM і ручного управління виявленням змін.
Fixture не виявляє зміни автоматично. Ти викликаєш fixture.detectChanges() сам. Це дає контроль: встановив стан, перевірив, оновив, перевірив знову. Для HTTP, HttpClientTestingModule замінює реальний HttpClient моком. Ти інжектуєш HttpTestingController, перехоплюєш запити через expectOne() і відповідаєш через flush(). Жодного реального мережевого запиту.
Типові помилки
Забули fixture.detectChanges() після зміни стану
// Неправильно - DOM не оновлений
component.isLoading = false;
expect(fixture.nativeElement.textContent).toContain('Done'); // не пройде
// Правильно
component.isLoading = false;
fixture.detectChanges(); // Перерендерює шаблон
expect(fixture.nativeElement.textContent).toContain('Done'); // пройдеAngular не перерендерює шаблон автоматично в тестах. Ти запускаєш це сам.
Тестуємо деталі реалізації замість поведінки
// Неправильно - зламається при перейменуванні методу
it('should call getUserData', () => {
spyOn(component, 'getUserData');
component.ngOnInit();
expect(component.getUserData).toHaveBeenCalled();
});
// Правильно - тестуємо що бачить користувач
it('should display user name after init', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Alice');
});Перший тест зламається при рефакторингу, навіть якщо фіча працює. Тестуй результат, не шлях до нього.
async() замість fakeAsync() для контролю часу
// Неправильно - реальні таймери, нестабільно в CI
it('should debounce search', async () => {
component.search('test');
await new Promise(r => setTimeout(r, 300));
expect(component.results).toBeDefined();
});
// Правильно - час під контролем
it('should debounce search', fakeAsync(() => {
component.search('test');
tick(300);
expect(component.results).toBeDefined();
}));fakeAsync() + tick() дає повний контроль над таймерами. Реальні таймери в тестах призводять до нестабільних запусків CI.
Занадто багато моків в інтеграційних тестах
// Неправильно - тестуєш моки, а не інтеграцію
providers: [
{ provide: UserService, useValue: mockUserService },
{ provide: HttpClient, useValue: mockHttp }
]
// Правильно - мокай лише зовнішні системи, дай реальним сервісам працювати
imports: [UserListComponent, HttpClientTestingModule],
providers: [UserService] // Реальний сервіс, HTTP перехопленийЯкщо замокати все в інтеграційному тесті, ти не отримуєш жодної інформації про те, як частини насправді взаємодіють.
httpMock.verify() не викликається в afterEach
Без httpMock.verify() несподівані HTTP запити проходять непоміченими. Додавай його в afterEach для кожного набору тестів із HTTP запитами.
Де зустрічається в реальних проектах
- NestJS використовує
TestingModule- той самий патерн що й AngularTestBed, концепція переноситься напряму - React (Jest + Testing Library) використовує
render()+screen- та сама ідея що йfixture.nativeElement - Vue (Vitest) використовує
mount()+flushPromises()- аналогfakeAsync+tick - Redux: модульні тести для редюсерів як чистих функцій, інтеграційні зі стором і middleware
Команди що пропускають інтеграційні тести часто знаходять проблеми тільки на стейджингу, коли два добре покритих модульними тестами сервіси повертають правильно типізовану відповідь, але шаблон компонента її просто не відображає. П'ять рядків з HttpTestingController вловили б це ще локально.
Питання для поглиблення
Q: Яка різниця між TestBed.inject() і отриманням сервісу з конструктора компонента?
A: Обидва повертають один і той самий singleton з тестового інжектора. TestBed.inject() дає прямий доступ в тесті щоб налаштувати spies або перевірити виклики, не йдучи через екземпляр компонента.
Q: Коли використовувати fakeAsync(), а коли async() або просто Promise?
A: fakeAsync() - коли потрібно контролювати час через tick() для debounce, throttle або setTimeout. async() - коли просто чекаєш на Promise без контролю часу. fakeAsync() найстабільніший варіант для CI середовищ.
Q: Як тестувати компонент з ChangeDetectionStrategy.OnPush?
A: Так само, але fixture.detectChanges() треба викликати після кожної зміни стану. OnPush компоненти оновлюються лише при зміні Input-ів або подіях. Angular не підхопить прямі мутації властивостей, навіть у тестах.
Q: Яка різниця між TestBed.createComponent() і new ComponentClass()?
A: TestBed.createComponent() іде через налаштований інжектор, тому залежності підставляються правильно і lifecycle hooks спрацьовують. new повністю обходить інжектор: ні ін'єкції, ні lifecycle, ні можливостей Angular.
Q (рівень senior): Твій тест для компонента що завантажує дані в ngOnInit проходить, але дані так і не з'являються в DOM. З чого починаєш?
A: Спочатку перевір чи fixture.detectChanges() викликається після завершення асинхронної операції, а не тільки при ініціалізації. Потім перевір чи потрібен fakeAsync + tick якщо сервіс використовує Observable із затримкою. Якщо компонент використовує OnPush, стан встановлений напряму на компоненті не запустить перерендер без явного виявлення змін. І нарешті, додай spy: spyOn(service, 'getData').and.returnValue(of(mockData)). Якщо spy спрацьовує але DOM не оновлюється - проблема у виявленні змін. Якщо spy показує відсутність виклику - проблема в ngOnInit.
Приклади
Базовий: LoginComponent з замоканим AuthService
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
let authService: jasmine.SpyObj<AuthService>;
beforeEach(async () => {
authService = jasmine.createSpyObj('AuthService', ['login']);
authService.login.and.returnValue(of({ token: 'abc123' }));
await TestBed.configureTestingModule({
imports: [LoginComponent, ReactiveFormsModule],
providers: [{ provide: AuthService, useValue: authService }]
}).compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should call authService.login with form values', () => {
component.form.patchValue({ email: 'user@test.com', password: 'pass' });
component.onSubmit();
expect(authService.login).toHaveBeenCalledWith({
email: 'user@test.com',
password: 'pass'
});
});
it('should display error on login failure', () => {
authService.login.and.returnValue(
throwError(() => new Error('Invalid credentials'))
);
component.form.patchValue({ email: 'user@test.com', password: 'wrong' });
component.onSubmit();
fixture.detectChanges();
const errorEl = fixture.nativeElement.querySelector('.error');
expect(errorEl.textContent).toContain('Invalid credentials');
});
});jasmine.createSpyObj створює мок з відстежуваними методами. Другий тест перевизначає значення spy щоб симулювати помилку, потім перевіряє що шаблон оновився. Обидва тести ізольовані: AuthService ніколи не стосується мережі.
Інтеграційний: UserListComponent з реальним сервісом і перехопленим HTTP
describe('UserListComponent integration', () => {
let fixture: ComponentFixture<UserListComponent>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserListComponent, HttpClientTestingModule],
providers: [UserService] // Реальний сервіс, не мок
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Падає якщо є необроблені запити
});
it('should load and display users', fakeAsync(() => {
fixture.detectChanges(); // Запускає ngOnInit -> UserService.getUsers()
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
tick();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.user-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Alice');
}));
it('should show error message on network failure', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/users');
req.error(new ErrorEvent('Network error'));
tick();
fixture.detectChanges();
const errorMsg = fixture.nativeElement.querySelector('.error-message');
expect(errorMsg.textContent).toContain('Failed to load users');
}));
});UserService є реальним класом, але HttpClient перехоплений. Тест перевіряє повний шлях: ngOnInit викликає сервіс, сервіс робить HTTP запит, компонент відображає відповідь. Тест на помилку мережі підтверджує що шлях обробки помилок теж працює.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.