🧪 Angular 測試與部署
📌 Jasmine + Karma 單元測試
Angular CLI 內建 Jasmine(測試框架)和 Karma(測試執行器)。
// calculator.service.ts
@Injectable({ providedIn: 'root' })
export class CalculatorService {
add(a: number, b: number): number { return a + b; }
divide(a: number, b: number): number {
if (b === 0) throw new Error('不能除以零');
return a / b;
}
}
// calculator.service.spec.ts — 測試檔案(.spec.ts)
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({}); // 設定測試模組
service = TestBed.inject(CalculatorService); // 取得服務實例
});
it('應該被建立', () => {
expect(service).toBeTruthy();
});
it('1 + 2 應該等於 3', () => {
expect(service.add(1, 2)).toBe(3);
});
it('10 / 2 應該等於 5', () => {
expect(service.divide(10, 2)).toBe(5);
});
it('除以零應該拋出錯誤', () => {
expect(() => service.divide(10, 0)).toThrowError('不能除以零');
});
});
# 執行所有測試
ng test
# 執行測試並產生覆蓋率報告
ng test --code-coverage
📌 TestBed 元件測試
// greeting.component.ts
@Component({
selector: 'app-greeting',
template: `
<h1>{{ greeting }}</h1>
<button (click)="changeName('Angular')">打招呼</button>
`
})
export class GreetingComponent {
@Input() name = 'World';
greeting = '';
ngOnInit() { this.greeting = `Hello, ${this.name}!`; }
changeName(newName: string) {
this.name = newName;
this.greeting = `Hello, ${this.name}!`;
}
}
// greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent] // Standalone component
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // 觸發初始化(ngOnInit)
});
it('應該顯示預設招呼語', () => {
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain('Hello, World!');
});
it('應該根據 @Input 顯示不同名字', () => {
component.name = 'Angular';
component.ngOnInit();
fixture.detectChanges(); // 觸發變更偵測
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain('Hello, Angular!');
});
it('按下按鈕後應該改變招呼語', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
const h1 = fixture.nativeElement.querySelector('h1');
expect(h1.textContent).toContain('Hello, Angular!');
});
});
📌 HttpClientTestingModule Mock API
// data.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DataService } from './data.service';
describe('DataService', () => {
let service: DataService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // 使用測試用的 HttpClient
providers: [DataService]
});
service = TestBed.inject(DataService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 確認沒有未處理的請求
});
it('GET /products 應回傳產品列表', () => {
const mockProducts = [
{ id: 1, name: '筆電', price: 30000 },
{ id: 2, name: '手機', price: 15000 }
];
service.getProducts().subscribe(products => {
expect(products.length).toBe(2);
expect(products[0].name).toBe('筆電');
});
// 攔截 HTTP 請求,回傳假資料
const req = httpMock.expectOne('https://api.example.com/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts); // 回傳假資料
});
it('應處理 HTTP 錯誤', () => {
service.getProducts().subscribe({
next: () => fail('應該要失敗'),
error: (err) => {
expect(err.status).toBe(500);
}
});
const req = httpMock.expectOne('https://api.example.com/products');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
});
});
📌 E2E 測試
// 使用 Cypress(目前推薦)
// cypress/e2e/app.cy.ts
describe('首頁', () => {
beforeEach(() => {
cy.visit('/');
});
it('應該顯示歡迎訊息', () => {
cy.get('h1').should('contain', '歡迎使用');
});
it('導航到產品頁', () => {
cy.get('a[routerLink="/products"]').click();
cy.url().should('include', '/products');
cy.get('.product-card').should('have.length.at.least', 1);
});
it('搜尋功能', () => {
cy.get('input[placeholder="搜尋..."]').type('Angular');
cy.get('.search-results').should('be.visible');
cy.get('.search-result-item').should('have.length.at.least', 1);
});
});
📌 Angular CLI 打包優化
# 生產環境打包(自動開啟 AOT 編譯、Tree Shaking、程式碼壓縮)
ng build --configuration=production
# 分析打包大小
ng build --stats-json
npx webpack-bundle-analyzer dist/my-app/stats.json
angular.json 打包設定
{
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
}
],
"outputHashing": "all",
"optimization": true,
"sourceMap": false
}
}
}
📌 部署到雲端
# 部署到 Firebase Hosting
npm install -g firebase-tools
firebase init hosting
ng build --configuration=production
firebase deploy
# 部署到 Azure Static Web Apps
# 在 GitHub Actions 中自動部署
ng build --configuration=production --output-path=dist
# 部署到 Nginx
# 將 dist/ 複製到 Nginx 的靜態資源目錄
# 重要:SPA 需要設定 URL 重寫
# Nginx 設定(SPA URL 重寫)
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html; # 所有路由都導向 index.html
}
}
📌 小結
.spec.ts檔案是測試檔案,Angular CLI 內建 Jasmine + Karma- TestBed 是 Angular 的測試工具,用來建立元件和服務的測試環境
- HttpClientTestingModule 可以 Mock HTTP 請求,不需要真正的後端
- E2E 測試推薦使用 Cypress
ng build --configuration=production自動執行 AOT、Tree Shaking、壓縮- SPA 部署需要設定 URL 重寫(所有路由指向 index.html)