Skip to content

Testes Unitários para RxJS ​

O código usando RxJS envolve muito processamento assíncrono e requer uma abordagem diferente dos métodos de teste tradicionais. Este guia descreve técnicas básicas e avançadas para testar efetivamente código usando RxJS.

Testando Observable Síncrono ​

Vamos começar com o caso mais simples: testar um Observable que completa de forma síncrona.

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

// Função sob teste
function doubleValues(input$: Observable<number>) : Observable<number>{
  return input$.pipe(
    map(x => x * 2)
  );
}

describe('Teste básico de Observable', () => {
  it('Dobra valores', () => {
    // Observable de teste
    const source$ = of(1, 2, 3);
    const result$ = doubleValues(source$);

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

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

Como testar um Observable assíncrono ​

Para Observable assíncrono, aproveite o suporte assíncrono do framework de teste.

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

// Função assíncrona sob teste
function getDelayedValues(): Observable<number> {
  return timer(0, 100).pipe(
    map(x => x + 1),
    take(3)
  );
}

describe('Testando Observable assíncrono', () => {
  it('Recebe valores assíncronos em ordem', (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();
      }
    });
  });
});

Teste assíncrono com transformação Promise ​

Outro método é converter um Observable para uma Promise usando firstValueFrom() ou lastValueFrom() e utilizar async/await do 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';

// Função sob teste
function processWithDelay(input$: Observable<number>) {
  return input$.pipe(
    map(x => x * 10),
    delay(100),
    toArray()
  );
}

describe('Testando com conversão Promise', () => {
  it('Aguarda processamento com delay antes da validação', async () => {
    const source$ = of(1, 2, 3);
    const result$ = processWithDelay(source$);

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

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

Utilizando TestScheduler ​

O RxJS fornece um scheduler especial chamado TestScheduler que pode ser usado para testar eficientemente operadores baseados em tempo.

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

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

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

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

Notação Marble Test Ao usar TestScheduler, use diagramas marble para representar a passagem do tempo.

Tornar o tempo manipulável ​

Ao testar código dependente de tempo (delay, debounceTime, etc.), use o TestScheduler para controlar o tempo.

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

describe('Controle de tempo', () => {
  let testScheduler: TestScheduler;

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

  it('Avançar tempo rapidamente para teste', () => {
    testScheduler.run(({ expectObservable }) => {
      const source = interval(1000).pipe(
        take(3),
        map(x => x + 1)
      );

      // Na realidade leva 3 segundos, mas executa imediatamente no ambiente de teste
      const expected = '1s a 999ms b 999ms (c|)';
      const values = { a: 1, b: 2, c: 3 };

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

Testar tratamento de erros (versão TestScheduler) ​

Também é importante testar o comportamento do Observable quando um erro ocorre.

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

describe('Teste de tratamento de erros', () => {
  let testScheduler: TestScheduler;

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

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

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

  it('Quando catchError captura erro e substitui por um 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 testar streams complexos, use um diagrama marble para representar intuitivamente as expectativas do teste.

Hot Observable vs. Cold Observable ​

O TestScheduler permite a criação de dois tipos de Observables: hot e cold. É importante entender essa diferença ao testar.

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

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

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

  it('Cold Observable cria streams independentes para cada subscription', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      // Cold Observable (independente para cada subscriber)
      const source = cold('--a--b--c|', { a: 1, b: 2, c: 3 });

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

      // Segunda subscription (inicia do começo)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

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

      // Subscreve tarde (recebe apenas valores após o início da subscription)
      expectObservable(source, '----^').toBe('-----b--c|', { b: 2, c: 3 });

      // Subscreve desde o início (recebe todos os valores)
      expectObservable(source).toBe('--a--b--c|', { a: 1, b: 2, c: 3 });
    });
  });

  it('Testando Hot Observable usando Subject real', () => {
    // Versão não-TestScheduler
    const subject = new Subject<number>();
    const values1: number[] = [];
    const values2: number[] = [];

    // Primeiro subscriber
    const subscription1 = subject.subscribe(val => values1.push(val));

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

    // Segundo subscriber (entra no meio)
    const subscription2 = subject.subscribe(val => values2.push(val));

    // Emite mais valores
    subject.next(3);
    subject.complete();

    // Verificação
    expect(values1).toEqual([1, 2, 3]);
    expect(values2).toEqual([3]); // Apenas valores após o início da subscription

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

NOTE

Cold Observable gera dados independentemente cada vez que você se inscreve, mas Hot Observable compartilha e distribui dados.

Mocking e Stubbing ​

Fazendo Mock de Serviços Dependentes ​

Ao testar serviços usando RxJS, é comum fazer mock de dependências 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;
}

// Serviço sob teste
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

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

describe('Teste de serviço', () => {
  it('Filtra apenas usuários ativos', () => {
    // Mock do serviço 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ção
    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 ​

Stubs são objetos simples que imitam dados externos ou APIs dos quais o código sob teste depende. Eles eliminam dependências de recursos externos e permitem que os testes sejam executados de forma independente. Eles simplesmente retornam valores fixos e não têm 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;
};

// Serviço sob teste
class UserService {
  constructor(private apiService: { fetchUsers: Function }) {}

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

describe('Teste de UserService', () => {
  it('Retorna apenas usuários ativos', () => {
    // 🔹 Criando stubs
    const stubApiService = {
      fetchUsers: () => of<User[]>([
        { id: 1, name: 'Tanaka', active: true },
        { id: 2, name: 'Sato', active: false },
        { id: 3, name: 'Yamada', active: true }
      ])
    };

    // Serviço sob teste
    const userService = new UserService(stubApiService);

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

Espiar subscriptions ​

Spy pode ser usado para verificar se as subscriptions estão sendo feitas corretamente.

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

describe('Teste de subscription', () => {
  it('Inscrevendo com handlers apropriados', () => {
    const subject = new Subject();

    // Cria spies de handler
    const nextSpy = vi.fn();
    const errorSpy = vi.fn();
    const completeSpy = vi.fn();

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

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

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

Melhores Práticas ​

Melhores práticasExplicação
Observe o princípio da responsabilidade únicaPara escrever código testável, cada função ou classe deve ter uma única responsabilidade. Dessa forma, o teste é simplificado.
Faça mock de dependências externasDependências externas como requisições http e timers devem ter mock feito e serem testadas em um ambiente previsível.
Use técnicas apropriadas para código assíncronoEscolha métodos apropriados para teste assíncrono, como TestScheduler, callbacks done() ou async/await.
Utilize marble testingPara testar streams complexos, use diagramas marble para representar expectativas de teste de maneira intuitiva.

Resumo ​

Testar código RxJS tem aspectos que diferem do código JavaScript tradicional, como sua natureza síncrona/assíncrona e comportamento dependente de tempo. Ao escolher uma metodologia de teste apropriada, você pode desenvolver código reativo de alta qualidade com confiança. Em particular, mantenha os seguintes pontos em mente:

  • Testes de subscription simples para Observable síncrono
  • TestScheduler ou transformações Promise para processamento assíncrono
  • Marble test para código dependente de tempo
  • Faça mock de dependências externas para criar um ambiente de teste independente
  • Projete código testável de acordo com o princípio da responsabilidade única

🔗 Seções Relacionadas ​

Lançado sob a licença CC-BY-4.0.