テスト アーキテクチャ: Angular アプリの戦略
堅実なテスト戦略は、保守可能な Angular アプリケーションの基礎です。 信頼できるテストがなければ、すべてのリファクタリングはリスクとなり、すべてのデプロイメントはギャンブルとなります。 ただし、テストとは、ランダムなテストを作成することを意味するものではありません。テストアーキテクチャ アプリケーションの各レベルに明確な戦略があり、明確に定義されています。
この記事では、テスト ピラミッド、トロフィー モデルを調査し、その方法を見ていきます。 Angular を使用してサービス、コンポーネント、フローをエンドツーエンドでテストする 冗談, Angular テスト ライブラリ e 劇作家.
何を学ぶか
- ケント C. ドッズのテスト ピラミッドとトロフィー モデル
- Jest を使用した Angular サービスの単体テスト
- Angular Testing Library を使用したコンポーネントのテスト
- Playwright との E2E テスト
- HttpClient とサービスのモッキング戦略
- 非同期コードのテスト: Observable と Promise
- CI/CD パイプラインへのテストの統合
テストピラミッド vs トロフィーモデル
La テストピラミッド マイク・コーンによって提案された古典は、 ピラミッド分布: ベースでは単体テストが多く、中央では統合テストが少なく、 その上にいくつかの E2E テストがあります。より高速で、より分離されたテストが大部分を占めるべきだという考えです。
Il トロフィーモデル Kent C. Dodds 著は、このビジョンを部分的に覆します。 ~にさらに投資することを提案する 統合テスト、彼らが提供する 作成コストとアプリケーションの動作の信頼性との間の最良の関係。
2 つのモデルの比較
| レベル | ピラミッド (コーン) | トロフィー (ドッズ) | スピード | 信頼 |
|---|---|---|---|---|
| 静的解析 | 含まれていない | トロフィーベース | インスタント | 低い |
| 単体テスト | テストの 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 テスト ライブラリを使用したコンポーネント テスト
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 における戦略
主なものは 3 つあります: 手動モック、自動スパイ、 HttpTestingController
HTTP 呼び出しの場合。
模擬マニュアル vs スパイ
// 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、タイマー
e 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 の効果的なテスト戦略では、複数のレベルのテストを組み合わせます。 独自の目的を持って。覚えておくべき重要なポイントは次のとおりです。
- フォローしてください トロフィーモデル: 統合テストへの投資を増やす
- アメリカ合衆国 冗談 サービスとロジックの高速かつ強力な単体テスト用
- アメリカ合衆国 Angular テスト ライブラリ 実際のユーザーのようにコンポーネントをテストする
- アメリカ合衆国 劇作家 重要な E2E フローとクロスブラウザー テスト用
- すべてを自動化する CI/CD パイプライン
- カバレッジだけでなくテストの品質にも重点を置く
覚えておくべき重要なポイント
- トロフィーモデル: 統合テストへの投資を増やす
- テストライブラリ: 実装ではなく動作をテストする
- HttpTestingController: HTTP 呼び出しをテストするための必須ツール
- 偽非同期: 非同期コードテストでタイミングを確認する
- 劇作家: 高速、並列、マルチブラウザ E2E テスト
- CI/CD: 各コミットはテストスイートを通過する必要があります







