Skip to content

Pruebas Unitarias para RxJS ​

El código que usa RxJS implica mucho procesamiento asíncrono y requiere un enfoque diferente al de los métodos de prueba tradicionales. Esta guía describe tanto técnicas básicas como avanzadas para probar efectivamente código que usa RxJS.

Probar Observable Síncrono ​

Comencemos con el caso más simple: probar un Observable que se completa de forma síncrona.

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

// Función bajo prueba
function doubleValues(input$: Observable<number>) : Observable<number>{
  return input$.pipe(
    map(x => x * 2)
  );
}

describe('Pruebas básicas de Observable', () => {
  it('Duplica valores', () => {
    // Observable de prueba
    const source$ = of(1, 2, 3);
    const result$ = doubleValues(source$);

    // Resultado esperado
    const expected = [2, 4, 6];
    const actual: number[] = [];

    // Ejecución y verificación
    result$.subscribe({
      next: (value) => actual.push(value),
      complete: () => {
        expect(actual).toEqual(expected);
      }
    });
  });
});

Cómo probar un Observable asíncrono ​

Para Observable asíncrono, aproveche el soporte asíncrono del framework de pruebas.

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

// Función asíncrona bajo prueba
function getDelayedValues(): Observable<number> {
  return timer(0, 100).pipe(
    map(x => x + 1),
    take(3)
  );
}

describe('Prueba de Observable asíncrono', () => {
  it('Recibe valores asíncronos en orden', (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();
      }
    });
  });
});

Pruebas asíncronas con transformación de Promise ​

Otro método es convertir un Observable a una Promise usando firstValueFrom() o lastValueFrom() y utilizar async/await del JS/TS moderno.

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

// Función bajo prueba
function processWithDelay(input$: Observable<number>) {
  return input$.pipe(
    map(x => x * 10),
    delay(100),
    toArray()
  );
}

describe('Pruebas con conversión de Promise', () => {
  it('Esperar procesamiento de delay antes de validación', async () => {
    const source$ = of(1, 2, 3);
    const result$ = processWithDelay(source$);

    // Convertir Observable a promise
    const result = await lastValueFrom(result$);

    // Resultado esperado
    expect(result).toEqual([10, 20, 30]);
  });
});

Utilizar TestScheduler ​

RxJS proporciona un scheduler especial llamado TestScheduler que puede usarse para probar eficientemente operadores basados en tiempo.

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

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

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

  it('Probar 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

Notación de Marble Test Al usar TestScheduler, use diagramas de marble para representar el paso del tiempo.

Hacer el tiempo manipulable ​

Al probar código dependiente del tiempo (delay, debounceTime, etc.), use el TestScheduler para controlar el tiempo.

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

describe('Control de tiempo', () => {
  let testScheduler: TestScheduler;

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

  it('Avance rápido del tiempo para pruebas', () => {
    testScheduler.run(({ expectObservable }) => {
      const source = interval(1000).pipe(
        take(3),
        map(x => x + 1)
      );

      // Realmente toma 3 segundos, pero se ejecuta inmediatamente en entorno de prueba
      const expected = '1s a 999ms b 999ms (c|)';
      const values = { a: 1, b: 2, c: 3 };

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

Probar manejo de errores (versión TestScheduler) ​

También es importante probar el comportamiento del Observable cuando ocurre un error.

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

describe('Prueba de manejo de errores', () => {
  let testScheduler: TestScheduler;

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

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

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

  it('Cuando catchError captura error y lo reemplaza con un valor', () => {
    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 test ​

Para probar streams complejos, use un diagrama de marble para representar intuitivamente las expectativas de prueba.

Hot Observable vs. Cold Observable ​

TestScheduler permite la creación de dos tipos de Observables: hot y cold. Es importante entender esta diferencia al probar.

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

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

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

  it('Cold Observable crea streams independientes para cada suscripción', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      // Cold Observable (independiente para cada suscriptor)
      const source = cold('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Primera suscripción
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Segunda suscripción (comienza desde el principio)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

  it('Hot Observable comparte streams entre suscriptores', () => {
    testScheduler.run(({ hot, expectObservable }) => {
      // Hot Observable (compartido entre suscriptores)
      const source = hot('--a--b--c|', { a: 1, b: 2, c: 3 });

      // Suscribirse tarde (recibe solo valores después de que comienza la suscripción)
      expectObservable(source, '----^').toBe('-----b--c|', { b: 2, c: 3 });

      // Suscribirse desde el principio (recibe todos los valores)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

  it('Probar Hot Observable usando Subject real', () => {
    // Versión sin TestScheduler
    const subject = new Subject<number>();
    const values1: number[] = [];
    const values2: number[] = [];

    // Primer suscriptor
    const subscription1 = subject.subscribe(val => values1.push(val));

    // Emitir valores
    subject.next(1);
    subject.next(2);

    // Segundo suscriptor (se une a mitad de camino)
    const subscription2 = subject.subscribe(val => values2.push(val));

    // Emitir más valores
    subject.next(3);
    subject.complete();

    // Verificación
    expect(values1).toEqual([1, 2, 3]);
    expect(values2).toEqual([3]); // Solo valores después de que comenzó la suscripción

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

NOTE

Cold Observable genera datos independientemente cada vez que se suscribe, pero Hot Observable comparte y distribuye datos.

Mocking y Stubbing ​

Mockear Servicios Dependientes ​

Al probar servicios que usan RxJS, es común mockear dependencias externas.

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;
}

// Servicio bajo prueba
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

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

describe('Prueba de servicio', () => {
  it('Filtrar solo usuarios activos', () => {
    // Mockear servicio de 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();

    // Verificación
    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);
    });
  });
});

Stubs ​

Los stubs son objetos simples que imitan datos externos o APIs de los que depende el código bajo prueba. Eliminan dependencias de recursos externos y permiten que las pruebas se ejecuten independientemente. Simplemente devuelven valores fijos y no tienen lógica interna.

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

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

// Servicio bajo prueba
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

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

describe('Prueba de UserService', () => {
  it('Devuelve solo usuarios activos', () => {
    // 🔹 Crear stubs
    const stubApiService = {
      fetchUsers: () => of<User[]>([
        { id: 1, name: 'Tanaka', active: true },
        { id: 2, name: 'Sato', active: false },
        { id: 3, name: 'Yamada', active: true }
      ])
    };

    // Servicio bajo prueba
    const userService = new UserService(stubApiService);

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

Espiar suscripciones ​

El espía puede usarse para verificar que las suscripciones se están realizando correctamente.

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

describe('Prueba de suscripción', () => {
  it('Suscribirse con manejadores apropiados', () => {
    const subject = new Subject();

    // Crear espías de manejadores
    const nextSpy = vi.fn();
    const errorSpy = vi.fn();
    const completeSpy = vi.fn();

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

    // Emitir valores
    subject.next('value1');
    subject.next('value2');
    subject.complete();

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

Mejores Prácticas ​

Mejores prácticasExplicación
Observar el principio de responsabilidad únicaPara escribir código que se pueda probar, cada función o clase debe tener una responsabilidad única. De esta manera, las pruebas se simplifican.
Mockear dependencias externasLas dependencias externas como solicitudes http y temporizadores deben mockearse y probarse en un entorno predecible.
Usar técnicas apropiadas para código asíncronoElija métodos apropiados para pruebas asíncronas, como TestScheduler, callbacks done(), o async/await.
Utilizar marble testingPara probar streams complejos, use diagramas de marble para representar las expectativas de prueba de manera intuitiva.

Resumen ​

Probar código RxJS tiene aspectos que difieren del código JavaScript tradicional, como su naturaleza síncrona/asíncrona y comportamiento dependiente del tiempo. Al elegir una metodología de prueba apropiada, puede desarrollar código reactivo de alta calidad con confianza. En particular, tenga en cuenta los siguientes puntos

  • Pruebas de suscripción simples para Observable síncrono
  • TestScheduler o transformaciones de Promise para procesamiento asíncrono
  • Marble test para código dependiente del tiempo
  • Mockear dependencias externas para crear un entorno de prueba independiente.
  • Diseñar código que se pueda probar según el principio de responsabilidad única

🔗 Secciones Relacionadas ​

Publicado bajo licencia CC-BY-4.0.