Как писать unit-тесты для компонентов и сервисов?

Юнит-тестирование в Angular основано на использовании Jasmine и Karma (по умолчанию) для компонентов, сервисов, пайпов и других единиц логики. Основные инструменты для написания тестов — это TestBed, ComponentFixture и мокирование зависимостей. Angular CLI автоматически генерирует файл с тестом при создании компонента или сервиса, заканчивающийся на .spec.ts.

1. Базовые принципы юнит-тестов

  • Изолированность: тестируем только одну единицу (компонент, сервис).

  • Предсказуемость: результат теста не зависит от внешних условий.

  • Быстрота: тесты не должны зависеть от HTTP-запросов, реальных таймеров и прочего.

2. Тестирование сервисов

Пример сервиса

@Injectable({ providedIn: 'root' })
export class UserService {
getUserName(): string {
return 'John Doe';
}
}

Юнит-тест сервиса

describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should return username', () => {
expect(service.getUserName()).toBe('John Doe');
});
});

Если сервис зависит от других сервисов, их можно замокать:

class MockApiService {
get() {
return of({ id: 1 });
}
}
TestBed.configureTestingModule({
providers: \[
UserService,
{ provide: ApiService, useClass: MockApiService }
\]
});

3. Тестирование компонентов

Пример компонента

@Component({
selector: 'app-greeting',
template: \`<h1>Hello, {{ name }}</h1>\`
})
export class GreetingComponent {
@Input() name = '';
}

Юнит-тест компонента

describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: \[GreetingComponent\]
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
});
it('should display name in heading', () => {
component.name = 'Alice';
fixture.detectChanges(); // применяет изменения в шаблоне
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Alice');
});
});

4. Мокирование зависимостей

Если компонент зависит от сервиса:

class MockUserService {
getUserName() {
return 'Mock Name';
}
}
TestBed.configureTestingModule({
declarations: \[GreetingComponent\],
providers: \[{ provide: UserService, useClass: MockUserService }\]
});

5. Тестирование событий

@Component({
selector: 'app-counter',
template: \`
<button (click)="increment()">+</button>
<p>{{ count }}</p>
\`
})
export class CounterComponent {
count = 0;
increment() {
this.count++;
}
}
it('should increment count on click', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
const p = fixture.nativeElement.querySelector('p');
expect(p.textContent).toBe('1');
});

6. Тестирование форм

@Component({
template: \`
<form \[formGroup\]="form">
<input formControlName="email" />
</form>
\`
})
export class EmailFormComponent {
form = new FormGroup({
email: new FormControl('', \[Validators.required, Validators.email\])
});
}
it('should validate email control', () => {
const control = component.form.get('email');
control?.setValue('');
expect(control?.valid).toBeFalse();
control?.setValue('invalid_email');
expect(control?.valid).toBeFalse();
control?.setValue('test@example.com');
expect(control?.valid).toBeTrue();
});

7. Тестирование Output-событий

@Component({
selector: 'app-child',
template: \`<button (click)="send()">Send</button>\`
})
export class ChildComponent {
@Output() data = new EventEmitter<string>();
send() {
this.data.emit('Hello');
}
}
it('should emit value on send()', () => {
spyOn(component.data, 'emit');
component.send();
expect(component.data.emit).toHaveBeenCalledWith('Hello');
});

8. Использование async и fakeAsync

async / whenStable()

it('should wait for changes (async)', async () => {
component.name = 'Async';
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('Async');
});

fakeAsync / tick()

it('should handle timeout (fakeAsync)', fakeAsync(() => {
setTimeout(() => component.count++, 1000);
tick(1000);
expect(component.count).toBe(1);
}));

9. HttpClient с HttpTestingController

@Injectable()
export class ApiService {
constructor(private http: HttpClient) {}
getUser(id: number) {
return this.http.get(\`/api/users/${id}\`);
}
}
describe('ApiService', () => {
let httpMock: HttpTestingController;
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: \[HttpClientTestingModule\],
providers: \[ApiService\]
});
httpMock = TestBed.inject(HttpTestingController);
service = TestBed.inject(ApiService);
});
it('should call GET /api/users/1', () => {
service.getUser(1).subscribe();
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush({ id: 1 });
});
afterEach(() => {
httpMock.verify();
});
});

10. Тестирование директив и пайпов

@Pipe({ name: 'capitalize' })
export class CapitalizePipe implements PipeTransform {
transform(value: string): string {
return value\[0\].toUpperCase() + value.slice(1);
}
}
describe('CapitalizePipe', () => {
const pipe = new CapitalizePipe();
it('should capitalize string', () => {
expect(pipe.transform('hello')).toBe('Hello');
});
});

11. Организация тестов

  • Каждый файл *.spec.ts должен покрывать один модуль.

  • Использовать describe для логической группировки.

  • Для моков — создавать папку __mocks__ или использовать jest.mock при использовании Jest.

12. Инструменты и библиотеки

  • Jasmine — дефолтный тестовый фреймворк.

  • Jest — альтернатива с быстрым запуском и snapshot-тестами.

  • TestBed — создание среды Angular для теста.

  • HttpTestingController — для тестирования HTTP-запросов.

  • ng-mocks — библиотека для упрощения моков компонентов, директив.

Юнит-тесты в Angular позволяют проверять корректность логики компонентов и сервисов изолированно от остального приложения, обеспечивая стабильность и надёжность при рефакторинге и масштабировании кода.