Kurs Angular · Moduł 11: Testowanie

Testowanie w Angular - Sztuka Wojny

3 min czytania ·
W tej lekcji5

Testowanie to jak przygotowanie do bitwy - sztuka wojny 兵法 (heiho). Samuraj trenuje codziennie, by w prawdziwej walce wykonać perfekcyjne cięcie. Twój kod też musi przejść próbę przed bitwą produkcyjną!

Rodzaje Testów

Jak różne techniki walki, mamy różne poziomy testów:

PoziomCelNarzędzia
UnitTestowanie pojedynczych jednostekJasmine, Jest
IntegrationTestowanie współpracy komponentówTestBed
E2ETestowanie całej aplikacjiPlaywright, Cypress

Konfiguracja Testów

Angular CLI automatycznie konfiguruje środowisko testowe:

# Uruchomienie testów
ng test

# Testy z coverage
ng test --code-coverage

# Jednorazowe uruchomienie (CI/CD)
ng test --watch=false --browsers=ChromeHeadless

Podstawowa Struktura Testu

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SamuraiComponent } from './samurai.component';

describe('SamuraiComponent', () => {
  // Zmienne dostępne dla wszystkich testów
  let component: SamuraiComponent;
  let fixture: ComponentFixture<SamuraiComponent>;

  // Przed każdym testem - przygotowanie
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [SamuraiComponent]  // Standalone component
    }).compileComponents();

    fixture = TestBed.createComponent(SamuraiComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();  // Trigger change detection
  });

  // Po każdym teście - sprzątanie (opcjonalne)
  afterEach(() => {
    // Cleanup jeśli potrzebne
  });

  // Test: czy komponent się tworzy
  it('should create', () => {
    expect(component).toBeTruthy();
  });

  // Test: sprawdzanie właściwości
  it('should have default level of 1', () => {
    expect(component.level).toBe(1);
  });

  // Test: sprawdzanie szablonu
  it('should display samurai name in template', () => {
    component.name = 'Musashi';
    fixture.detectChanges();  // Aktualizuj widok

    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('h2')?.textContent).toContain('Musashi');
  });
});

TestBed - Dojo Testów

TestBed to główne narzędzie do konfiguracji środowiska testowego:

// Minimalna konfiguracja dla standalone component
await TestBed.configureTestingModule({
  imports: [SamuraiComponent]
}).compileComponents();

// Z zależnościami
await TestBed.configureTestingModule({
  imports: [SamuraiComponent],
  providers: [
    provideHttpClient(),
    provideHttpClientTesting(),
    provideRouter([]),
    { provide: SamuraiService, useClass: MockSamuraiService },
    { provide: API_URL, useValue: 'http://test-api' }
  ]
}).compileComponents();

Testowanie @Input i @Output

describe('SamuraiCardComponent', () => {
  let component: SamuraiCardComponent;
  let fixture: ComponentFixture<SamuraiCardComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [SamuraiCardComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(SamuraiCardComponent);
    component = fixture.componentInstance;
  });

  it('should display samurai from @Input', () => {
    const samurai = { id: 1, name: 'Musashi', clan: 'Ronin' };
    component.samurai = samurai;  // Ustawiamy @Input
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('.name').textContent).toBe('Musashi');
  });

  it('should emit event on @Output', () => {
    const samurai = { id: 1, name: 'Musashi', clan: 'Ronin' };
    component.samurai = samurai;
    fixture.detectChanges();

    // Szpiegujemy emisję eventu
    spyOn(component.selected, 'emit');  // Jasmine; w Jest: jest.spyOn(...)

    // Symulujemy kliknięcie
    const button = fixture.nativeElement.querySelector('button');
    button.click();

    expect(component.selected.emit).toHaveBeenCalledWith(samurai);
  });
});
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

// Komponent do testowania
@Component({
  selector: 'app-samurai-counter',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="counter">
      <h2>Licznik Samurajów</h2>
      <div class="count">{{ count() }}</div>
      <div class="controls">
        <button (click)="decrement()" [disabled]="count() <= 0">-</button>
        <button (click)="increment()">+</button>
        <button (click)="reset()">Zeruj</button>
      </div>
      <p class="status">{{ status() }}</p>
    </div>
  `,
  styles: [`
    .counter {
      text-align: center;
      padding: 20px;
      background: rgba(0,0,0,0.3);
      border-radius: 10px;
      margin: 10px;
    }
    .count {
      font-size: 3em;
      font-weight: bold;
      color: #e94560;
    }
    .controls {
      display: flex;
      justify-content: center;
      gap: 10px;
      margin: 15px 0;
    }
    button {
      padding: 10px 20px;
      font-size: 1.2em;
      background: #0f3460;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    button:hover:not(:disabled) {
      background: #e94560;
    }
    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    .status {
      color: #888;
    }
  `]
})
export class SamuraiCounterComponent {
  count = signal(0);

  status = computed(() => {
    const c = this.count();
    if (c === 0) return 'Armia jest pusta';
    if (c < 5) return 'Mały oddział';
    if (c < 10) return 'Średnia armia';
    return 'Potężna armia!';
  });

  increment(): void {
    this.count.update(c => c + 1);
  }

  decrement(): void {
    if (this.count() > 0) {
      this.count.update(c => c - 1);
    }
  }

  reset(): void {
    this.count.set(0);
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, SamuraiCounterComponent],
  template: `
    <div class="testing-dojo">
      <h1>Dojo Testowania - Jasmine & TestBed</h1>

      <section class="demo">
        <h2>Komponent do testowania:</h2>
        <app-samurai-counter></app-samurai-counter>
      </section>

      <section class="test-code">
        <h2>Przykładowy test jednostkowy:</h2>
        <pre><code>{{ testCode }}</code></pre>
      </section>

      <section class="test-run">
        <h2>Symulacja testów</h2>
        <button (click)="runTests()">Uruchom testy</button>

        <div class="test-results">
          @for (test of testResults(); track test.name) {
            <div class="test-result" [class.passed]="test.passed" [class.failed]="!test.passed">
              <span class="icon">{{ test.passed ? '[OK]' : '[BŁĄD]' }}</span>
              <span class="name">{{ test.name }}</span>
              <span class="time">{{ test.time }}ms</span>
            </div>
          }
        </div>

        @if (testResults().length > 0) {
          <div class="summary">
            Wynik: {{ passedCount() }}/{{ testResults().length }} testów przeszło
          </div>
        }
      </section>

      <section class="tips">
        <h2>Dobre praktyki testowania</h2>
        <ul>
          <li><strong>Arrange-Act-Assert:</strong> Przygotuj dane, wykonaj akcję, sprawdź wynik</li>
          <li><strong>Jeden test = jeden przypadek:</strong> Każdy test sprawdza jedną funkcjonalność</li>
          <li><strong>Niezależność:</strong> Testy nie powinny zależeć od siebie</li>
          <li><strong>Nazewnictwo:</strong> Opisowe nazwy testów (should_X_when_Y)</li>
          <li><strong>Mockowanie:</strong> Izoluj testowany kod od zależności</li>
        </ul>
      </section>
    </div>
  `,
  styles: [`
    .testing-dojo {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #00d9ff;
    }

    h2 {
      color: #00d9ff;
      border-bottom: 2px solid #00d9ff;
      padding-bottom: 10px;
    }

    section {
      background: rgba(255,255,255,0.05);
      border-radius: 10px;
      padding: 20px;
      margin: 20px 0;
    }

    pre {
      background: #0a0a15;
      padding: 15px;
      border-radius: 8px;
      overflow-x: auto;
      border-left: 4px solid #00d9ff;
    }

    code {
      color: #e6e6e6;
      font-family: 'Fira Code', monospace;
      font-size: 0.85em;
    }

    .test-run button {
      padding: 12px 30px;
      background: #00d9ff;
      color: #1a1a2e;
      border: none;
      border-radius: 5px;
      font-size: 1.1em;
      font-weight: bold;
      cursor: pointer;
      margin-bottom: 20px;
    }

    .test-run button:hover {
      background: #00b8d4;
    }

    .test-results {
      margin-top: 15px;
    }

    .test-result {
      display: flex;
      align-items: center;
      gap: 15px;
      padding: 10px;
      margin: 5px 0;
      border-radius: 5px;
      background: rgba(0,0,0,0.3);
    }

    .test-result.passed {
      border-left: 4px solid #00e676;
    }

    .test-result.failed {
      border-left: 4px solid #ff5252;
    }

    .test-result .icon {
      font-size: 1.2em;
    }

    .test-result .name {
      flex: 1;
    }

    .test-result .time {
      color: #888;
      font-size: 0.9em;
    }

    .summary {
      margin-top: 15px;
      padding: 15px;
      background: rgba(0,217,255,0.1);
      border-radius: 5px;
      text-align: center;
      font-weight: bold;
    }

    .tips ul {
      list-style: none;
      padding: 0;
    }

    .tips li {
      padding: 10px 0;
      border-bottom: 1px solid rgba(0,217,255,0.2);
    }

    .tips li:last-child {
      border-bottom: none;
    }
  `]
})
export class AppComponent {
  testResults = signal<TestResult[]>([]);

  passedCount = computed(() =>
    this.testResults().filter(t => t.passed).length
  );

  testCode = `describe('SamuraiCounterComponent', () => {
  let component: SamuraiCounterComponent;
  let fixture: ComponentFixture<SamuraiCounterComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [SamuraiCounterComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(SamuraiCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should start with count 0', () => {
    expect(component.count()).toBe(0);
  });

  it('should increment count', () => {
    component.increment();
    expect(component.count()).toBe(1);
  });

  it('should decrement count when > 0', () => {
    component.count.set(5);
    component.decrement();
    expect(component.count()).toBe(4);
  });

  it('should not decrement below 0', () => {
    component.count.set(0);
    component.decrement();
    expect(component.count()).toBe(0);
  });

  it('should reset to 0', () => {
    component.count.set(10);
    component.reset();
    expect(component.count()).toBe(0);
  });

  it('should show correct status for empty army', () => {
    component.count.set(0);
    expect(component.status()).toBe('Armia jest pusta');
  });

  it('should show correct status for small army', () => {
    component.count.set(3);
    expect(component.status()).toBe('Mały oddział');
  });
});`;

  runTests(): void {
    this.testResults.set([]);

    const tests: TestResult[] = [
      { name: 'should create component', passed: true, time: 12 },
      { name: 'should start with count 0', passed: true, time: 3 },
      { name: 'should increment count', passed: true, time: 5 },
      { name: 'should decrement count when > 0', passed: true, time: 4 },
      { name: 'should not decrement below 0', passed: true, time: 3 },
      { name: 'should reset to 0', passed: true, time: 2 },
      { name: 'should show correct status for empty army', passed: true, time: 4 },
      { name: 'should show correct status for small army', passed: true, time: 3 }
    ];

    // Symulacja uruchamiania testów jeden po drugim
    tests.forEach((test, index) => {
      setTimeout(() => {
        this.testResults.update(results => [...results, test]);
      }, (index + 1) * 200);
    });
  }
}

interface TestResult {
  name: string;
  passed: boolean;
  time: number;
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co robi metoda fixture.detectChanges() w teście Angular?

  2. 2. Który blok Jasmine grupuje powiązane testy w jeden zestaw?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij unit test dla komponentu samuraja sprawdzający wyświetlanie imienia.

  • Układanie w pionie

    Uporządkuj kroki konfiguracji standalone komponentu w beforeEach (od góry do dołu):

  • Klikanie w kolejności

    Ułóż linię sprawdzającą, czy komponent został utworzony: expect(component).toBeTruthy()

  • Układanie w poziomie

    Ułóż wyrażenie pobierające element h1 z wyrenderowanego widoku (jedna linia):

Przydatne artykuły