Skip to content

บทที่ 8 — Testing

← บทที่ 7 | สารบัญ | บทที่ 9 →

หลังจบบท คุณจะ:

  • เขียน 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 อย่างก็พอ:

  1. Jasmine (เฟรมเวิร์ก testing ที่บทนี้ใช้สอน) = วิธีเขียนเทสต์ — assertion พื้นฐาน (toBe, toEqual, toContain) ใช้ได้ทุก runner
  2. 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 service Angular 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.ts

4. 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 install
typescript
// 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/schematic
typescript
// 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       # CI

23. 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


← บทที่ 7 | บทที่ 9 → SSR + Modern Angular