테스트 아키텍처: Angular 앱 전략
견고한 테스트 전략은 유지 관리 가능한 Angular 애플리케이션의 기초입니다. 신뢰할 수 있는 테스트가 없으면 모든 리팩토링은 위험이 되고 모든 배포는 도박이 됩니다. 하지만 테스트가 무작위 테스트 작성을 의미하는 것은 아닙니다.테스트 아키텍처 애플리케이션의 각 수준에 대한 명확한 전략으로 잘 정의되어 있습니다.
이 기사에서는 테스트 피라미드, 트로피 모델을 살펴보고 방법을 살펴보겠습니다. Angular를 사용하여 엔드투엔드 서비스, 구성 요소 및 흐름을 테스트합니다. 농담, 각도 테스트 라이브러리 e 극작가.
무엇을 배울 것인가
- Kent C. Dodds의 테스트 피라미드와 트로피 모델
- Jest를 사용하여 Angular 서비스 단위 테스트
- Angular Testing Library를 사용한 구성 요소 테스트
- Playwright와 함께하는 E2E 테스트
- HttpClient 및 서비스에 대한 모의 전략
- 비동기 코드 테스트: Observable 및 Promise
- CI/CD 파이프라인에 테스트 통합
테스트 피라미드와 트로피 모델
La 테스트 피라미드 Mike Cohn이 제안한 클래식은 다음을 제안합니다. 피라미드 분포: 베이스에서는 많은 단위 테스트, 중앙에서는 적은 수의 통합 테스트, 그리고 몇 가지 E2E 테스트도 있습니다. 더 빠르고 더 격리된 테스트가 대다수가 되어야 한다는 생각입니다.
Il 트로피 모델 Kent C. Dodds가 이 비전을 부분적으로 뒤집었습니다. 에 더 많은 투자를 제안 통합 테스트, 그들이 제공하는 작성 비용과 응용 프로그램 작동에 대한 신뢰 사이의 가장 좋은 관계.
두 모델의 비교
| 수준 | 피라미드(콘) | 트로피(도즈) | 속도 | 신뢰하다 |
|---|---|---|---|---|
| 정적 분석 | 포함되지 않음 | 트로피 베이스 | 즉각적인 | 낮은 |
| 단위 테스트 | 테스트의 70% | 테스트의 20% | 매우 높음 | 평균 |
| 통합 테스트 | 테스트의 20% | 테스트의 60% | 높은 | 높은 |
| E2E 테스트 | 테스트의 10% | 테스트의 20% | 낮은 | 매우 높음 |
어떤 모델을 따라야 합니까?
Angular 애플리케이션의 경우 트로피 모델이 더 효과적인 경우가 많습니다. 통합 테스트 템플릿, 바인딩 및 상호 작용을 확인하는 구성 요소가 더 많은 실제 버그를 포착합니다. 격리된 클래스의 순수 단위 테스트와 비교됩니다. 권장사항은 다음과 같습니다. 테스트 작성 빠르고 안정적으로 유지되는 가능한 최고 수준.
Jest를 사용한 서비스 단위 테스트
Angular 서비스에는 비즈니스 로직과 API와의 통신이 포함되어 있습니다. 서비스를 테스트한다는 것은 로직이 올바르게 작동하는지 확인하는 것을 의미합니다. 중독으로부터 격리되어 있습니다.
Angular용 Jest 구성
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
setupFilesAfterSetup: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/dist/',
'<rootDir>/e2e/'
],
moduleNameMapper: {
'@app/(.*)': '<rootDir>/src/app/$1',
'@env/(.*)': '<rootDir>/src/environments/$1'
},
collectCoverageFrom: [
'src/app/**/*.ts',
'!src/app/**/*.module.ts',
'!src/app/**/*.routes.ts',
'!src/app/**/index.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
export default config;
HttpClient를 사용하여 서비스 테스트
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/products';
getProducts(category?: string): Observable<Product[]> {
let params = new HttpParams();
if (category) {
params = params.set('category', category);
}
return this.http.get<Product[]>(this.baseUrl, { params });
}
getProductById(id: string): Observable<Product> {
return this.http.get<Product>(this.baseUrl + '/' + id);
}
createProduct(data: CreateProductDto): Observable<Product> {
return this.http.post<Product>(this.baseUrl, data);
}
calculateDiscount(product: Product, percentage: number): number {
if (percentage < 0 || percentage > 100) {
throw new Error('La percentuale deve essere tra 0 e 100');
}
return product.price * (1 - percentage / 100);
}
}
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
const mockProducts: Product[] = [
{ id: '1', name: 'Laptop', price: 999, category: 'electronics', inStock: true },
{ id: '2', name: 'Mouse', price: 29, category: 'electronics', inStock: true },
{ id: '3', name: 'Book', price: 15, category: 'books', inStock: false }
];
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ProductService,
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Verifica che non ci siano richieste HTTP pendenti
httpMock.verify();
});
describe('getProducts', () => {
it('deve caricare tutti i prodotti', () => {
service.getProducts().subscribe(products => {
expect(products).toEqual(mockProducts);
expect(products.length).toBe(3);
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
it('deve filtrare per categoria', () => {
service.getProducts('electronics').subscribe(products => {
expect(products.length).toBe(2);
});
const req = httpMock.expectOne(
r => r.url === '/api/products' &&
r.params.get('category') === 'electronics'
);
expect(req.request.method).toBe('GET');
req.flush(mockProducts.filter(p => p.category === 'electronics'));
});
it('deve gestire errori HTTP', () => {
service.getProducts().subscribe({
next: () => fail('Dovrebbe fallire'),
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpMock.expectOne('/api/products');
req.flush('Server error', {
status: 500,
statusText: 'Internal Server Error'
});
});
});
describe('calculateDiscount', () => {
const product: Product = {
id: '1', name: 'Laptop', price: 1000,
category: 'electronics', inStock: true
};
it('deve calcolare lo sconto correttamente', () => {
expect(service.calculateDiscount(product, 20)).toBe(800);
expect(service.calculateDiscount(product, 50)).toBe(500);
expect(service.calculateDiscount(product, 0)).toBe(1000);
expect(service.calculateDiscount(product, 100)).toBe(0);
});
it('deve lanciare errore per percentuale non valida', () => {
expect(() => service.calculateDiscount(product, -10))
.toThrow('La percentuale deve essere tra 0 e 100');
expect(() => service.calculateDiscount(product, 110))
.toThrow('La percentuale deve essere tra 0 e 100');
});
});
});
Angular 테스트 라이브러리를 사용한 구성 요소 테스트
각도 테스트 라이브러리 테스트 라이브러리 철학을 따릅니다: 테스트 실제 사용자로서의 구성요소는 역할을 통해 DOM과 상호작용하며 이를 사용합니다. 구현 세부정보를 통하지 않고 눈에 보이는 텍스트와 액세스 가능한 레이블을 사용합니다.
상호 작용으로 구성 요소 테스트
@Component({
standalone: true,
selector: 'app-product-list',
template: `
<div class="product-list">
<input
type="text"
placeholder="Cerca prodotti..."
[ngModel]="searchTerm"
(ngModelChange)="onSearch($event)"
aria-label="Cerca prodotti"
/>
<div class="filters">
<select
[ngModel]="selectedCategory"
(ngModelChange)="onCategoryChange($event)"
aria-label="Filtra per categoria">
<option value="">Tutte le categorie</option>
<option *ngFor="let cat of categories" [value]="cat">
{{ cat }}
</option>
</select>
</div>
<div *ngIf="loading" role="status">Caricamento...</div>
<div *ngIf="error" role="alert" class="error">
{{ error }}
<button (click)="reload()">Riprova</button>
</div>
<ul *ngIf="!loading && !error">
<li *ngFor="let product of filteredProducts" class="product-card">
<h3>{{ product.name }}</h3>
<p class="price">EUR {{ product.price }}</p>
<span [class.in-stock]="product.inStock"
[class.out-of-stock]="!product.inStock">
{{ product.inStock ? 'Disponibile' : 'Esaurito' }}
</span>
<button (click)="addToCart(product)"
[disabled]="!product.inStock">
Aggiungi al carrello
</button>
</li>
</ul>
<p *ngIf="filteredProducts.length === 0 && !loading">
Nessun prodotto trovato
</p>
</div>
`,
imports: [CommonModule, FormsModule]
})
export class ProductListComponent implements OnInit {
// ... implementazione
}
import { render, screen, fireEvent, waitFor } from '@testing-library/angular';
import { ProductListComponent } from './product-list.component';
import { ProductService } from '../../services/product.service';
import { CartService } from '../../services/cart.service';
import { of, throwError } from 'rxjs';
describe('ProductListComponent', () => {
const mockProducts: Product[] = [
{ id: '1', name: 'Laptop Pro', price: 999, category: 'electronics', inStock: true },
{ id: '2', name: 'Wireless Mouse', price: 29, category: 'electronics', inStock: true },
{ id: '3', name: 'TypeScript Handbook', price: 45, category: 'books', inStock: false }
];
const mockProductService = {
getProducts: jest.fn().mockReturnValue(of(mockProducts))
};
const mockCartService = {
addItem: jest.fn()
};
async function setup(overrides = {}) {
return render(ProductListComponent, {
providers: [
{ provide: ProductService, useValue: { ...mockProductService, ...overrides } },
{ provide: CartService, useValue: mockCartService }
]
});
}
beforeEach(() => {
jest.clearAllMocks();
});
it('deve mostrare la lista dei prodotti', async () => {
await setup();
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.getByText('Wireless Mouse')).toBeTruthy();
expect(screen.getByText('TypeScript Handbook')).toBeTruthy();
});
it('deve mostrare lo stato di disponibilità', async () => {
await setup();
const disponibili = screen.getAllByText('Disponibile');
const esauriti = screen.getAllByText('Esaurito');
expect(disponibili.length).toBe(2);
expect(esauriti.length).toBe(1);
});
it('deve filtrare i prodotti con la ricerca', async () => {
await setup();
const searchInput = screen.getByLabelText('Cerca prodotti');
fireEvent.input(searchInput, { target: { value: 'Laptop' } });
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
expect(screen.queryByText('Wireless Mouse')).toBeNull();
});
});
it('deve disabilitare il pulsante per prodotti esauriti', async () => {
await setup();
const buttons = screen.getAllByText('Aggiungi al carrello');
// Il terzo prodotto è esaurito
expect(buttons[2]).toBeDisabled();
expect(buttons[0]).not.toBeDisabled();
});
it('deve aggiungere al carrello quando si clicca il pulsante', async () => {
await setup();
const buttons = screen.getAllByText('Aggiungi al carrello');
fireEvent.click(buttons[0]);
expect(mockCartService.addItem).toHaveBeenCalledWith(mockProducts[0]);
});
it('deve mostrare il caricamento', async () => {
// Simula un caricamento lento
const neverResolve = new Observable<Product[]>(() => {});
await setup({ getProducts: () => neverResolve });
expect(screen.getByText('Caricamento...')).toBeTruthy();
});
it('deve mostrare errore e permettere il retry', async () => {
mockProductService.getProducts
.mockReturnValueOnce(throwError(() => new Error('Network error')))
.mockReturnValueOnce(of(mockProducts));
await setup();
// Verifica messaggio di errore
expect(screen.getByRole('alert')).toBeTruthy();
// Clicca su Riprova
fireEvent.click(screen.getByText('Riprova'));
await waitFor(() => {
expect(screen.getByText('Laptop Pro')).toBeTruthy();
});
});
it('deve mostrare messaggio quando non ci sono risultati', async () => {
await setup();
const searchInput = screen.getByLabelText('Cerca prodotti');
fireEvent.input(searchInput, { target: { value: 'xyznonexistent' } });
await waitFor(() => {
expect(screen.getByText('Nessun prodotto trovato')).toBeTruthy();
});
});
});
Playwright와 함께하는 E2E 테스트
테스트 엔드투엔드 전체 애플리케이션의 작동을 확인하고, 브라우저에서 백엔드로. 극작가 E2E를 위한 현대적인 선택입니다. 감사합니다 속도, 다중 브라우저 지원 및 직관적인 API를 제공합니다.
극작가 구성
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/e2e-results.xml' }]
],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});
사용자 흐름의 E2E 테스트
import { test, expect } from '@playwright/test';
test.describe('Flusso prodotti', () => {
test('utente cerca e aggiunge un prodotto al carrello', async ({ page }) => {
// Naviga alla pagina prodotti
await page.goto('/products');
// Attendi che i prodotti siano caricati
await expect(page.getByRole('list')).toBeVisible();
// Cerca un prodotto
await page.getByLabel('Cerca prodotti').fill('Laptop');
await page.waitForTimeout(300); // debounce
// Verifica i risultati filtrati
const products = page.locator('.product-card');
await expect(products).toHaveCount(1);
await expect(products.first()).toContainText('Laptop Pro');
// Aggiungi al carrello
await page.getByRole('button', { name: 'Aggiungi al carrello' }).click();
// Verifica notifica
await expect(
page.getByText('Prodotto aggiunto al carrello')
).toBeVisible();
// Vai al carrello
await page.getByRole('link', { name: 'Carrello' }).click();
// Verifica che il prodotto sia nel carrello
await expect(page.getByText('Laptop Pro')).toBeVisible();
await expect(page.getByText('999')).toBeVisible();
});
test('utente gestisce prodotto esaurito', async ({ page }) => {
await page.goto('/products');
await expect(page.getByRole('list')).toBeVisible();
// Cerca un prodotto esaurito
await page.getByLabel('Cerca prodotti').fill('TypeScript Handbook');
await page.waitForTimeout(300);
// Verifica che il pulsante sia disabilitato
const addButton = page.getByRole('button', { name: 'Aggiungi al carrello' });
await expect(addButton).toBeDisabled();
// Verifica l'etichetta di stato
await expect(page.getByText('Esaurito')).toBeVisible();
});
test('gestione degli errori di rete', async ({ page }) => {
// Intercetta la richiesta API e simula un errore
await page.route('/api/products', route =>
route.fulfill({ status: 500, body: 'Server Error' })
);
await page.goto('/products');
// Verifica il messaggio di errore
await expect(page.getByRole('alert')).toBeVisible();
// Rimuovi l'intercettazione
await page.unroute('/api/products');
// Clicca su Riprova
await page.getByRole('button', { name: 'Riprova' }).click();
// Verifica che i prodotti siano caricati
await expect(page.getByRole('list')).toBeVisible();
});
});
조롱 전략
테스트 중인 장치를 격리하려면 모의 작업이 필수적입니다. Angular에서는 전략
세 가지 주요 항목이 있습니다: 수동 모의, 자동 스파이 및 HttpTestingController
HTTP 호출의 경우.
모의 매뉴얼 대 스파이
// 1. Mock Manuale: controllo totale
const mockAuthService: Partial<AuthService> = {
isAuthenticated: jest.fn().mockReturnValue(true),
getCurrentUser: jest.fn().mockReturnValue({
id: 'user-1',
name: 'Test User',
role: 'admin'
}),
logout: jest.fn()
};
// 2. Spy Automatico con jest.spyOn
const authService = TestBed.inject(AuthService);
jest.spyOn(authService, 'isAuthenticated').mockReturnValue(true);
jest.spyOn(authService, 'getCurrentUser').mockReturnValue(mockUser);
// 3. Auto-mock completo con jest.mock
jest.mock('./auth.service');
// Tutti i metodi diventano jest.fn() automaticamente
// 4. Factory con createSpyObj (stile Jasmine)
function createMockService<T>(methods: (keyof T)[]): jest.Mocked<T> {
const mock: any = {};
methods.forEach(method => {
mock[method] = jest.fn();
});
return mock as jest.Mocked<T>;
}
const mockService = createMockService<ProductService>([
'getProducts', 'getProductById', 'createProduct'
]);
HttpTestingController를 사용하여 HttpClient 모의
describe('ApiService - HTTP Testing avanzato', () => {
let service: ApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApiService,
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting()
]
});
service = TestBed.inject(ApiService);
httpMock = TestBed.inject(HttpTestingController);
});
it('deve aggiungere il token di autenticazione', () => {
service.getProducts().subscribe();
const req = httpMock.expectOne('/api/products');
expect(req.request.headers.get('Authorization'))
.toBe('Bearer test-token');
});
it('deve gestire richieste concorrenti', () => {
// Simula due richieste simultanee
service.getProducts().subscribe();
service.getCategories().subscribe();
const requests = httpMock.match(r => r.url.startsWith('/api/'));
expect(requests.length).toBe(2);
requests[0].flush(mockProducts);
requests[1].flush(mockCategories);
});
it('deve gestire il retry su errore', () => {
service.getProductsWithRetry().subscribe({
next: (products) => {
expect(products).toEqual(mockProducts);
}
});
// Prima richiesta: fallisce
const req1 = httpMock.expectOne('/api/products');
req1.flush('Error', { status: 503, statusText: 'Service Unavailable' });
// Seconda richiesta (retry): successo
const req2 = httpMock.expectOne('/api/products');
req2.flush(mockProducts);
});
});
비동기 코드 테스트
Angular에서 비동기 코드를 테스트하려면 주의가 필요합니다: Observable, Promise, 타이머
전자 fakeAsync 그것들은 주요 도구입니다.
import { fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing';
describe('Test asincroni', () => {
// fakeAsync: controlla il tempo manualmente
it('deve debounce la ricerca', fakeAsync(() => {
const service = TestBed.inject(SearchService);
const spy = jest.spyOn(service, 'search');
component.onSearchInput('ang');
tick(200); // Non ancora...
expect(spy).not.toHaveBeenCalled();
component.onSearchInput('angular');
tick(300); // Debounce di 300ms scaduto
expect(spy).toHaveBeenCalledWith('angular');
expect(spy).toHaveBeenCalledTimes(1);
}));
// Test con Observable che emettono nel tempo
it('deve gestire Observable con delay', fakeAsync(() => {
const results: string[] = [];
of('a', 'b', 'c').pipe(
concatMap(val => of(val).pipe(delay(100)))
).subscribe(val => results.push(val));
tick(100);
expect(results).toEqual(['a']);
tick(100);
expect(results).toEqual(['a', 'b']);
tick(100);
expect(results).toEqual(['a', 'b', 'c']);
}));
// waitForAsync: per test con Promise reali
it('deve risolvere dati dal servizio', waitForAsync(async () => {
const service = TestBed.inject(DataService);
const data = await firstValueFrom(service.getData());
expect(data).toBeDefined();
expect(data.length).toBeGreaterThan(0);
}));
// flush: risolve tutti i timer pendenti
it('deve completare tutte le animazioni', fakeAsync(() => {
component.startAnimation();
flush(); // Risolve tutti i setTimeout/setInterval
expect(component.animationComplete).toBe(true);
}));
});
CI/CD 통합
테스트는 CI/CD 파이프라인에서 자동으로 실행되어 다음을 보장해야 합니다. 각 커밋 및 풀 요청에는 회귀가 발생하지 않습니다.
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:ci
env:
CI: true
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run e2e
env:
CI: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
보장 전략
권장되는 적용 범위 측정항목
| 코드 유형 | 적용대상 | 동기 부여 |
|---|---|---|
| 서비스(비즈니스 로직) | 90%+ | 쉽게 테스트할 수 있는 중요한 논리 |
| 구성요소(상호작용) | 80%+ | 사용자 행동, 시각적 상태 |
| 파이프와 지시어 | 95%+ | 순수 기능, 소수의 엣지 케이스 |
| 가드와 인터셉터 | 90%+ | 보안, 중요 경로 |
| 템플릿 및 유틸리티 | 85%+ | 전체적으로 재사용되는 로직 |
| 구성 및 라우팅 | 필요하지 않음 | E2E에서 다루는 선언적 |
보장이 전부는 아니다
100% 적용이 코드에 버그가 없다는 의미는 아닙니다. 확인하는 테스트 오류를 발생시키지 않는 함수가 100%에 도달했지만 정확성을 확인하지 않는다는 것뿐입니다. 결과의. 집중하다 테스트의 품질, 뿐만 아니라 적용 비율.
결론
Angular의 효과적인 테스트 전략은 여러 수준의 테스트를 결합합니다. 그 자체의 목적으로. 기억해야 할 핵심 사항은 다음과 같습니다.
- 따라가다 트로피 모델: 통합 테스트에 더 투자하세요.
- 미국 농담 서비스와 로직의 빠르고 강력한 단위 테스트를 위해
- 미국 각도 테스트 라이브러리 실제 사용자처럼 구성 요소를 테스트하려면
- 미국 극작가 중요한 E2E 흐름 및 브라우저 간 테스트를 위해
- 모든 것을 자동화하세요. CI/CD 파이프라인
- 적용 범위뿐만 아니라 테스트 품질에 집중
기억해야 할 핵심 사항
- 트로피 모델: 통합 테스트에 더 많이 투자하세요
- 테스트 라이브러리: 구현이 아닌 동작을 테스트하세요
- HttpTestingController: HTTP 호출 테스트를 위한 필수 도구
- 가짜비동기화: 비동기 코드 테스트의 타이밍 확인
- 극작가: 빠른 병렬 다중 브라우저 E2E 테스트
- CI/CD: 각 커밋은 테스트 스위트를 거쳐야 합니다.







