Wróć do strony głównej
Angular

Testowanie jednostkowe widoków z blokami defer

Widoki z możliwością odroczenia ładowania (inaczej blok @defer) to nowa funkcja Angulara, dzięki której możemy deklaratywnie, leniwie załadować część naszego kodu HTML. Innymi słowy, możemy wybrać, która część naszego drzewa komponentów powinna znajdować się w oddzielnym pliku i leniwie go ładować, gdy nastąpi określona akcja. Jeśli chcesz przeczytać więcej na temat widoków odroczonych, zdecydowanie powinieneś sprawdzić ten artykuł.

Zanim zagłębimy się w kod testowania widoków z @defer, cofnijmy się o krok i zobaczmy, w jaki sposób takie widoki mogą ogólnie ładować treść.

Istnieją różne wyzwalacze ładowania widoku, niektóre z nich wymagają interakcji użytkownika, inne nie.

W poniższej tabeli możesz zobaczyć więcej szczegółów:

Pytanie 1: Dlaczego ważne jest, aby wiedzieć, czy dany wyzwalacz wymaga interakcji użytkownika? 

Odpowiedź 1: Jeśli wyzwalacz wymaga interakcji użytkownika, napisanie naszego testu jednostkowego jest dość proste. Musimy po prostu naśladować to działanie, a następnie zweryfikować rezultat.

Pytanie 2: A co z przypadkami, w których nie mamy interakcji z użytkownikiem?

Odpowiedź 2: Można powiedzieć, że możemy zamockować wewnętrzną implementację. W końcu to właśnie często robimy podczas testów jednostkowych, prawda? Trudno jednak byłoby zamockować requestIdleCallback (na którym opiera się idle) i IntersectionObserver (na którym opiera się viewport)

Pytanie 3: Czy istnieje sposób na uniknięcie mockowania requestIdleCallback I IntersectionObserver?

Odpowiedź 3: Angular udostępnia narzędzia testowania (“test harness”), dzięki którym możemy jawnie określić, która część odroczonego widoku ma zostać wyrenderowana.

Test Harness

Pod pojęciem „test harness” rozumiemy interfejs API, który pomaga testować komponenty. Zapewnia on sposób interakcji i weryfikacji zachowania komponentów w środowisku testowym. Jeśli chcesz dowiedzieć się więcej na ten temat, zachęcam do obejrzenia filmu na YouTube od mojego kolegi Mateusza Stefańczyka „Testing with component harnesses” – Mateusz Stefańczyk | #8 Angular Meetup

Jeśli mowa o widokach odroczonych, można je przetestować na dwa sposoby:

  1. Manualny (Manual)
    Dzięki tej opcji możemy wybrać, który blok odroczenia chcemy załadować i w jakim stanie.
  2. Symulacja użycia (Playthrough)
    Jest to zachowanie domyślne, a nasz test jednostkowy zachowuje się tak, jakby kod był renderowany w przeglądarce.

Definiujemy strategię w wywołaniu metody configureTestingModule w następujący sposób:

TestBed.configureTestingModule({
   deferBlockBehavior: DeferBlockBehavior.Manual,
});

Oprócz tego mamy również możliwość wyboru stanu bloku do wyrenderowania. Dostępne stany przedstawiono w następującym enumie:

export declare enum ɵDeferBlockState {
    /** Renderowana jest zawartość bloku zastępczego (placeholder) */
    Placeholder = 0,
    /** Renderowana jest zawartość widoku ładowania */
    Loading = 1,
    /** Renderowana jest główna/docelowa zawartość bloku */
    Complete = 2,
    /** Renderowana jest zawartość widoku błędu */
    Error = 3
}

Podczas gdy podejście Symulacja użycia wydaje się proste, potrzebujemy zrozumieć tryb Manualny trochę lepiej.

W trybie Manualnym, wybieramy blok, który chcemy testować, a także wybieramy stan, w jakim chcemy go wyrenderować. Załóżmy w poniższym przykładzie, że musimy wyrenderować drugi blok @defer:

Wymagany kod to:

// Załaduj drugi blok
const deferBlock = (await fixture.getDeferBlocks())[1]; // zwróć uwagę na indeks

// Renderuj główną/docelową zawartość (stan complete)
czekać na deferBlock.render(DeferBlockState.Complete);

Zobaczmy teraz jakie stany są dostępne:

Jeśli chcemy wyrenderować zawartość widoku ładowania (stan loading), kod będzie wyglądał następująco:

// Załaduj drugi blok
const deferBlock = (await fixture.getDeferBlocks())[1]; // zwróć uwagę na indeks

// Renderuj widok ładowania (loading)
await deferBlock.render(ɵDeferBlockState.Loading);

Ponieważ teraz lepiej rozumiemy narzędzia do testowania bloków odroczonych, zobaczmy, jak przetestować jednostkowo każdy indywidualny wyzwalacz.

Testowanie wyzwalaczy

Dla każdego testu jednostkowego załadujemy ten komponent:

Leniwie ładowany komponent:

@Component({
  selector: 'app-lazy',
  standalone: true,
  template: ` <p>lazy works!</p> `,
})
export class LazyComponent {}

“When” (Symulacja użycia)

Ponieważ użytkownicy muszą wykonać jakąś akcję, aby uruchomić wyzwalacz „when”, użyjemy podejścia Symulacja użycia (Playthrough). W rzeczywistości jest to ustawienie domyślne, więc można pominąć jego ustawienie w wywołaniu metody configureTestingModule!

Załóżmy, że komponent, który musimy przetestować, wygląda następująco:

Prosty komponent do testów: 

 @Component({
    selector: 'app-root',
    template: `
      <button data-test="button--isVisible" 
              (click)="isVisible = !isVisible">
        Toggle
      </button>

      @defer (when isVisible) {
        <app-lazy />
      }
      ,
    `,
    standalone: true,
    imports: [LazyComponent],
  })
  class DummyComponent {
    isVisible = false;
  }

Aby załadować <app-lazy/>, powinniśmy najpierw kliknąć przycisk, a następnie potwierdzić rezultat.

Test jednostkowy będzie wyglądał następująco:

it('should render the defer block on button click, fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const button = fixture.debugElement.query(
      By.css('[data-test="button--isVisible"]'),
    );

    // Act
    button.triggerEventHandler('click', null);
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

on interaction – jawnie określony element (Symulacja użycia)

Ponieważ “on interaction” wymaga interakcji użytkownika, użyjemy podejścia Symulacja użycia (Playthrough).

> Przez „jawną interakcję” mam na myśli to, że aby załadować widok z możliwością odroczenia, musimy wejść w interakcję z jawnie określonym elementem.

Załóżmy, że szablon, który musimy przetestować, jest następujący:

<button #toggleButton data-test="button--isVisible">Toggle</button>

@defer (on interaction(toggleButton)) {
    <app-lazy />
}

Aby załadować <app-lazy/> powinniśmy najpierw kliknąć przycisk, a następnie potwierdzić rezultat.

Test jednostkowy będzie wyglądał następująco:

it('should render the defer block on explicit interaction', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const button = fixture.debugElement.query(
      By.css('[data-test="button--isVisible"]'),
    );

    // Act
    button.nativeElement.click();
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

Uwaga: w tym teście jednostkowym nie używamy metody triggerEventHandler, ponieważ natywny element nie ma zdefiniowanego handlera zdarzenia kliknięcia.

on interaction – domyślny element (Symulacja użycia)

Ponieważ “on interaction” wymaga interakcji użytkownika, ponownie użyjemy podejścia Symulacja użycia (Playthrough).

> Przez „domyślny element” mam na myśli to, że aby załadować docelowy widok, musimy wejść w interakcję z domyślnym elementem (czyli częścią bloku @defer).

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer (on interaction) {
    <app-lazy />
} @placeholder {
    <div data-test="el--placeholder">
        click here to load the complete state
    </div>
}

Aby załadować <app-lazy/> powinniśmy najpierw wejść w interakcję (kliknąć) widok zastępczy (placeholder), a następnie potwierdzić rezultat.

Test jednostkowy będzie wyglądał następująco:

 it('should render the defer block on implicit interaction', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    const placeholderElement = fixture.debugElement.query(
      By.css('[data-test="el--placeholder"]'),
    );

    // Act
    placeholderElement.nativeElement.click();
    fixture.detectChanges();
    tick();

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

on timer (Symulacja użycia)

Wyzwalacz „on timer” nie wymaga interakcji użytkownika, ale ponieważ jego implementacja opiera się na setTimeout, skorzystamy z trybu Symulacja użycia (Playthrough)

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer (on timer(1000)) {
  <app-lazy />
}

Test jednostkowy będzie wyglądał następująco:

 it('should render the defer block on timer', fakeAsync(() => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Playthrough,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    fixture.detectChanges();
    tick(1000);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  }));

Zauważ, że w tym teście jednostkowym czekamy (tick(1000)) przez taki sam okres, jak ten, którego użyliśmy w naszym wyzwalaczu timer(1000).

domyślny (Manualny)

Domyślnie właściwa zawartość bloku @defer jest ładowana, gdy przeglądarka jest w stanie bezczynności (idle). A więc, użycie po prostu @defer jest tożsame z @defer (on idle).  Wyzwalacz  „on idle” opiera się na requestIdleCallback, co może być trudne do samodzielnego przetestowania.  Z tego powodu używamy trybu Manualny (Manual). Później dowiemy się, jak renderować każdy stan bloku.

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer {
  <app-lazy />
} @placeholder {
  <div>Placeholder text</div>
} @loading {
  <div>Loading text</div>
} @error {
  <div>Error text</div>
}

Wyrenderuj widok zastępczy (stan placeholder) i zweryfikuj rezultat:

it('should render the defer block on idle - placeholder', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Placeholder text');
  });

Uwaga: w przypadku widoku zastępczego nie ma potrzeby określania stanu renderowania, ponieważ widok zastępczy jest renderowany domyślnie.

Wyrenderuj widok ładowania (stan loading) i zweryfikuj rezultat:

  it('should render the defer block on idle - loading', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Loading);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Loading text');
  });

Wyrenderuj widok błędu (stan error) i zweryfikuj rezultat:

  it('should render the defer block on idle - error', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Error);
    
    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('Error text');
  });

Wyrenderuj docelowy widok (stan complete) i zweryfikuj rezultat:

 it('should render the defer block on idle - complete', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on viewport (Manualny)

> Sytuacja jest analogiczna do “on idle”.

Wyzwalacz „on viewport” opiera się na intersectionObserver API, które jest trudne do zamockowania. Ponownie skorzystamy więc z trybu Manualny (Manual).

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer (on viewport) {
  <app-lazy />
} @placeholder {
  <div>on viewport the complete state will be loaded</div>
}

Test jednostkowy będzie wyglądał następująco:

it('should render the defer block on viewport', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on immediate (Manualny)

> Sytuacja jest analogiczna do „on viewport”

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer (on immediate) {
  <app-lazy />
}

Test jednostkowy będzie wyglądał następująco:

  it('should render the defer block on immediate', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
  });

on immediate z zagnieżdżonymi blokami

Przeanalizujmy krok po kroku testowanie zagnieżdżonych bloków @defer. Wyobraź sobie, że zagnieżdżony blok znajduje się w innym bloku, jak pudełko w pudełku.

Aby przetestować pudełko wewnętrzne, musimy najpierw zbudować pudełko zewnętrzne. Następnie możemy zbudować samo pudełko wewnętrzne.

Na obrazku zagnieżdżony blok @defer jest schowany w większym bloku. W celu przetestowania takiego scenariusza, musimy najpierw zbudować cały zewnętrzny blok, a następnie zbudować w nim zagnieżdżony blok.

Załóżmy, że szablon, który musimy przetestować, jest następujący:

@defer (on immediate) {
  <app-lazy />

  @defer {
    <div>nested complete state</div>
  }
}

Test jednostkowy będzie wyglądał następująco:

  it('should render the nested defer block', async () => {
    // Arrange
    TestBed.configureTestingModule({
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });

    fixture = TestBed.createComponent(DummyComponent);

    // Act
    const firstDeferBlock = (await fixture.getDeferBlocks())[0];
    await firstDeferBlock.render(ɵDeferBlockState.Complete);

    const secondDeferBLock = (await firstDeferBlock.getDeferBlocks())[0];
    await secondDeferBLock.render(ɵDeferBlockState.Complete);

    // Assert
    expect(fixture.nativeElement.innerHTML).toContain('nested complete state');
  });

Przeanalizujmy ten kod bardziej szczegółowo. Najpierw renderujemy zewnętrzny blok (firstDeferBlock), a następnie uzyskujemy dostęp do wszystkich wewnętrznych bloków odroczenia (firstDeferBlock.getDeferBlocks).

Podsumowanie

Wsparcie dla testowania bloków @defer w Angularze jest całkiem dobre dzięki narzędziom testowym stworzonym przez zespół Angulara. Istnieje jednak mały haczyk. W tej chwili musisz dokładnie określić w  testach, który blok ma być załadowany i sprawdzony, podając jego indeks. Może to stanowić problem, jeśli później dodasz więcej widoków odroczonych, ponieważ poprzednie testy mogą przestać działać poprawnie.

Dobra wiadomość jest taka, że ​​zespół Angulara jest tego świadomy i przyszłe aktualizacje dadzą nam więcej możliwości pisania testów tego typu widoków.

Dziękuję za przeczytanie mojego artykułu! 🙂 

O autorze

Fanis Prodromou

Jestem full-stack web developerem z pasją do Angulara i NodeJs. Mieszkam w Atenach-Grecji i pracowałem w wielu dużych firmach. Podczas moich 14 lat kodowania zdobyłem ogromne doświadczenie w zakresie jakości kodu, architektury aplikacji oraz ich wydajności.

Zdając sobie sprawę z tego, jak szybko rozwija się informatyka i aspekty techniczne, staram się być na bieżąco, uczestnicząc w konferencjach i meetupach, studiując i próbując nowych technologii. Uwielbiam dzielić się swoją wiedzą i pomagać innym programistom.

„Sharing is Caring”

Uczę Angulara w firmach korporacyjnych poprzez instytut Code.Hub, piszę artykuły i tworzę filmy na YouTube.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *