Close

18 dicembre 2019

Introduzione ai Test in NgRx

TEST IN ANGULAR

Introduzione

L’utilizzo dei test nello sviluppo del software è ormai un fattore d’importanza accertata che garantisce svariati vantaggi tra cui:

  • Comprensione del codice: scrivere codice che supera i test implica affrontare errori e effetti collaterali che difficilmente vengono notati senza l’utilizzo dei test. Questo ci permette di ottenere una migliore comprensione del codice, delle cause di certi errori e quindi come evitare di scatenarli.
  • Integrità rispetto al refactoring: i test ci permettono di assicurare che, rifattorizzando il codice, non cambi il suo effetto e che ottimizzando parti del codice non ne vengano danneggiate altre.

Vediamo quindi come i test possono aiutarci nel corretto utilizzo della libreria NgRx di Angular.

NgRx

NgRx è un framework che ci permette di gestire lo stato di un’applicazione Angular reattiva, che segue cioè il paradigma del reactive programming, attraverso l’utilizzo di un contenitore, detto Store, basato sulla libreria RxJs.

Image result for ngrx state management

Per gestire lo stato dell’applicazione, NgRx utilizza i seguenti componenti:

  • Actions, azioni che descrivono eventi scatenati dai componenti o dai servizi;
  • Reducers, funzioni pure che compongono il nuovo stato in base a stato corrente e all’azione ricevuta;
  • Selectors, funzioni pure utilizzare per selezionare parti dello stato;
  • Store, un observable con cui accediamo allo stato che è anche un observer delle azioni;
  • Effects, classi che isolano i componenti dai servizi, stanno in ascolto sulle azioni e possono scatenarne come risultato di un evento.

La documentazione della libreria NgRx è ampia e si possono trovare numerosi articoli e tutorial per il suo utilizzo, per cui procediamo con un’introduzione ai test di Angular.

Test in Angular

Il framework predefinito con cui vengono eseguiti i test delle applicazioni Angular è Jasmine. Jasmine utilizza una sintassi molto intuitiva per i test attraverso le funzioni:

  • describe, viene utilizzato per raggruppare più test sullo stesso componente o metodo;
  • it, definisce un singolo test e contiene una o più expectations, le aspettative rispetto al comportamento del componente testato;
  • expect, la funzione che confronta il valore passato in argomento con quello atteso attraverso un matcher.

I matcher sono comparatori tra oggetti che restituiscono un valore booleano, segnalando così a Jasmine il successo o il fallimento di un test. Chiaramente un test viene considerato un successo solo se tutte le expectations al suo interno hanno avuto risultato positivo, di conseguenza viene considerato un fallimento se almeno una expectation ha risultato negativo.

Esempio:

describe('This example', () => {
  it('should be successful', () => {
    expect(true).toBe(true);
  });

  it('should fail', () => {
    const flag = true;
    expect(flag).not.toBe(flag);
  });
});

Per evitare la ripetizione di codice all’interno dei test, è possibile scrivere parte di questo all’interno della funzione describe, in da condividerlo in tutti i test al suo interno. Inoltre, è possibile definire del codice da eseguire prima di ogni singolo test, utilizzando la funzione beforeEach.

describe('This example', () => {
  let flag;

  beforeEach(() => {
    flag = true;
  });

  it('should be successful', () => {
    expect(flag).toBe(true);
  });

  it('should fail', () => {
    expect(flag).not.toBe(true);
  });
});

Per eseguire i test presenti in un’applicazione Angular è sufficiente eseguire da terminale il comando ng test all’interno della cartella del progetto. Con questo, abbiamo concluso l’introduzione su NgRx e sui test in Angular e, con le basi appena acquisite su questi argomenti, possiamo passare al testing dei componenti dello Store della nostra applicazione.

Test in NgRx

Dei componenti precedentemente descritti di NgRx è interessante testare effects e reducers: inizieremo da questi ultimi per semplicità, per poi passare ai più complessi.

I reducers, come spiegato precedentemente, sono funzioni pure che accettano come argomento una parte di stato e una action e che possono aggiornare o meno lo stato, a seconda del tipo di action ricevuta. Di conseguenza, testare un reducer significa verificare che lo stato cambi con le giuste actions e che resti lo stesso in tutti gli altri casi.

Esempio action:

export enum ESampleAction {
  LoadSample = '[Sample] Load Sample data',
  LoadSampleSuccess = '[Sample] Load Sample data success',
  LoadSampleFail = '[Sample] Load Sample data fail'
}

export class LoadSampleAction implements Action {
  readonly type = ESampleAction.LoadSample;
}

export class LoadSampleSuccessAction implements Action {
  readonly type = ESampleAction.LoadSampleSuccess;
  constructor(public payload: string[]) {}
}

export class LoadSampleFailAction implements Action {
  readonly type = ESampleAction.LoadSampleFail;
}


export type SampleActions =
  | LoadSampleAction
  | LoadSampleSuccessAction
  | LoadSampleFailAction;

Esempio reducer:

export function sampleReducer(
  state = initialISampleState,
  action: SampleActions
): ISampleState {
  switch (action.type) {
    case ESampleAction.LoadSampleSuccessAction:
      return {
        ...state,
        sampleData: action.payload
      };
    default:
      return state;
  }
}

Esempio test:

describe('sampleReducer', () => {
  let action: SampleActions;
  let newState: ISampleState;

  describe('given an invalid action', () => {
    it('should return the previous state', () => {
      action = new LoadSampleAction();

      newState = sampleReducer(initialISampleState, action);

      expect(newState).toBe(initialISampleState);
    });
  });

  describe('given a valid action', () => {
    it('should change the state', () => {
      action = new LoadSampleSuccessAction(['data']);
      newState = sampleReducer(initialISampleState, action);

      expect(newState).not.toBe(initialISampleState);
    });
  });
});

Il test verifica sia che il reducer entri nel caso default dello switch ritornando lo stato iniziale, sia che entri nel caso in cui lo stato viene aggiornato, aggiungendo i dati contenuti nel payload dell’action.

Testare gli effects è un po’ più complesso, in quanto si ha a che fare con dati provenienti dai servizi, eventualmente in modo asincrono. Innanzitutto, vediamo un possibile effect:

@Injectable()
export class SampleEffects {

  constructor(private _actions$: Actions, private service: SampleService) {}

  @Effect()
  getSample$ = this._actions$.pipe(
    ofType(ESampleAction.LoadSample),
    switchMap(() =>
      this.service.getSample().pipe(
        switchMap(data =>
          of(new LoadSampleSuccessAction(data.payload))
        ),
        catchError(() => of(new LoadSampleFailAction()))
      )
    )
  );
}

L’effect getSample$ è in ascolto su tutte le azioni che vengono scatenate, ma si attiva solo per quelle con type LoadSample. Quando un action di questo tipo viene intercettata, l’effect chiama il metodo getSample() del SampleService: se questo va a buon fine, viene scatenata un’azione di successo, altrimenti una di fallimento.

Innanzitutto testiamo il costruttore di questo effect:

describe('SampleEffects', () => {
  let actions: Actions;
  let service: any;
  let effects: SampleEffects;
  let expected: SampleActions;

  it('should be created', () => {
    actions = new Actions(of(new LoadSampleAction()));
    service = stubService('');
    effects = new SampleEffects(actions, service);
    expect(effects).toBeTruthy();
  });

});

In questo esempio possiamo vedere come, per testare il costruttore della classe, abbiamo utilizzato un mock del servizio SampleService utilizzato da SampleEffects. stubService è una funzione che, dato un input, restituisce una spia Jasmine da invocare in sostituzione del servizio vero e proprio. Possiamo in questo modo controllare la risposta del servizio: in questo caso, lo stubService restituisce un Observable del valore passato in input.

function stubService(response: any): any {
    const sampleService = jasmine.createSpyObj('service', ['getSample']);
    sampleService.getSample.and.returnValue(of(response));

    return sampleService;
  }

Vediamo invece come viene testato l’effect getSample:

 describe('getSample effect', () => {
    beforeEach(() => {
      actions = new Actions(of(new LoadSampleAction()));
    });

    describe('on a valid action', () => {
      it('should dispatch a successful action with the response from the service', () => {
        service = stubService(['test']);
        effects = new SampleEffects(actions, service);

        expected = new LoadSampleSuccessAction(['test']);

        effects.getSample$.subscribe(action => {
          expect(action).toEqual(expected);
        });
      });
    });

    describe('on an error from the service', () => {
      it('should dispatch a fail action', () => {
        service = stubFailService();
        effects = new SampleEffects(actions, service);

        expected = new LoadSampleFailAction();

        effects.getSample$.subscribe(action => {
          expect(action).toEqual(expected);
        });
      });
    });
  });

  function stubService(response: any): any {
    const sampleService = jasmine.createSpyObj('service', ['getSample']);
    sampleService.getSample.and.returnValue(of(response));

    return sampleService;
  }


  function stubFailService(): any {
    const sampleService = jasmine.createSpyObj('service', ['getSample']);
    sampleService.getSample.and.returnValue(hot('#'));

    return sampleService;
  }
 }

Per testare sia l’ottenimento dei dati da parte del servizio, che l’ottenimento di un errore, è stato implementato anche uno stubFailService, che simula, tramite l’utilizzo di Jasmine marbles, l’arrivo di un errore dal servizio.

Grazie al test possiamo verificare che l’effect si comporta esattamente come desideriamo: quando invochiamo stubService, che risponde con un oggetto valido, viene scatenata una azione di successo LoadSampleSuccessAction contenente nel payload la risposta data dal servizio, mentre quando invochiamo lo stubFailService, ottenendo un errore da parte del servizio, l’effect scatena un’azione di fallimento LoadSampleFailAction.

Abbiamo analizzato dei test per i reducer e per gli effect dello Store di NGRX molto semplici, ma comunque sufficienti a comprendere l’impostazione e il funzionamento dei test di NGRX con il framework Jasmine di Angular.

Adesso, grazie all’utilizzo dei test possiamo procedere ad eventuali refactoring del codice sicuri che il funzionamento dello stesso sarà garantito dall’esecuzione dei test. Inoltre, aver imparato il funzionamento dei test per NgRx ci permette di utilizzare un approccio Test Driven la prossima volta che avremo a che fare con questo componente.

Domande/osservazioni/commenti a m.fanni{at}quidinfo.it

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *