Angular – Einen HTTP-Interceptor testen, der den State eines NgRx Stores verändert

titelbild-angular-loading

In diesem Artikel gehe ich auf ein Testszenario ein, auf das ich vor kurzer Zeit gestoßen bin. Dabei handelt es sich um einen HTTP-Interceptor, der genutzt wird, um einen Ladebalken während Anfragen an ein Backend anzuzeigen.

Ein Ladebalken könnte an vielen Stellen in einer Anwendung genutzt werden, um einen Ladevorgang anzuzeigen. Damit nicht jede Komponente der Anwendung die Information, ob der Ladebalken angezeigt werden soll, selbst halten muss, wird ein NgRx Store genutzt. Dieser ermöglicht es, die Information über den Ladebalken zentral und für die gesamte Anwendung zugänglich zu speichern.

Das nachfolgende Beispiel soll dabei helfen, diese Funktionalität nachzustellen. Dafür liegt ein Test-Backend vor, das eine Schnittstelle zur Verfügung stellt, mit der eine Liste von Benutzern geladen werden kann. Die App bildet diese Liste ab und zeigt einen Ladebalken an, solange die Benutzer geladen werden.

Zunächst gehen wir kurz auf die Funktionsweise des Interceptors ein. Dieser fängt alle HTTP-Anfragen ab und ermöglicht es daraufhin, Aktionen auszuführen. Ein Interceptor implementiert das Interface Http-Interceptor. Dafür muss die Intercept-Methode überschrieben werden.

public intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    this.store.dispatch(showLoading());
    return next
      .handle(request)
      .pipe(finalize(() => this.store.dispatch(hideLoading())));
  } 

Im ersten Codeabschnitt ist die Intercept-Methode dargestellt. Über die Dispatch-Funktion des Stores wird in Zeile 5 zunächst die Information gesetzt, dass der Ladebalken angezeigt werden soll. In Zeile 8 wird ebenfalls über ein dispatch vermittelt, dass der Ladebalken nicht mehr angezeigt wird, sobald die Anfrage beendet ist.

Ohne zu genau auf die Funktionsweise eines Stores einzugehen, möchte ich kurz erklären, was in unserem Store passiert.

Im Reducer für das User Management sind die Funktionen showLoading, die kennzeichnet, dass der Ladebalken angezeigt werden soll, und die Funktion hideLoading, die diesen verschwinden lässt, implementiert.

Der Status (State) einer Anwendung wird durch ein Objekt dargestellt. Das bedeutet, dass dieses Objekt immer die aktuellen Daten über bestimmte Informationen der Anwendung an einem zentralen Ort hält. Dazu zählt z. B. die Information, ob der Ladebalken angezeigt werden soll. Unser State Objekt sieht folgendermaßen aus:

export interface UserManagementState {
    selectedUser: User;
    loading: boolean;
} 

Das Objekt UserManagementState dient dazu, alle Informationen, die wir in unserer App für das Benutzermanagement zentral verwalten wollen, zu speichern. In unserem Beispiel wird neben dem Loading-Attribut auch der aktuell ausgewählte Benutzer gehalten.

const _userManagementReducer = createReducer(
  initialState,
  on(updateSelectedUser, (state, { selectedUser }) => ({
    ...state,
    selectedUser: selectedUser,
  })),
  on(showLoading, (state) => ({ ...state, loading: true })),
  on(hideLoading, (state) => ({ ...state, loading: false }))
); 

Im Reducer unseres Stores sind die Funktionen showLoading und hideLoading implementiert. Diese sind in Zeile 7 und 8 zu sehen. Durch showLoading wird das loading Attribut auf true gesetzt und durch hideLoading auf false. Diese Information wird von der Komponente für den Ladebalken konsumiert und zeigt entsprechend den Ladebalken an oder lässt ihn verschwinden.

Durch den Einsatz des Stores wurde erreicht, dass das Signal für die Anzeige des Ladebalkens und die tatsächliche Anzeige getrennt voneinander sind.

Diese Funktionalität wollen wir durch Tests prüfen. Dafür bin ich auf die folgende Möglichkeit gestoßen: Wir nutzen den Service, der die Benutzer vom Backend lädt. Mit dem Aufruf der Methode des Services, mit der die Anfrage getätigt wird, können wir innerhalb unseres Tests einen Request simulieren. Da die Anfrage mittels HTTP erfolgt, sollte diese vom HTTP-Interceptor abgefangen werden. Dieser ruft dann die Dispatch-Methode des Stores auf. Ob die Dispatch-Methode tatsächlich aufgerufen wurde, können wir mit einem Spy testen.
Darauffolgend wird der Inhalt der Datei für den Interceptor Test dargestellt:

import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { finalize } from 'rxjs/operators';
import { User } from '../users-data/user';
import { UsersDataService } from '../users-data/users-data.service';
import { LoaderInterceptor } from './loader.interceptor';

describe('LoaderInterceptor', () => {
  let interceptor: LoaderInterceptor;
  const initialState = {
    selectedUser: null,
    showLoading: false,
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        LoaderInterceptor,
        provideMockStore({ initialState }),
        UsersDataService,
        {
          provide: HTTP_INTERCEPTORS,
          useClass: LoaderInterceptor,
          multi: true,
        },
      ],
      imports: [HttpClientTestingModule]
    });
    interceptor = TestBed.inject(LoaderInterceptor);
  }
  );

  it('should be created', () => {
    expect(interceptor).toBeTruthy();
  });

  it('should activate the loader during the request', (done: DoneFn) => {
    const mockStore = TestBed.inject(MockStore);
    const httpMock = TestBed.inject(HttpTestingController);
    const usersDataService = TestBed.inject(UsersDataService);
    const dispatchSpy = spyOn(mockStore, 'dispatch');
    const mockedUsers: User[] = [
      {
        "name": "Nicholas",
        "age": 42,
        "occupation": "Network Engineer"
      },
      {
        "name": "Elvin",
        "age": 32,
        "occupation": "Doctor"
      },
      {
        "name": "Jass",
        "age": 22,
        "occupation": "Web Developer"
      }
    ];

    usersDataService
      .getUsers()
      .pipe(finalize(() => expect(dispatchSpy).toHaveBeenCalledTimes(1)))
      .subscribe((response) => {
        expect(response).toBeTruthy();
        expect(dispatchSpy).toHaveBeenCalledTimes(1);
        done();
      });

    const mockRequest = httpMock.expectOne('http://localhost:5000/users');

    mockRequest.flush(mockedUsers);
  });
}); 

In Zeile 23 binden wir den UsersDataService ein, um die HTTP-Anfrage simulieren zu können. Der Spy, mit dem wir den Aufruf der Dispatch-Methode prüfen, wird in Zeile 44 erstellt. Die GetUsers-Methode des UserDataServices, die wir in Zeile 63 aufrufen, simuliert die HTTP-Anfrage. Da wir uns in einem Test befinden, müssen wir die Anfrage in Zeile 72 mocken und mit Mockdaten in Zeile 74 füllen.

Innerhalb des Aufrufs der GetUsers-Methode prüfen wir dann, ob der dispatchSpy einmal aufgerufen wurde.
Sollte dies der Fall sein, dann wurde die Information, dass der Ladebalken angezeigt werden soll, über die Dispatch-Methode verändert. Da die Anfrage zu dem Zeitpunkt noch nicht beantwortet ist, genügt es, zu prüfen, ob die Methode einmal aufgerufen wurde.

Mit diesem Test wurde eine Möglichkeit vorgestellt, das Zusammenspiel eines HTTP-Interceptors und einem NgRx Store zu testen. Die Schwierigkeit dabei bestand darin, innerhalb des Tests eine HTTP-Anfrage zu simulieren. Dadurch wird der HTTP-Interceptor aktiviert und die Information im Store verändert.
Unter Verwendung einer Funktion, die eine HTTP-Anfrage tätigt, und einem Spy, der den Aufruf der dispatch Methode im NgRx Store testet, haben wir dieses Zusammenspiel erfolgreich getestet.

By accepting you will be accessing a service provided by a third-party external to https://blog.ordix.de/