Kurs Angular · Moduł 11: Testowanie
Testowanie serwisów - sparing przed bitwą
W tej lekcji3
Prawdziwy samuraj nie wychodzi na pole bitwy bez sparingów. Test to taki sparing dla kodu: sprawdzasz, czy technika działa, zanim wróg (użytkownik) ją wykorzysta. W tej lekcji nauczysz się testować serwisy - a zobaczysz, że każdy test to ten sam trzytaktowy rytuał: przygotuj → wykonaj → sprawdź.
Rytuał testu serwisu
Zacznijmy od najprostszego serwisu. Przeczytaj kod i wyłap w nim trzy takty, które wrócą w każdym teście.
import { TestBed } from '@angular/core/testing';
import { SamuraiService } from './samurai.service';
describe('SamuraiService', () => {
let service: SamuraiService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [SamuraiService] });
service = TestBed.inject(SamuraiService);
});
it('should calculate power level correctly', () => {
const result = service.calculatePower(10, 5);
expect(result).toBe(50);
});
});Oto trzy takty rytuału. Przygotuj (beforeEach): Angular buduje serwis w testowym środowisku i wstrzykuje go przez TestBed.inject - dzięki temu każdy test dostaje świeży, czysty egzemplarz. Wykonaj: wołasz metodę (calculatePower). Sprawdź (expect): porównujesz wynik z oczekiwaniem. Zapamiętaj ten schemat - reszta lekcji to ten sam rytuał w trudniejszych warunkach.
Testowanie HTTP na atrapie
Serwis, który odpytuje serwer, ma problem: w teście nie chcesz naprawdę wysyłać żądań - byłoby wolno i zależałoby od sieci. Rozwiązanie: atrapa (mock). HttpTestingController przechwytuje żądanie, a Ty sam podajesz odpowiedź.
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('SamuraiApiService', () => {
let service: SamuraiApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SamuraiApiService, provideHttpClient(), provideHttpClientTesting()]
});
service = TestBed.inject(SamuraiApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // żadne żądanie nie zostało bez odpowiedzi
it('should fetch samurai list', () => {
const mockSamurai = [{ id: 1, name: 'Musashi', clan: 'Ronin' }];
service.getAll().subscribe(samurai => {
expect(samurai[0].name).toBe('Musashi');
});
const req = httpMock.expectOne('/api/samurai'); // przechwyć żądanie
expect(req.request.method).toBe('GET');
req.flush(mockSamurai); // podaj udawaną odpowiedź
});
});Prześledź nowy taniec: expectOne przechwytuje żądanie wysłane pod dany adres, a req.flush(...) dopiero teraz podaje udawaną odpowiedź - to on odpala kod w subscribe. Kolejność jest odwrotna, niż się wydaje: najpierw zamawiasz (subscribe), potem ręcznie dostarczasz wynik (flush). afterEach(httpMock.verify) pilnuje, że test nie zostawił żadnego żądania bez odpowiedzi - to Twój strażnik przed cichymi błędami.
Ta sama atrapa sprawdza też wysyłanie danych i błędy. Zwróć uwagę, jak testujesz sytuację, w której serwer zwraca 404:
it('should send POST request to create samurai', () => {
const newSamurai = { name: 'Hanzo', clan: 'Iga' };
service.create(newSamurai).subscribe(result => expect(result.id).toBe(3));
const req = httpMock.expectOne('/api/samurai');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newSamurai); // sprawdź, co wysłano
req.flush({ id: 3, ...newSamurai });
});
it('should handle HTTP errors', () => {
service.getById(999).subscribe({
next: () => fail('Should have failed'),
error: (error) => expect(error.status).toBe(404)
});
const req = httpMock.expectOne('/api/samurai/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' }); // udawaj błąd
});Przy POST możesz sprawdzić req.request.body - czyli co dokładnie serwis wysłał, nie tylko dokąd. A błąd testujesz, każąc atrapie zwrócić status 404 (drugi argument flush): wtedy powinien odpalić się error, a nie next - dlatego w next wołamy fail, żeby test padł, gdyby błąd nie nastąpił. To wzorzec "udowodnij, że zła droga naprawdę jest zła".
Testowanie interceptora
Interceptor dokłada coś do każdego żądania (np. token). Testujesz go tak samo - na atrapie - ale serwis, od którego zależy, zastępujesz szpiegiem (spy), by kontrolować jego odpowiedzi.
describe('AuthInterceptor', () => {
let httpClient: HttpClient;
let httpMock: HttpTestingController;
let authService: jasmine.SpyObj<AuthService>;
beforeEach(() => {
const authSpy = jasmine.createSpyObj('AuthService', ['getToken']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authSpy }
]
});
httpClient = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
});
it('should add Authorization header when token exists', () => {
authService.getToken.and.returnValue('test-token'); // szpieg udaje token
httpClient.get('/api/samurai').subscribe();
const req = httpMock.expectOne('/api/samurai');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush([]);
});
});jasmine.createSpyObj tworzy szpiega - podróbkę serwisu, której każesz zwrócić dokładnie to, czego potrzebuje test (getToken.and.returnValue('test-token')). Dzięki temu nie zależysz od prawdziwej logiki logowania: ustawiasz warunki i sprawdzasz jeden fakt - czy interceptor dołożył nagłówek Authorization. Drugi test (bez tokenu) dowodzi, że nagłówka wtedy nie ma.
Pamiętaj z tej lekcji jeden rytuał: przygotuj → wykonaj → sprawdź. Testy HTTP dokładają do niego atrapę (expectOne + flush), by sparing odbywał się w sali treningowej, a nie na prawdziwym serwerze.
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
template: `
<div class="service-testing">
<h1>Testowanie Serwisów i HTTP</h1>
<section>
<h2>Testowanie serwisu z HttpClient</h2>
<pre><code>{{ serviceTestCode }}</code></pre>
</section>
<section>
<h2>Mockowanie zależności</h2>
<pre><code>{{ mockingCode }}</code></pre>
</section>
<section>
<h2>Testowanie komponentów z serwisami</h2>
<pre><code>{{ componentServiceCode }}</code></pre>
</section>
<section class="commands">
<h2>Komendy testowe</h2>
<div class="command-list">
<div class="command">
<code>ng test</code>
<span>Uruchom testy jednostkowe</span>
</div>
<div class="command">
<code>ng test --watch=false</code>
<span>Uruchom testy raz (CI)</span>
</div>
<div class="command">
<code>ng test --code-coverage</code>
<span>Generuj raport pokrycia</span>
</div>
<div class="command">
<code>ng e2e</code>
<span>Uruchom testy E2E</span>
</div>
</div>
</section>
</div>
`,
styles: [`
.service-testing {
font-family: 'Segoe UI', sans-serif;
padding: 20px;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
min-height: 100vh;
color: #eee;
}
h1 {
text-align: center;
color: #4ecca3;
}
h2 {
color: #4ecca3;
border-bottom: 2px solid #4ecca3;
padding-bottom: 10px;
}
section {
background: rgba(255,255,255,0.05);
border-radius: 10px;
padding: 20px;
margin: 20px 0;
}
pre {
background: #0a1628;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
border-left: 4px solid #4ecca3;
}
code {
color: #e6e6e6;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
.command-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.command {
display: flex;
align-items: center;
gap: 20px;
padding: 12px;
background: rgba(0,0,0,0.3);
border-radius: 5px;
}
.command code {
background: #4ecca3;
color: #0d2137;
padding: 5px 12px;
border-radius: 4px;
font-weight: bold;
min-width: 200px;
}
.command span {
color: #aaa;
}
`]
})
export class AppComponent {
serviceTestCode = `// samurai.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { SamuraiService } from './samurai.service';
describe('SamuraiService', () => {
let service: SamuraiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [SamuraiService]
});
service = TestBed.inject(SamuraiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Sprawdź czy nie ma nieobsłużonych requestów
});
it('should fetch samurai list', () => {
const mockSamurai = [
{ id: 1, name: 'Musashi' },
{ id: 2, name: 'Kojiro' }
];
service.getSamuraiList().subscribe(samurai => {
expect(samurai.length).toBe(2);
expect(samurai[0].name).toBe('Musashi');
});
const req = httpMock.expectOne('/api/samurai');
expect(req.request.method).toBe('GET');
req.flush(mockSamurai);
});
it('should handle error', () => {
service.getSamuraiList().subscribe({
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpMock.expectOne('/api/samurai');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
});
});`;
mockingCode = `// Tworzenie mocka serwisu
const mockSamuraiService = {
getSamuraiList: jasmine.createSpy().and.returnValue(of([
{ id: 1, name: 'Musashi' }
])),
addSamurai: jasmine.createSpy().and.returnValue(of({ id: 2, name: 'Kojiro' }))
};
// Użycie w TestBed
TestBed.configureTestingModule({
imports: [SamuraiListComponent],
providers: [
{ provide: SamuraiService, useValue: mockSamuraiService }
]
});
// Spy na istniejącym serwisie
const service = TestBed.inject(SamuraiService);
spyOn(service, 'getSamuraiList').and.returnValue(of(mockData));
// Sprawdzanie wywołań
expect(mockSamuraiService.getSamuraiList).toHaveBeenCalled();
expect(mockSamuraiService.addSamurai).toHaveBeenCalledWith({ name: 'Hanzo' });`;
componentServiceCode = `// samurai-list.component.spec.ts
describe('SamuraiListComponent', () => {
let component: SamuraiListComponent;
let fixture: ComponentFixture<SamuraiListComponent>;
let samuraiService: jasmine.SpyObj<SamuraiService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('SamuraiService', ['getSamuraiList', 'deleteSamurai']);
await TestBed.configureTestingModule({
imports: [SamuraiListComponent],
providers: [
{ provide: SamuraiService, useValue: spy }
]
}).compileComponents();
fixture = TestBed.createComponent(SamuraiListComponent);
component = fixture.componentInstance;
samuraiService = TestBed.inject(SamuraiService) as jasmine.SpyObj<SamuraiService>;
});
it('should load samurai on init', fakeAsync(() => {
const mockSamurai = [{ id: 1, name: 'Musashi' }];
samuraiService.getSamuraiList.and.returnValue(of(mockSamurai));
fixture.detectChanges();
tick();
expect(component.samuraiList()).toEqual(mockSamurai);
expect(samuraiService.getSamuraiList).toHaveBeenCalled();
}));
it('should display samurai in template', () => {
samuraiService.getSamuraiList.and.returnValue(of([
{ id: 1, name: 'Musashi' }
]));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Musashi');
});
});`;
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy HttpTestingController?
2. Co robi metoda req.flush(mockData) w teście HTTP?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Uzupełnij test serwisu HTTP sprawdzający pobieranie listy samurajów.
- Układanie w pionie
Uporządkuj kroki testu żądania HTTP w teście serwisu (od góry do dołu):
- Układanie w poziomie
Ułóż linię importu dostawcy testowego HttpClient (jedna linia):