โหมดมืด
บทที่ 8 — Testing
หลังจบบท คุณจะ:
- เขียน unit test ของ component, service, pipe
- ใช้ TestBed + Jasmine
- Mock HTTP + dependency
- เขียน E2E ด้วย Cypress / Playwright
1. Test Stack (ชุดเครื่องมือทดสอบ)
การเทสต์ Angular ประกอบด้วย 2 ส่วนหลัก: (1) ภาษา/framework ที่ใช้เขียนเทสต์ และ (2) โปรแกรมที่รันเทสต์ (runner) — ทั้งสองส่วนทำงานต่างกัน เลือกได้อิสระ
💡 บทนี้สอน Jasmine syntax (describe/it/expect) — assertion พื้นฐานใช้ได้กับทุก runner ก่อนเข้าตัวอย่างจริง รู้แค่ 2 อย่างก็พอ:
- Jasmine (เฟรมเวิร์ก testing ที่บทนี้ใช้สอน) = วิธีเขียนเทสต์ — assertion พื้นฐาน (
toBe,toEqual,toContain) ใช้ได้ทุก runner- Runner = โปรแกรมที่รันเทสต์ — ตรวจสอบสถานะ/ตัวเลือกล่าสุดในเอกสารทางการของ Angular CLI ก่อนใช้ (Karma เลิกแนะนำแล้ว ทีมงานหันไปทาง Vitest/Web Test Runner) ·
ng newจะถามให้เลือก runner ตอน setup · รายละเอียดดูตอน setup โปรเจกต์จริง(เฟรมเวิร์ก/runner ตัวอื่นที่อาจเจอในโปรเจกต์เก่า: Mocha, Jest — สำเนียงการเขียนคล้าย Jasmine)
⚠️ ข้อยกเว้น:
jasmine.createSpyObj,jasmine.SpyObj,jasmine.any()ที่เห็นในบทนี้เป็น API ของ Jasmine โดยเฉพาะ — ถ้าย้ายไป Vitest ใช้vi.fn()/vi.mocked()แทน, ถ้า Jest ใช้jest.fn()(ตัวอย่างเทสต์ในบทนี้ทั้งหมดใช้ syntax Jasmine เพื่อความชัดเจนในการสอน — ถ้าโปรเจกต์จริงใช้ Vitest ให้แทนที่ตาม mapping นี้)
โครงเทสต์ที่จะเห็น:
typescript
describe('UserService', () => { // กลุ่ม
it('should add user', () => { // เคสหนึ่ง
expect(true).toBe(true); // assertion
});
});2. Run Tests
ℹ️ ไฟล์เทสต์อยู่ที่ไหน? เมื่อสร้าง component/service ด้วย
ng generate componentหรือng generate serviceAngular CLI จะสร้างไฟล์*.spec.tsควบคู่ให้อัตโนมัติ เช่นuser.service.spec.ts— ไม่ต้องสร้างเองจากศูนย์
เมื่อเขียนเทสต์เสร็จ เราสั่งรันด้วยคำสั่ง ng test ผ่าน Angular CLI โดยมีตัวเลือก (flag) ให้เลือกได้ว่าจะรันแบบเฝ้าดูการเปลี่ยนแปลง (watch), รันครั้งเดียวสำหรับ CI, หรือรันพร้อมวัดความครอบคลุม (coverage):
bash
ng test # unit tests (runner ตามที่โปรเจกต์ตั้ง — Vitest/Web Test Runner สำหรับโปรเจกต์ใหม่)
ng test --watch=false # CI mode (run once)
ng test --code-coverage # with coverage
ng e2e # e2e tests (need setup)ℹ️ Runner: โปรเจกต์ Angular เก่าจะเห็น Karma ใน comment — Angular team deprecated Karma แล้วปี 2023 · โปรเจกต์ใหม่ (Angular 18+) ตั้ง Vitest หรือ Web Test Runner เป็น default · Jasmine syntax (describe/it/expect) ยังใช้ได้เหมือนเดิมทุก runner
3. Service Test (Easiest)
เริ่มจากสิ่งที่ง่ายที่สุดก่อน — Service เป็นแค่คลาส TypeScript ธรรมดาที่ไม่มี template หรือ DOM ให้ยุ่งยาก เราจึงทดสอบมันได้ตรง ๆ ด้วยการ TestBed.inject() เพื่อขอ instance ออกมา แล้วเรียกเมธอดทดสอบเหมือนเรียกใช้งานจริง:
📖 TestBed คือ Angular testing utility (มาพร้อม
@angular/core/testingไม่ต้องติดตั้งแยก) ทำหน้าที่เป็น "Angular app จิ๋ว" สำหรับใช้ในเทสต์ — ช่วยสร้าง component/service พร้อม dependency injection เหมือนในแอปจริง
typescript
// user.service.spec.ts
// สมมติว่า UserService มี method: add(user), all(): User[]
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should add user', () => {
const user = { id: 1, name: 'Anna' };
service.add(user);
expect(service.all()).toContain(user);
});
});bash
ng test # → runs all *.spec.ts4. Jasmine Basics
Jasmine คือ "ภาษา" ที่เราใช้เขียนเทสต์ ประกอบด้วยคำสั่งหลักไม่กี่ตัว: describe จัดกลุ่มเทสต์, it คือเทสต์หนึ่งกรณี, expect(...).toBe(...) คือการตรวจคำตอบ และ beforeEach ใช้เตรียมข้อมูลก่อนแต่ละเทสต์ ส่วนนี้รวมคำสั่งที่ใช้บ่อยทั้งหมดไว้ให้ดูเป็นพจนานุกรม:
typescript
describe('UserService', () => {
// ทำงานก่อนแต่ละ test
beforeEach(() => { ... });
afterEach(() => { ... });
// ทำงานครั้งเดียวก่อนทุก test
beforeAll(() => { ... });
afterAll(() => { ... });
it('should do something', () => {
expect(actual).toBe(expected); // ===
expect(actual).toEqual(obj); // deep equal
expect(actual).toBeTruthy();
expect(actual).toBeFalsy();
expect(actual).toBeNull();
expect(actual).toBeUndefined();
expect(actual).toBeDefined();
expect(actual).toContain(item);
expect(arr.length).toBeGreaterThan(0);
expect(() => fn()).toThrow();
expect(() => fn()).toThrowError('msg');
});
// ข้าม test นี้ (ไม่รัน ไม่นับว่า fail)
xit('skipped test', () => {});
// รันแค่ test นี้เท่านั้น ⚠️ อย่าลืมลบก่อน commit เพราะ test อื่นจะไม่รัน
fit('focused test', () => {});
});5. Spy (Mock Function)
เวลาเทสต์ เรามักไม่อยากเรียกของจริง (เช่นยิง API จริง) เพราะช้าและควบคุมผลลัพธ์ไม่ได้ "Spy" คือฟังก์ชันปลอมที่เราสร้างขึ้นมาแทนของจริง — มันบันทึกว่าถูกเรียกกี่ครั้ง ด้วยอาร์กิวเมนต์อะไร และเราสั่งให้มันคืนค่าอะไรก็ได้ เปรียบเหมือน "นักแสดงแทน" ที่ทำตามบทเป๊ะ ๆ
มี 2 แบบหลัก:
spyOn(obj, 'method')— ใส่ spy บน method ที่มีอยู่แล้วในออบเจกต์จริง (แทนที่ชั่วคราว)jasmine.createSpyObj('ชื่อ', ['method1', 'method2', ...])— สร้าง object ใหม่ทั้งก้อนที่ทุก method เป็น spy ตั้งแต่ต้น (ใช้ตอน mock service ทั้งตัว)
typescript
import { of } from 'rxjs'; // of() = สร้าง Observable จากค่าคงที่ ใช้ใน test เพื่อ mock ค่าที่ service จะ return
it('should call api', () => {
const spy = spyOn(service, 'fetchUser').and.returnValue(of(mockUser));
component.loadUser(1);
expect(spy).toHaveBeenCalledWith(1);
expect(spy).toHaveBeenCalledTimes(1);
});
// Mock service entirely
const mockUserService = jasmine.createSpyObj('UserService', ['get', 'list', 'create']);
mockUserService.get.and.returnValue(of(mockUser));
TestBed.configureTestingModule({
providers: [
{ provide: UserService, useValue: mockUserService }
]
});6. Component Test — Basic
Component ทดสอบยากกว่า Service เพราะมันมี template และต้องผ่านวงจร change detection (CD — กลไกที่ Angular ใช้อัปเดต DOM ตามข้อมูล ดูบทที่ 7) ก่อนข้อมูลจะแสดงบนหน้าจอ
⚠️ สำคัญ: ใน environment เทสต์ Angular จะ ไม่อัปเดต DOM อัตโนมัติ — ต้องเรียก
fixture.detectChanges()เองทุกครั้งที่ต้องการให้ template แสดงค่าใหม่ ถ้าลืมเรียก ค่าบนหน้าจอจะยังเป็นค่าเดิม
หัวใจอยู่ที่ TestBed.createComponent() ที่สร้าง "fixture" (กล่องครอบ component พร้อม DOM) ให้เรา จากนั้นเรียก fixture.detectChanges() เพื่อสั่งให้ render แล้วค่อยตรวจผลที่ nativeElement · สำหรับ standalone component (ค่าเริ่มต้นใน Angular 20) ใส่ไว้ใน imports: [...] ไม่ใช่ declarations (declarations ใช้กับ NgModule แบบเก่าเท่านั้น):
typescript
// counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent] // standalone — ใส่ใน imports เสมอ (ไม่ใช่ declarations)
}).compileComponents(); // ℹ️ จำเป็นแค่ตอนใช้ templateUrl/styleUrl — inline template ข้ามได้
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // สั่งให้ Angular อัปเดต DOM ครั้งแรก (trigger initial CD)
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display initial count', () => {
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Count: 0');
});
it('should increment', () => {
component.increment();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Count: 1');
});
});7. Interact with Template
เทสต์ที่ดีควร "เลียนแบบผู้ใช้จริง" คือกดปุ่ม กรอกฟอร์ม แล้วดูว่าหน้าจอเปลี่ยนถูกไหม ส่วนนี้สอนวิธีค้นหา element ใน template ด้วย By.css() แล้วสั่งให้เกิด event (เช่น click) จากนั้น detectChanges() เพื่ออัปเดตหน้าจอ แล้วตรวจผลลัพธ์:
📖 ทำไมใช้
By.css()+DebugElementแทนquerySelectorธรรมดา?By.css()คู่กับfixture.debugElement.query()คือ Angular wrapper ที่รู้จัก template และ directive ของ Angular โดยตรง ส่วนquerySelectorก็ใช้ได้เช่นกันแต่จะได้HTMLElementธรรมดาที่ไม่มีข้อมูล Angular (เช่นไม่สามารถtriggerEventHandlerได้)
typescript
// counter.component.ts
@Component({
standalone: true,
selector: 'app-counter',
template: `
<div data-testid="count">Count: {{ count() }}</div>
<button (click)="increment()" data-testid="inc">+</button>
<button (click)="decrement()" data-testid="dec">-</button>
`
})
export class CounterComponent {
count = signal(0);
increment() { this.count.update(c => c + 1); }
decrement() { this.count.update(c => c - 1); }
}typescript
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
it('should increment on button click', () => {
// Find element
const incBtn: DebugElement = fixture.debugElement.query(
By.css('[data-testid="inc"]')
);
// Click
incBtn.triggerEventHandler('click', null);
fixture.detectChanges();
// Assert
const countEl = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(countEl.textContent).toContain('Count: 1');
});Helper: Get by Test ID
📖 data-testid คือ HTML attribute ที่เราเพิ่มเองใน template เพื่อให้ test ค้นหา element ได้ง่าย เช่น
<button data-testid="submit">— ค้นหาด้วยgetByTestId("submit")ทำให้ test ไม่พังเมื่อ class หรือข้อความปุ่มเปลี่ยน
typescript
function getByTestId(testId: string): HTMLElement | null {
return fixture.nativeElement.querySelector(`[data-testid="${testId}"]`);
}
function clickByTestId(testId: string) {
const el = getByTestId(testId);
el?.click();
fixture.detectChanges();
}8. Component with Input
ถ้า component รับค่าเข้ามาทาง input() เราต้องป้อนค่าให้มันก่อนทดสอบ วิธีมาตรฐานคือใช้ fixture.componentRef.setInput(ชื่อ, ค่า) ซึ่งจำลองการที่ component แม่ส่งค่าลงมา แล้วค่อย detectChanges() เพื่อให้ template อัปเดตตามค่าใหม่:
typescript
@Component({
selector: 'app-greeting',
template: `<p>Hello, {{ name() }}</p>`
})
export class GreetingComponent {
name = input.required<string>();
}typescript
describe('GreetingComponent', () => {
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent]
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
});
it('should greet user', () => {
// Set input via fixture.componentRef
fixture.componentRef.setInput('name', 'Anna');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Hello, Anna');
});
});⚠️ สำหรับ
input.required: ต้องเรียกsetInputก่อนdetectChanges()เสมอ มิฉะนั้น Angular จะ throw error เนื่องจากค่า required ยังไม่ได้ถูกตั้ง
→ fixture.componentRef.setInput(name, value) = standard way to set signal input
9. Component with Output
กลับกัน ถ้า component "ส่งค่าออก" ทาง output() เมื่อเกิดเหตุการณ์ เราทดสอบโดย subscribe รอฟังค่าที่มันจะ emit ออกมา จากนั้นกระตุ้นเหตุการณ์ (เช่น click ปุ่ม) แล้วตรวจว่าค่าที่รับได้ตรงกับที่คาดไว้หรือไม่:
📖
output()กับ.subscribe():output()ใน Angular คืนOutputRef<T>ซึ่งมี.subscribe()method ให้ใช้ในเทสต์โดยตรง — นี่คือ Angular-specific API ไม่ใช่ RxJS Observable (ไม่สามารถ pipe RxJS operator ได้) และไม่ต้องใช้EventEmitterแบบเก่าแล้ว
typescript
@Component({
template: `<button (click)="onClick()">Click</button>`
})
export class ButtonComponent {
clicked = output<string>();
onClick() {
this.clicked.emit('hello');
}
}typescript
it('should emit clicked event', () => {
let emitted: string | undefined;
fixture.componentInstance.clicked.subscribe(value => {
emitted = value;
});
const btn = fixture.nativeElement.querySelector('button');
btn.click();
expect(emitted).toBe('hello');
});10. Test with Dependency
component ส่วนใหญ่พึ่งพา service (เช่นดึงข้อมูลผู้ใช้) ถ้าเราใช้ service จริงเทสต์จะช้าและเปราะ วิธีแก้คือสร้าง "service ปลอม" ด้วย jasmine.createSpyObj แล้วบอก Angular ผ่าน providers ให้ใช้ตัวปลอมแทนตอนเทสต์ — เราจึงควบคุมได้ว่าข้อมูลที่ component จะได้รับคืออะไร:
typescript
@Component({...})
export class UserListComponent {
private userService = inject(UserService);
users = signal<User[]>([]);
ngOnInit() {
this.userService.getAll().subscribe(users => this.users.set(users));
}
}typescript
import { of } from 'rxjs';
describe('UserListComponent', () => {
let mockUserService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
mockUserService = jasmine.createSpyObj('UserService', ['getAll']);
await TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }
]
}).compileComponents();
});
it('should load users on init', () => {
const mockUsers = [{ id: 1, name: 'Anna' }, { id: 2, name: 'Ben' }];
mockUserService.getAll.and.returnValue(of(mockUsers));
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges(); // triggers ngOnInit
expect(mockUserService.getAll).toHaveBeenCalled();
expect(fixture.componentInstance.users()).toEqual(mockUsers);
});
});11. Test HTTP
เวลาเทสต์โค้ดที่ยิง HTTP เราไม่อยากเรียก server จริง Angular มีเครื่องมือ HttpTestingController ที่ "ดักจับ" request ทุกตัวไว้ก่อนถึง network จริง เราจึงตรวจได้ว่ายิงไป URL ไหน method อะไร แล้วสั่งให้มัน "ตอบกลับ" ด้วยข้อมูลปลอมตามที่ต้องการ — ทดสอบทั้งกรณีสำเร็จและกรณี error ได้:
📖
req.flush(data)= "ตอบกลับ" request นั้นด้วยข้อมูลที่กำหนด เหมือนการที่ server ส่งข้อมูลกลับมา แต่เป็นข้อมูลปลอมที่เราควบคุมเองในเทสต์ (flush มาจากแนวคิด "flush out" ข้อมูลออกมาตอบ)
typescript
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // no outstanding requests
});
it('should fetch users', () => {
const mock = [{ id: 1, name: 'Anna' }];
service.getAll().subscribe(users => {
expect(users).toEqual(mock);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mock); // respond
});
it('should send POST', () => {
const newUser = { name: 'Anna' };
const created = { id: 1, ...newUser };
service.create(newUser).subscribe(user => {
expect(user).toEqual(created);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(created, { status: 201, statusText: 'Created' });
});
it('should handle 404', () => {
service.get(999).subscribe({
error: (err) => {
expect(err.status).toBe(404);
}
});
httpMock.expectOne('/api/users/999').flush('Not Found', {
status: 404,
statusText: 'Not Found'
});
});
});12. Test Signal
ข้อดีของ signal คือทดสอบง่ายมาก เพราะการอ่านค่าทำได้แค่ "เรียกมันเหมือนฟังก์ชัน" (service.total()) เราจึงเปลี่ยน state ผ่านเมธอด แล้วตรวจค่าของ signal/computed ได้ตรง ๆ โดยไม่ต้องยุ่งกับ async หรือ subscribe:
typescript
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly total = computed(() =>
this._items().reduce((s, i) => s + i.price * i.qty, 0)
);
add(item: CartItem) {
this._items.update(items => [...items, item]);
}
clear() {
this._items.set([]);
}
}typescript
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
it('should start empty', () => {
expect(service.items()).toEqual([]);
expect(service.total()).toBe(0);
});
it('should add item', () => {
service.add({ id: 1, name: 'A', price: 10, qty: 2 });
expect(service.items().length).toBe(1);
expect(service.total()).toBe(20);
});
it('should clear', () => {
service.add({ id: 1, name: 'A', price: 10, qty: 1 });
service.clear();
expect(service.items()).toEqual([]);
});
});→ ทดสอบ signal ได้ง่าย แค่เรียกเหมือนฟังก์ชัน: service.total()
13. Test Pipe
Pipe คือ function ที่ใช้แปลงข้อมูลก่อนแสดงใน template เช่น {{ price | currency }} — ถ้ายังไม่รู้จัก Pipe ให้อ่านบทที่ 3 ก่อน
Pipe เป็นสิ่งที่ทดสอบง่ายที่สุดอย่างหนึ่ง เพราะมันคือฟังก์ชันบริสุทธิ์ (pure function) — รับค่าเข้า คืนค่าออก ไม่มี state เราจึงสร้าง instance ขึ้นมาตรง ๆ ด้วย new แล้วเรียก transform() ทดสอบได้เลย ไม่ต้องใช้ TestBed:
typescript
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit: number): string {
return value.length <= limit ? value : value.slice(0, limit) + '...';
}
}typescript
describe('TruncatePipe', () => {
const pipe = new TruncatePipe();
it('should return unchanged when short', () => {
expect(pipe.transform('hello', 10)).toBe('hello');
});
it('should truncate long', () => {
expect(pipe.transform('hello world', 5)).toBe('hello...');
});
});→ Pipe คือ pure function (รับเข้า คืนออก ไม่มี side effect) — ทดสอบง่ายที่สุด
📖 ข้อจำกัดของ
new Pipe(): pattern นี้ใช้ได้เฉพาะ Pipe ที่ไม่มี injected dependency — ถ้า Pipe ใช้inject()ดึง service จะต้องใช้ TestBed แทน
14. Test Directive
Directive คือ class ที่เพิ่มพฤติกรรมให้ HTML element เช่น เปลี่ยนสีเมื่อ hover — ถ้ายังไม่รู้จัก Directive ให้อ่านบทที่ 4 ก่อน
Directive ทำงานโดยเกาะกับ element ใน DOM ดังนั้นเราจะทดสอบมันผ่าน "host component" — สร้าง component ตัวเล็ก ๆ ขึ้นมาที่ใช้ directive นั้น แล้วทดสอบว่าเมื่อเกิดเหตุการณ์ (เช่นเอาเมาส์ชี้) directive เปลี่ยนแปลง element ถูกต้องหรือไม่:
typescript
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
constructor(private el: ElementRef) {}
@HostListener('mouseenter') onEnter() {
this.el.nativeElement.style.background = 'yellow';
}
@HostListener('mouseleave') onLeave() {
this.el.nativeElement.style.background = null;
}
}typescript
@Component({
standalone: true,
imports: [HighlightDirective],
template: `<p appHighlight>Hover me</p>`
})
class TestHost { }
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestHost>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHost]
}).compileComponents();
fixture = TestBed.createComponent(TestHost);
fixture.detectChanges();
});
it('should highlight on mouseenter', () => {
const p = fixture.nativeElement.querySelector('p');
p.dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(p.style.background).toBe('yellow');
});
});15. Test Form
ฟอร์มมีตรรกะที่ควรทดสอบเยอะ: ค่าเริ่มต้นถูกไหม, validation ทำงานไหม, ปุ่ม submit ปิดเมื่อข้อมูลไม่ครบไหม เราตั้งค่าฟอร์มผ่าน form.setValue() แล้วตรวจ form.valid/form.invalid และ error ของแต่ละ control ได้โดยตรง รวมถึงเช็คสภาพปุ่มบนหน้าจอด้วย:
typescript
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" data-testid="email">
<input formControlName="name" data-testid="name">
<button type="submit" [disabled]="form.invalid" data-testid="submit">Submit</button>
</form>
`
})
export class UserFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
name: ['', Validators.required]
});
onSubmit() { /* ... */ }
}typescript
describe('UserFormComponent', () => {
let fixture: ComponentFixture<UserFormComponent>;
let component: UserFormComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserFormComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should be invalid initially', () => {
expect(component.form.invalid).toBeTrue();
});
it('should be valid with correct input', () => {
component.form.setValue({
email: 'anna@x.com',
name: 'Anna'
});
expect(component.form.valid).toBeTrue();
});
it('should disable submit when invalid', () => {
const btn = fixture.nativeElement.querySelector('[data-testid="submit"]');
expect(btn.disabled).toBeTrue();
component.form.setValue({ email: 'a@b.com', name: 'A' });
fixture.detectChanges();
expect(btn.disabled).toBeFalse();
});
it('should validate email format', () => {
const email = component.form.controls.email;
email.setValue('invalid');
expect(email.errors?.['email']).toBeTruthy();
email.setValue('valid@x.com');
expect(email.errors).toBeNull();
});
});16. Test Router
ถ้า component ดึงค่าจาก URL (เช่น id ใน /users/42) เราต้องจำลองการนำทางตอนเทสต์ Angular มี RouterTestingHarness ที่ช่วยให้เรา "สั่งนำทางไปยัง URL" แล้วได้ instance ของ component กลับมาตรวจสอบ ว่ามันอ่านค่าจาก route ได้ถูกต้องไหม:
typescript
// user-detail.component.ts — อ่าน route param ผ่าน input() (Angular 16+)
@Component({ standalone: true, template: `<p>User: {{ id() }}</p>` })
export class UserDetailComponent {
id = input<string>(''); // Angular จะ bind route param :id มาให้อัตโนมัติ
}typescript
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
describe('UserDetailComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'users/:id', component: UserDetailComponent }
])
]
});
});
it('should display user from route', async () => {
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl('/users/42', UserDetailComponent);
expect(instance.id()).toBe('42');
});
});17. Test Signal Effect
effect() เป็นโค้ดที่ทำงานเองเมื่อ signal เปลี่ยน ซึ่งปกติรันแบบ async ทำให้เทสต์ยาก Angular จึงมี TestBed.flushEffects() ที่บังคับให้ effect ที่ค้างอยู่ทำงานทันทีแบบ synchronous เราจึงตรวจผลได้ทีละขั้น แต่ต้องห่อโค้ดไว้ใน runInInjectionContext เพราะ effect() ต้องการ injector (DI context — โค้ดที่ Angular รู้จัก dependency injection)
📖 injection context คืออะไร? คือ "ช่วงจังหวะ" ในโค้ดที่ Angular ยอมให้เรียก
inject()ได้ — ปกติคือใน constructor ของ class ที่ Angular สร้างให้ (component/service) เท่านั้น นอกช่วงนี้ (เช่นใน callback ธรรมดา)inject()จะ throw error เพราะ Angular หาไม่เจอว่ากำลังทำงานอยู่ใน component/service ตัวไหนrunInInjectionContext()คือฟังก์ชันที่ "จำลอง" ช่วงจังหวะนี้ให้ชั่วคราว เพื่อให้เรียกeffect()(ซึ่งต้องการ injection context เหมือนกัน) ได้ตอนเทสต์ (ดู dependency injection เพิ่มเติมได้ในบทที่ 3):
typescript
import { TestBed } from '@angular/core/testing';
import { signal, effect } from '@angular/core';
it('should run effect when signal changes', () => {
TestBed.runInInjectionContext(() => {
const count = signal(0);
const calls: number[] = [];
effect(() => {
calls.push(count());
});
TestBed.flushEffects(); // บังคับให้ effect ที่ค้างทำงานทันที (รอบแรก)
expect(calls).toEqual([0]);
count.set(5);
TestBed.flushEffects(); // signal เปลี่ยน → flush อีกครั้งเพื่อให้ effect รัน
expect(calls).toEqual([0, 5]);
});
});→ runInInjectionContext provides DI context for effect() (which requires injector)
→ TestBed.flushEffects() synchronously runs queued effects in tests (API สำหรับ flush effect ในเทสต์เคยเปลี่ยนแปลงมาหลายเวอร์ชัน — ตรวจสอบสถานะ/ชื่อ API ล่าสุดในเอกสารทางการของ Angular ก่อนใช้)
→ ทางเลือก: fixture.detectChanges() + await fixture.whenStable() ถ้าทดสอบผ่าน component fixture (รัน CD แล้วรอจน async settle)
Test Component Effect
typescript
describe('UserComponent', () => {
it('should log when user changes', () => {
const fixture = TestBed.createComponent(UserComponent);
const component = fixture.componentInstance;
const logSpy = spyOn(console, 'log');
fixture.componentRef.setInput('user', { id: 1, name: 'A' });
fixture.detectChanges(); // also flushes effects
expect(logSpy).toHaveBeenCalledWith('user changed', jasmine.any(Object));
});
});18. Test HTTP Interceptor
Interceptor คือฟังก์ชันที่ดักทุก request เพื่อแก้ไข (เช่นแนบ token) การทดสอบทำได้โดยสร้าง request ปลอม แล้วส่งผ่าน interceptor พร้อม next ปลอมที่คอยตรวจว่า request ถูกแก้ไขถูกต้องไหม (เช่นมี header Authorization แล้วหรือยัง):
typescript
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { of } from 'rxjs';
import { authInterceptor } from './auth.interceptor'; // interceptor ที่ต้องการทดสอบ
describe('authInterceptor', () => {
let mockAuth: jasmine.SpyObj<AuthService>;
beforeEach(() => {
// สร้าง spy object พร้อม method `token` แล้วสั่งให้คืน 'abc123'
mockAuth = jasmine.createSpyObj('AuthService', ['token']);
mockAuth.token.and.returnValue('abc123');
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: mockAuth }
]
});
});
// แบบ Jasmine done callback (ใช้ได้กับ Jasmine runner)
it('should add Bearer token to request', (done) => {
const next: HttpHandlerFn = (req) => {
expect(req.headers.get('Authorization')).toBe('Bearer abc123');
done();
return of({} as any);
};
TestBed.runInInjectionContext(() => {
const req = new HttpRequest('GET', '/api/users');
authInterceptor(req, next).subscribe();
});
});
// แบบ async/await (ใช้ได้กับทั้ง Jasmine และ Vitest)
it('should add Bearer token (async)', async () => {
let capturedReq: HttpRequest<any> | undefined;
const next: HttpHandlerFn = (req) => {
capturedReq = req as HttpRequest<any>;
return of({} as any);
};
await TestBed.runInInjectionContext(() => {
const req = new HttpRequest('GET', '/api/users');
return new Promise<void>(resolve => {
authInterceptor(req, next).subscribe(() => resolve());
});
});
expect(capturedReq?.headers.get('Authorization')).toBe('Bearer abc123');
});
});19. Component Harness (Angular Material)
⚠️ ต้องการ Angular Material (บทที่ 14) — Angular Material คือ UI library ที่ต้องติดตั้งแยก (
ng add @angular/material) ถ้ายังไม่ได้ติดตั้ง ข้ามหัวข้อนี้ได้ก่อน
เวลาเทสต์ component ของ Angular Material (เช่นปุ่ม, input, dropdown) เราไม่ควรไปขุด HTML ข้างในของมัน เพราะโครงสร้างเปลี่ยนได้ทุกเวอร์ชัน "Harness" คือ API ทางการที่ Material ให้มาเพื่อสั่งงาน component (เช่น .setValue(), .click()) โดยไม่ต้องรู้ internal — ทำให้เทสต์ไม่พังเมื่ออัปเกรด:
typescript
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
describe('UserFormComponent', () => {
let fixture: ComponentFixture<UserFormComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserFormComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserFormComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should submit form', async () => {
// Find by selector
const emailInput = await loader.getHarness(
MatInputHarness.with({ selector: '[formControlName="email"]' })
);
await emailInput.setValue('test@example.com');
// Find by label
const passwordInput = await loader.getHarness(
MatInputHarness.with({ placeholder: 'Password' })
);
await passwordInput.setValue('secret');
// Click button by text
const submitBtn = await loader.getHarness(
MatButtonHarness.with({ text: /sign in/i })
);
await submitBtn.click();
// Assert
expect(fixture.componentInstance.submitted()).toBe(true);
});
it('should disable submit when invalid', async () => {
const submitBtn = await loader.getHarness(
MatButtonHarness.with({ text: /sign in/i })
);
expect(await submitBtn.isDisabled()).toBe(true);
});
});→ Harness ทดสอบ Material component แบบไม่ต้อง querySelector internals
→ Test ไม่ break เมื่อ Material upgrade
20. Async Test
โค้ดจริงมักมีงานที่ใช้เวลา (debounce, รอ API, setTimeout) ซึ่งทดสอบยากเพราะต้อง "รอ" Angular มีเครื่องมือช่วยควบคุมเวลา 3 แบบ: fakeAsync + tick() หมุนเวลาเองได้แบบ synchronous, waitForAsync รอจน async เสร็จ, และ async/await ธรรมดา — เลือกใช้ตามสถานการณ์:
📖 debounce (รอให้ผู้ใช้หยุดพิมพ์ก่อนแล้วค่อยทำงาน) = ถ้าพิมพ์ใน search box แต่ละครอก debounce จะรอ X millisecond หลังพิมพ์ครั้งสุดท้าย ก่อนส่ง request — ช่วยลด request ที่ไม่จำเป็น
fakeAsync
📖 fakeAsync — ควบคุมเวลาได้เอง:
tick(300)= เดินเวลาไป 300ms ทันทีโดยไม่ต้องรอจริง ๆ ใช้ทดสอบ debounce/setTimeout ได้แม่นยำ
สมมติ component มี search$ Observable พร้อม debounce:
typescript
// search.component.ts (stub)
export class SearchComponent {
private searchSubject = new Subject<string>();
// $ = convention บอกว่าตัวแปรนี้เป็น Observable
search$ = this.searchSubject.pipe(debounceTime(300));
search(term: string) {
this.searchSubject.next(term);
}
}typescript
import { fakeAsync, tick } from '@angular/core/testing';
it('should debounce', fakeAsync(() => {
let result = '';
component.search$.subscribe(v => result = v);
component.search('a');
tick(100); // เดินเวลาไป 100ms
expect(result).toBe(''); // ยังอยู่ใน debounce ยังไม่ emit
tick(300); // รวม 400ms แล้ว
expect(result).toBe('a'); // ผ่าน debounce แล้ว — emit ค่า
}));waitForAsync
📖 waitForAsync — รอให้งาน async ทั้งหมดเสร็จจริง ๆ ก่อนตรวจผล ใช้กับงานที่พึ่ง Promise จริง (เช่น HTTP)
typescript
it('should fetch user', waitForAsync(() => {
component.loadUser(1);
fixture.whenStable().then(() => {
expect(component.user()).toBeDefined();
});
}));async/await
typescript
it('should fetch user', async () => {
await component.loadUser(1);
expect(component.user()).toBeDefined();
});21. Code Coverage
"Code Coverage" คือตัวเลขบอกว่าเทสต์ของเราครอบคลุมโค้ดกี่เปอร์เซ็นต์ — บรรทัดไหนถูกรันบ้าง บรรทัดไหนยังไม่เคยถูกทดสอบ สั่งวัดได้ด้วย flag --code-coverage แล้วจะได้รายงาน HTML ดูสวยงาม แต่ระวัง: ตัวเลขสูงไม่ได้แปลว่าเทสต์ดี ดูเป้าหมายที่เหมาะสมท้ายหัวข้อ:
bash
ng test --code-coverage --watch=false→ Generate coverage/ directory with HTML report
bash
open coverage/index.html # macOS
start coverage/index.html # Windows
xdg-open coverage/index.html # Linux
# หรือเปิด browser แล้วลากไฟล์ coverage/index.html เข้าไปโดยตรงCoverage Configuration
⚠️ Config ขึ้นกับ runner ที่ใช้: ตัวอย่างด้านล่างเป็น
angular.jsonของ Karma-based setup — ถ้าใช้ Vitest ให้ตั้งค่าในvitest.config.tsแทน (ใช้coverage.exclude)
json
// angular.json (สำหรับ Karma-based setup เท่านั้น)
{
"test": {
"options": {
"codeCoverage": true,
"codeCoverageExclude": [
"src/**/*.module.ts",
"src/main.ts"
]
}
}
}typescript
// vitest.config.ts (สำหรับ Vitest)
export default defineConfig({
test: {
coverage: {
exclude: ['src/**/*.module.ts', 'src/main.ts']
}
}
});Target
text
✅ 80%+ สำหรับ business logic (โค้ดตรรกะทางธุรกิจ)
✅ 60%+ สำหรับ UI component
✅ 100% สำหรับ utility / pure function
❌ อย่าไล่ตาม 100% แบบ blind — เทสต์ที่ดีสำคัญกว่าตัวเลขสูง22. E2E with Playwright
เทสต์ที่ผ่านมาเป็น "unit test" (ทดสอบชิ้นส่วนเล็ก ๆ แยกกัน) ส่วน E2E (End-to-End) คือเปิดเบราว์เซอร์จริงแล้วทำตามที่ผู้ใช้ทำทั้งกระบวนการ เช่น ล็อกอิน → ไปหน้า dashboard → เห็นข้อความต้อนรับ ช้ากว่าแต่สมจริง Playwright คือเครื่องมือยอดนิยมปี 2026 (มี Cypress เป็นทางเลือก):
ขั้นตอน setup Playwright:
bash
# ขั้นที่ 1: ติดตั้ง Playwright และสร้าง config + โครงสร้างโฟลเดอร์ (ทำครั้งเดียว)
npm init playwright@latest
# (จะสร้าง playwright.config.ts, โฟลเดอร์ e2e/, และไฟล์ตัวอย่าง)
# ขั้นที่ 2: โหลด browser (Chrome, Firefox, WebKit)
npx playwright installtypescript
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can login', async ({ page }) => {
await page.goto('http://localhost:4200/login');
await page.fill('[data-testid="email"]', 'anna@example.com');
await page.fill('[data-testid="password"]', 'secret123');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL(/.*\/dashboard/);
await expect(page.locator('[data-testid="welcome"]')).toContainText('Welcome, Anna');
});bash
npx playwright test→ เปิดเบราว์เซอร์จริง — ช้ากว่า unit test แต่สมจริงที่สุด
Cypress Alternative
ℹ️
ng add @cypress/schematicจะติดตั้ง Cypress และตั้งค่าทุกอย่างในคำสั่งเดียว รวมถึงสร้างโฟลเดอร์cypress/และ config ให้ด้วย
bash
ng add @cypress/schematictypescript
// cypress/e2e/login.cy.ts
describe('Login', () => {
it('should login', () => {
cy.visit('/login');
cy.get('[data-testid="email"]').type('anna@example.com');
cy.get('[data-testid="password"]').type('secret123');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, Anna');
});
});bash
npx cypress open # interactive
npx cypress run # CI23. Page Object Pattern (E2E)
เมื่อ E2E test เยอะขึ้น โค้ดที่ระบุ selector ซ้ำ ๆ (เช่น [data-testid="email"]) จะกระจายไปทั่วและแก้ยาก "Page Object" คือแพตเทิร์นที่รวบการกระทำของแต่ละหน้า (เช่น login.page.ts มีเมธอด fillEmail, submit, login) ไว้ที่เดียว ทำให้เทสต์อ่านง่ายและแก้ที่เดียวเมื่อ UI เปลี่ยน:
typescript
// e2e/pages/login.page.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async fillEmail(email: string) {
await this.page.fill('[data-testid="email"]', email);
}
async fillPassword(password: string) {
await this.page.fill('[data-testid="password"]', password);
}
async submit() {
await this.page.click('[data-testid="submit"]');
}
async login(email: string, password: string) {
await this.fillEmail(email);
await this.fillPassword(password);
await this.submit();
}
}typescript
// e2e/login.spec.ts
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('anna@x.com', 'pass');
await expect(page).toHaveURL(/dashboard/);
});→ นำกลับใช้ได้ + แก้ที่เดียวเมื่อ UI เปลี่ยน
24. Visual Regression Test
บางครั้งโค้ดทำงานถูกต้องแต่ "หน้าตาเพี้ยน" (เช่น CSS พัง ปุ่มเลื่อนตำแหน่ง) ซึ่ง assertion ปกติจับไม่ได้ Visual Regression Test แก้ปัญหานี้โดยถ่ายภาพหน้าจอเก็บไว้เป็น "ภาพต้นแบบ" แล้วครั้งต่อไปเทียบว่าต่างจากเดิมไหม ถ้าต่างแสดงว่า UI อาจเปลี่ยนโดยไม่ตั้งใจ:
typescript
// Playwright
test('homepage screenshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});→ จับการเปลี่ยนแปลง UI ที่ไม่ตั้งใจ โดยเทียบกับภาพ baseline ที่บันทึกไว้
25. CI Integration
เทสต์จะมีประโยชน์สูงสุดเมื่อ "รันอัตโนมัติ" ทุกครั้งที่มีคน push โค้ด ไม่ใช่รอให้คนจำมารันเอง ส่วนนี้คือตัวอย่างไฟล์ GitHub Actions ที่สั่ง lint → unit test → e2e ให้ทุกครั้งที่มี push/PR ถ้าเทสต์ไม่ผ่าน โค้ดจะถูกบล็อกไม่ให้ merge:
โปรเจกต์ใหม่ที่ใช้ Vitest (แนะนำ):
yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
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 lint
- run: npm test -- --run --coverage # Vitest: รันครั้งเดียว + coverage (flag หลัง -- ขึ้นกับว่า script "test" ใน package.json ต่อไปยัง Vitest ตรง ๆ หรือไม่ — เช็ค package.json ของโปรเจกต์จริงก่อน)
- uses: actions/upload-artifact@v4 # อัปโหลด coverage report ไว้ดูใน GitHub
with:
name: coverage
path: coverage/
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run e2eโปรเจกต์เก่าที่ยังใช้ Karma (legacy):
yaml
# ใช้เฉพาะโปรเจกต์ที่ยังใช้ Karma runner
- run: npm test -- --watch=false --browsers=ChromeHeadless --code-coverage
# --browsers=ChromeHeadless เป็น flag ของ Karma โดยเฉพาะ ใช้กับ Vitest ไม่ได้26. Testing Best Practices
หลังรู้วิธีเขียนเทสต์แล้ว สิ่งสำคัญคือ "เขียนให้ดี" — เทสต์ที่ดีควรทดสอบพฤติกรรมที่ผู้ใช้เห็น ไม่ใช่รายละเอียดภายใน, ใช้ data-testid เลือก element ให้ทนต่อการเปลี่ยน UI, และแต่ละเทสต์ควรเช็คเรื่องเดียว ส่วนนี้รวมแนวทาง "ควรทำ/ไม่ควรทำ" ที่ใช้ได้จริง:
text
✅ ทดสอบ behavior (พฤติกรรมที่ผู้ใช้เห็น) ไม่ใช่ implementation (โครงสร้างภายในของโค้ด)
- "คลิกปุ่ม → user ถูกเพิ่มเข้า list"
- ไม่ใช่: "ตัวแปร internalCounter เพิ่มค่า"
✅ ใช้ data-testid เลือก element
- ทนต่อการเปลี่ยน UI (class/text เปลี่ยนได้ แต่ test ไม่พัง)
- <button data-testid="submit">
✅ Mock ของภายนอก
- HTTP, services, library จากภายนอก
✅ แต่ละเทสต์ = เช็คเรื่องเดียว
- หลาย `it` ดีกว่ายัด assertion หลายอย่างในเทสต์เดียว
✅ AAA pattern
- Arrange — เตรียมข้อมูล/ตั้งค่า
- Act — ทำการกระทำ
- Assert — ตรวจผลลัพธ์
✅ ทดสอบ edge case
- สถานะว่าง (Empty)
- สถานะกำลังโหลด (Loading)
- สถานะ error
- ค่าขอบ (Boundary values — ค่าน้อยสุด/มากสุด/0/null)
❌ อย่าทดสอบ framework
- "Angular render {{ x }}" — ไม่ใช่หน้าที่เรา
❌ อย่าทดสอบ private method
- ทดสอบผ่าน public API เท่านั้น
❌ อย่าแชร์ state ระหว่างเทสต์
- reset ใน beforeEach ทุกครั้ง27. Anti-Patterns
ตรงข้ามกับ best practices คือ "กับดัก" ที่มือใหม่มักพลาด เช่น ทดสอบตัวแปร private (พอ refactor เทสต์พังทั้งที่โค้ดยังถูก), ยัด assertion หลายอย่างในเทสต์เดียว, หรือใช้ waitForTimeout รอแบบสุ่ม ๆ ส่วนนี้แสดงตัวอย่าง "❌ แบบผิด vs ✅ แบบถูก" ให้เห็นชัด:
❌ Test Implementation
typescript
// ❌
it('should increment internal counter', () => {
component.increment();
expect(component['internalCounter']).toBe(1); // private!
});
// ✅ Test observable behavior
it('should display incremented count', () => {
component.increment();
fixture.detectChanges();
const count = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(count.textContent).toContain('1');
});❌ Multiple Assertions
typescript
// ❌ — ถ้า assertion แรกผิด จะไม่รู้ว่า assertion ที่เหลือเป็นยังไง
it('should do everything', () => {
expect(a).toBe(1);
expect(b).toBe(2);
expect(c).toBe(3);
// ...
});
// ✅
it('should set a', () => expect(a).toBe(1));
it('should set b', () => expect(b).toBe(2));
it('should set c', () => expect(c).toBe(3));❌ Hard-coded Wait
typescript
// ❌
await page.waitForTimeout(2000);
// ✅ Wait for condition
await page.waitForSelector('[data-testid="loaded"]');
await expect(page.locator('...')).toBeVisible();❌ Snapshot Test Everything
⚠️
toMatchSnapshot()ใช้ได้กับ Vitest และ Jest เท่านั้น — ไม่มีใน Jasmine ถ้าโปรเจกต์ยังใช้ Jasmine runner โค้ดตัวอย่างด้านล่างจะ compile ไม่ผ่าน (เป็นตัวอย่างแสดงแนวคิด ไม่ใช่โค้ดให้ copy-paste ตรง ๆ)
typescript
// ❌ Hard to maintain
expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
// ✅ Specific assertion — ดีกว่าเพราะอ่านรู้เลยว่า expect อะไร
expect(getByTestId('title').textContent).toBe('Hello');28. ตัวอย่างเต็ม — Component + Service Test
มาประกอบทุกอย่างที่เรียนมาเข้าด้วยกัน — ตัวอย่างนี้เป็น component จริงที่มีครบทั้ง loading, error, แสดงรายการ และปุ่ม reload พร้อมไฟล์เทสต์ที่ครอบคลุมทั้ง 4 สถานะ ลองอ่านคู่กันเพื่อเห็นภาพว่าเทสต์ที่สมบูรณ์หน้าตาเป็นอย่างไร:
typescript
// user-list.component.ts
import { firstValueFrom } from 'rxjs'; // แปลง Observable → Promise เพื่อใช้กับ async/await
@Component({
standalone: true,
template: `
<div data-testid="status">
@if (loading()) {
Loading...
} @else if (error()) {
Error: {{ error() }}
} @else {
Total: {{ users().length }}
}
</div>
<ul data-testid="list">
@for (u of users(); track u.id) {
<li [attr.data-testid]="'user-' + u.id">{{ u.name }}</li>
}
</ul>
<button (click)="load()" data-testid="reload">Reload</button>
`
})
export class UserListComponent {
private userService = inject(UserService);
users = signal<User[]>([]);
loading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
// หมายเหตุ: Angular ไม่ await async lifecycle hook — this.load() รันแบบ fire-and-forget
// ผลคือ loading state จะแสดงทันที แต่ data จะมาทีหลัง (ซึ่งเป็นพฤติกรรมที่ต้องการ)
this.load();
}
async load() {
this.loading.set(true);
this.error.set(null);
try {
const users = await firstValueFrom(this.userService.getAll());
this.users.set(users);
} catch (e: any) {
this.error.set(e.message || 'Failed');
} finally {
this.loading.set(false);
}
}
}typescript
// user-list.component.spec.ts
import { of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
describe('UserListComponent', () => {
let fixture: ComponentFixture<UserListComponent>;
let component: UserListComponent;
let mockUserService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
mockUserService = jasmine.createSpyObj('UserService', ['getAll']);
await TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }
]
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
component = fixture.componentInstance;
});
function getByTestId(id: string): HTMLElement {
return fixture.nativeElement.querySelector(`[data-testid="${id}"]`)!;
}
it('should show loading initially', async () => {
mockUserService.getAll.and.returnValue(
of([]).pipe(delay(100))
);
fixture.detectChanges(); // trigger ngOnInit
expect(getByTestId('status').textContent).toContain('Loading');
});
it('should display users after load', async () => {
const mockUsers = [
{ id: 1, name: 'Anna' },
{ id: 2, name: 'Ben' }
];
mockUserService.getAll.and.returnValue(of(mockUsers));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(getByTestId('status').textContent).toContain('Total: 2');
expect(getByTestId('user-1').textContent).toContain('Anna');
expect(getByTestId('user-2').textContent).toContain('Ben');
});
it('should show error on failure', async () => {
mockUserService.getAll.and.returnValue(
throwError(() => new Error('Server down'))
);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(getByTestId('status').textContent).toContain('Server down');
});
it('should reload on button click', async () => {
mockUserService.getAll.and.returnValue(of([]));
fixture.detectChanges();
const btn = getByTestId('reload');
btn.click();
expect(mockUserService.getAll).toHaveBeenCalledTimes(2);
});
});29. Checkpoint
ลองฝึกเขียนเทสต์ด้วยตัวเองตามโจทย์ด้านล่าง ไล่จากง่าย (service) ไปยาก (E2E) เพื่อให้มั่นใจว่าจับหลักการของแต่ละแบบได้จริง:
🛠️ Checkpoint 8.1 — Service Test
Test for:
- CartService: add, remove, total, clear
- Use signal assertions
🛠️ Checkpoint 8.2 — Component Test
Test for:
- CounterComponent
- Input signal (initial value)
- Click increment/decrement
- DOM updates
🛠️ Checkpoint 8.3 — HTTP Test
Test UserService:
- list, get, create
- Use HttpTestingController
- Verify URL + method + body
- Test 404 handling
🛠️ Checkpoint 8.4 — Form Test
Test form:
- Initial invalid state
- Validation rules
- Submit only when valid
🛠️ Checkpoint 8.5 — E2E with Playwright
- Setup Playwright
- Test login flow
- Use page object pattern
- Run headlessly (รันโดยไม่เปิดหน้าต่างเบราว์เซอร์) ใน CI
30. สรุปบท
ทบทวนสิ่งที่ได้เรียนในบทนี้ — ตั้งแต่ชุดเครื่องมือพื้นฐาน วิธีเทสต์ service/component/form/http ไปจนถึง E2E และหลักการเขียนเทสต์ที่ดี:
✅ Jasmine syntax (describe/it/expect) = assertion พื้นฐานใช้ได้กับทุก runner — แต่ Karma test runner เลิกใช้แล้ว (deprecated ปี 2023) · โปรเจกต์ใหม่ใช้ Vitest (เร็ว นิยมในวงการ) หรือ Web Test Runner (ของทีม Angular) แทน
✅ TestBed.configureTestingModule + imports: [...] for standalone
✅ Service: simple TestBed.inject — easy
✅ Component: createComponent + detectChanges + nativeElement
✅ Signal input: fixture.componentRef.setInput(name, value)
✅ Mock service: jasmine.createSpyObj + provide in module
✅ HTTP test: provideHttpClientTesting() + HttpTestingController
✅ fakeAsync + tick for time-based tests (debounce, etc.)
✅ data-testid for resilient selectors
✅ E2E: Playwright (recommended) or Cypress
✅ Test behavior, not implementation
✅ Coverage target: 80% business logic + 60% UI