Skip to content

Test Unitari di RxJS ​

Il codice che utilizza RxJS contiene molte operazioni asincrone e richiede un approccio diverso rispetto ai metodi di test tradizionali. Questa guida spiega le tecniche fondamentali e avanzate per testare efficacemente il codice che utilizza RxJS.

Test di Observable Sincroni ​

Iniziamo dal caso più semplice: il test di Observable che si completano in modo sincrono.

ts
import { Observable, of } from 'rxjs';
import { map } from 'rxjs';
import { describe, it, expect } from 'vitest';

// Funzione da testare
function doubleValues(input$: Observable<number>) : Observable<number>{
  return input$.pipe(
    map(x => x * 2)
  );
}

describe('Test di Observable di base', () => {
  it('raddoppia i valori', () => {
    // Observable per il test
    const source$ = of(1, 2, 3);
    const result$ = doubleValues(source$);

    // Risultato atteso
    const expected = [2, 4, 6];
    const actual: number[] = [];

    // Esecuzione e verifica
    result$.subscribe({
      next: (value) => actual.push(value),
      complete: () => {
        expect(actual).toEqual(expected);
      }
    });
  });
});

Come Testare Observable Asincroni ​

Per gli Observable asincroni, utilizziamo il supporto asincrono del framework di test.

ts
import { Observable, timer } from 'rxjs';
import { map, take } from 'rxjs';
import { describe, it, expect } from 'vitest';

// Funzione asincrona da testare
function getDelayedValues(): Observable<number> {
  return timer(0, 100).pipe(
    map(x => x + 1),
    take(3)
  );
}

describe('Test di Observable asincroni', () => {
  it('riceve valori asincroni in sequenza', (done: Function) => {
    const result$ = getDelayedValues();
    const expected = [1, 2, 3];
    const actual: number[] = [];

    result$.subscribe({
      next: (value) => actual.push(value),
      complete: () => {
        expect(actual).toEqual(expected);
        done();
      }
    });
  });
});

Test Asincroni tramite Conversione in Promise ​

È possibile convertire gli Observable in Promise usando firstValueFrom() o lastValueFrom() e utilizzare async/await di JavaScript/TypeScript moderno.

ts
import { Observable, of } from 'rxjs';
import { map, delay, toArray } from 'rxjs';
import { describe, it, expect } from 'vitest';
import { lastValueFrom } from 'rxjs';

// Funzione da testare
function processWithDelay(input$: Observable<number>) {
  return input$.pipe(
    map(x => x * 10),
    delay(100),
    toArray()
  );
}

describe('Test usando conversione in Promise', () => {
  it('attende l\'elaborazione con ritardo prima della verifica', async () => {
    const source$ = of(1, 2, 3);
    const result$ = processWithDelay(source$);

    // Converte Observable in promise
    const result = await lastValueFrom(result$);

    // Risultato atteso
    expect(result).toEqual([10, 20, 30]);
  });
});

Utilizzo di TestScheduler ​

RxJS fornisce uno scheduler speciale chiamato TestScheduler che permette di testare efficientemente gli operatori basati sul tempo.

ts
import { TestScheduler } from 'rxjs/testing';
import { map, debounceTime } from 'rxjs';
import { describe, it, beforeEach } from 'vitest';

describe('Utilizzo di TestScheduler', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('test di debounceTime', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('a--b--c--d|', { a: 1, b: 2, c: 3, d: 4 });
      const result = source.pipe(
        debounceTime(20),
        map(x => x * 10)
      );

      const expected = '----------(d|)';

      expectObservable(result).toBe(expected, { d: 40 });
    });
  });
});

NOTE

Notazione Marble Testing Quando si usa TestScheduler, si utilizza la notazione dei diagrammi marble per rappresentare il passare del tempo.

Rendere il Tempo Manipolabile ​

Quando si testano codici dipendenti dal tempo (delay, debounceTime, ecc.), si usa TestScheduler per controllare il tempo.

ts
import { TestScheduler } from 'rxjs/testing';
import { interval } from 'rxjs';
import { take, map } from 'rxjs';
import { describe, it, beforeEach } from 'vitest';

describe('Controllo del tempo', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('test con avanzamento rapido del tempo', () => {
    testScheduler.run(({ expectObservable }) => {
      const source = interval(1000).pipe(
        take(3),
        map(x => x + 1)
      );

      // Richiede 3 secondi in realtà, ma viene eseguito istantaneamente nell'ambiente di test
      const expected = '1s a 999ms b 999ms (c|)';
      const values = { a: 1, b: 2, c: 3 };

      expectObservable(source).toBe(expected, values);
    });
  });
});

Test di Gestione degli Errori (versione TestScheduler) ​

È importante anche testare il comportamento degli Observable quando si verificano errori.

ts
import { TestScheduler } from 'rxjs/testing';
import { throwError, of } from 'rxjs';
import { catchError } from 'rxjs';

describe('Test di gestione degli errori', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('quando Observable notifica un errore', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('  --a--b--#');
      const expected =     '--a--b--#';

      expectObservable(source).toBe(expected);
    });
  });

  it('quando catchError cattura l\'errore e lo sostituisce con un valore', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const source = cold('  --a--b--#');
      const handled = source.pipe(
        catchError(() => of('X'))
      );

      const expected =     '--a--b--(X|)';

      expectObservable(handled).toBe(expected);
    });
  });
});

Marble Testing ​

Per testare stream complessi, si esprimono i valori attesi in modo intuitivo usando i diagrammi marble.

Hot Observable vs Cold Observable ​

Con TestScheduler è possibile creare due tipi di Observable: hot e cold. È importante comprendere questa differenza per i test.

ts
import { TestScheduler } from 'rxjs/testing';
import { Subject } from 'rxjs';
import { describe, it, beforeEach, expect } from 'vitest';

describe('Test Hot vs Cold Observable', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('Cold Observable genera uno stream indipendente per ogni sottoscrizione', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      // Cold Observable (indipendente per ogni sottoscrittore)
      const source = cold('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Prima sottoscrizione
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Seconda sottoscrizione (inizia dall'inizio)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

  it('Hot Observable condivide lo stream tra i sottoscrittori', () => {
    testScheduler.run(({ hot, expectObservable }) => {
      // Hot Observable (condiviso tra i sottoscrittori)
      const source = hot('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Sottoscrizione ritardata (riceve solo i valori dopo l'inizio della sottoscrizione)
      // expectObservable(source, '-----^---').toBe('-----b--c|', { b: 2, c: 3 });
      expectObservable(source, '----^').toBe('-----b--c|', { b: 2, c: 3 });

      // Sottoscrizione dall'inizio (riceve tutti i valori)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

  it('test di Hot Observable usando Subject reale', () => {
    // Versione non-TestScheduler
    const subject = new Subject<number>();
    const values1: number[] = [];
    const values2: number[] = [];

    // Primo sottoscrittore
    const subscription1 = subject.subscribe(val => values1.push(val));

    // Emissione di valori
    subject.next(1);
    subject.next(2);

    // Secondo sottoscrittore (a metà strada)
    const subscription2 = subject.subscribe(val => values2.push(val));

    // Ulteriore emissione di valori
    subject.next(3);
    subject.complete();

    // Verifica
    expect(values1).toEqual([1, 2, 3]);
    expect(values2).toEqual([3]); // Solo i valori dopo l'inizio della sottoscrizione

    // Pulizia
    subscription1.unsubscribe();
    subscription2.unsubscribe();
  });
});

NOTE

I Cold Observable generano dati in modo indipendente ogni volta che vengono sottoscritti, mentre gli Hot Observable condividono e distribuiscono i dati.

Utilizzo di Mock e Stub ​

Mock di Servizi Dipendenti ​

Quando si testano servizi che utilizzano RxJS, si mockano spesso le dipendenze esterne.

ts
import { Observable, of } from 'rxjs';
import { map } from 'rxjs';
import { describe, it, expect, vi } from 'vitest';

type User = {
  id: number;
  name: string;
  active: boolean;
}

// Servizio da testare
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

  getUsers(): Observable<User[]> {
    return this.apiService.fetchUsers().pipe(
      map((users: User[]) => users.filter(user => user.active))
    );
  }
}

describe('Test di servizio', () => {
  it('filtra solo gli utenti attivi', () => {
    // Mock del servizio API
    const mockApiService = {
      fetchUsers: vi.fn().mockReturnValue(of([
        { id: 1, name: 'Tanaka', active: true },
        { id: 2, name: 'Sato', active: false },
        { id: 3, name: 'Yamada', active: true }
      ]))
    };

    const userService = new UserService(mockApiService);
    const result$ = userService.getUsers();

    // Verifica
    result$.subscribe(users => {
      expect(users.length).toBe(2);
      expect(users[0].name).toBe('Tanaka');
      expect(users[1].name).toBe('Yamada');
      expect(mockApiService.fetchUsers).toHaveBeenCalledTimes(1);
    });
  });
});

Utilizzo di Stub ​

Gli stub sono oggetti semplici che imitano dati esterni o API da cui dipende il codice da testare. Eliminano la dipendenza da risorse esterne e permettono ai test di funzionare in modo indipendente. Restituiscono solo valori fissi, senza logica interna.

ts
import { of } from 'rxjs';
import { map } from 'rxjs';
import { describe, it, expect } from 'vitest';

type User = {
  id: number;
  name: string;
  active: boolean;
};

// Servizio da testare
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

  getActiveUsers() {
    return this.apiService.fetchUsers().pipe(
      map((users: User[]) => users.filter(user => user.active))
    );
  }
}

describe('Test di UserService', () => {
  it('restituisce solo utenti attivi', () => {
    // 🔹 Creazione dello stub
    const stubApiService = {
      fetchUsers: () => of<User[]>([
        { id: 1, name: 'Tanaka', active: true },
        { id: 2, name: 'Sato', active: false },
        { id: 3, name: 'Yamada', active: true }
      ])
    };

    // Servizio da testare
    const userService = new UserService(stubApiService);

    // Verifica del risultato
    userService.getActiveUsers().subscribe((users: User[]) => {
      expect(users.length).toBe(2);
      expect(users[0].name).toBe('Tanaka');
      expect(users[1].name).toBe('Yamada');
    });
  });
});

Spy sulle Sottoscrizioni ​

Si possono usare spy per verificare che le sottoscrizioni vengano effettuate correttamente.

ts
import { Subject } from 'rxjs';
import { describe, it, expect, vi } from 'vitest';

describe('Test di sottoscrizione', () => {
  it('sottoscrizione con i gestori appropriati', () => {
    const subject = new Subject();

    // Creazione di spy per i gestori
    const nextSpy = vi.fn();
    const errorSpy = vi.fn();
    const completeSpy = vi.fn();

    // Sottoscrizione
    subject.subscribe({
      next: nextSpy,
      error: errorSpy,
      complete: completeSpy
    });

    // Emissione di valori
    subject.next('value1');
    subject.next('value2');
    subject.complete();

    // Verifica
    expect(nextSpy).toHaveBeenCalledTimes(2);
    expect(nextSpy).toHaveBeenCalledWith('value1');
    expect(nextSpy).toHaveBeenCalledWith('value2');
    expect(errorSpy).not.toHaveBeenCalled();
    expect(completeSpy).toHaveBeenCalledTimes(1);
  });
});

Best Practice ​

Best PracticeDescrizione
Rispettare il principio di singola responsabilitàPer scrivere codice testabile, assicurarsi che ogni funzione o classe abbia una singola responsabilità. Questo rende anche i test più semplici.
Mockare le dipendenze esterneMockare le dipendenze esterne come richieste HTTP o timer per testare in un ambiente prevedibile.
Usare tecniche appropriate per il codice asincronoPer i test asincroni, scegliere il metodo appropriato tra TestScheduler, callback done(), o async/await.
Utilizzare il marble testingPer testare stream complessi, esprimere i valori attesi in modo intuitivo usando i diagrammi marble.

I test del codice RxJS presentano aspetti diversi dal codice JavaScript tradizionale, come la natura sincrona/asincrona e il comportamento dipendente dal tempo. Scegliendo le tecniche di test appropriate, è possibile sviluppare con sicurezza codice reattivo di alta qualità. In particolare, tenere a mente i seguenti punti:

  • Per Observable sincroni, test di sottoscrizione semplici
  • Per elaborazioni asincrone, TestScheduler o conversione in Promise
  • Per codice dipendente dal tempo, marble testing
  • Mockare le dipendenze esterne per creare un ambiente di test indipendente
  • Seguire il principio di singola responsabilità e progettare codice facilmente testabile

🔗 Sezioni Correlate ​

Pubblicato sotto licenza CC-BY-4.0.