ajorquera
ajorquera

Reputation: 1309

How to mock rxjs/Websocket in angular for Unit Testing

I created a service which handles websocket communication with a server and Im trying to create unit tests for it but I cant find a way to mock rxjs/Websocket.

I found a similar question here, but I cant use it with the new version of rxjs. Any help will be very helpful.

I could also, inject WebSocket as a service and mock the service in my test but it seems like a workaround and I prefer a better solution

Here is mi code:

socket.service.ts

//imports 

@Injectable()
export class SocketService {
  private baseUrl = ENV.WS_WORKSPACES_URL;

  socket$: WebSocketSubject<any>;

  constructor(
    private userService: UserService
  ) { }

  initSocket(): void {
    this.socket$ = webSocket(`${this.baseUrl}clusters`);
    const user = this.userService.getUser();

    if (user) {
      this.send({token: user.token});
    }
  }

  send(data: any): void {
    this.socket$.next(data);
  }
}

socket.service.spec.ts

//imports

describe("SocketService", () => {
  let service: SocketService;
  let userServiceSpy;
  let socket;
  const token = "whatever";

  beforeEach(() => {
    userServiceSpy = jasmine.createSpyObj("UserService", ["getUser"]);
    userServiceSpy.getUser.and.returnValue(null);
    socket = {next: jasmine.createSpy()};
    TestBed.configureTestingModule({
      providers: [
        {provide: UserService, useValue: userServiceSpy},
        SocketService
      ]
    });

    service = TestBed.inject(SocketService);

    socket = {} //this should be my mock
  });

  it("should be created", () => {
    expect(service).toBeTruthy();
  });

  it("should open connection", () => {
    service.initSocket();

    expect(socket.next).not.toHaveBeenCalled();
    expect(service.socket$).toBeDefined();
  });

  it("should open connection with auth", () => {
    const user = {token};
    userServiceSpy.getUser.and.returnValue(user);

    service.initSocket();
    expect(socket.next).toHaveBeenCalledWith(user);
  });

  it("should send a message", () => {
    service.initSocket();

    const message = {};
    service.send(message);

    expect(socket.next).toHaveBeenCalledWith(message);
  });
});

Upvotes: 2

Views: 3693

Answers (1)

John Neuhaus
John Neuhaus

Reputation: 1937

This was a particularly tricky one to solve, but the answer here really is to make what you want to mock injectable instead.

It feels like a workaround, because it's work that seems unnecessary. Most Angular-specific libraries would export a service that acts like a factory to decouple it, which is awesome. But rxjs isn't an Angular library, despite how heavily the framework relies on it.

TLDR; You're gonna have to wrap webSocket in something injectable.

Short background on the general problem

The webSocket constructor is not mockable in general because of how modules work. There's some finagling you can do depending on what type of modules you're using and whether you have control over the exporting module, but they're just workarounds. The evolution has been towards imports being read-only, which has slowly broken most existing workarounds. This issue on jasmine's github covers the workarounds and (eventually) the reasons why a general solution is elusive.

Wtf am I supposed to do then?

Jasmine provides some official guidance in its FAQ:

  1. Use dependency injection for things you’ll want to mock, and inject a spy or a mock object from the spec. This approach usually results in maintainability improvements in the specs and the code under test. Needing to mock modules is often a sign of tightly coupled code, and it can be wise to fix the coupling rather than work around it with testing tools.

Always great advice! It's certainly a dependency, and the functionality we want to mock is very coupled with the constructor. Sidenote: It's hard to fault the rxjs team here, as frustrating as it is. It's a problem requiring a framework-specific solution.

There's two options here, both are decent.

  1. Make a service.
  2. Make a factory function.

Make a Service

This is easy, and embarrassingly not the first thing I tried. Just make a new Service and give it one public method with the same signature:

@Injectable()
export class WebSocketFactoryService {

  constructor(){}

  public makeSocket<T>(urlConfigOrSource: string | WebSocketSubjectConfig<T>): WebSocketSubject<T> {
    return webSocket<T>(urlConfigOrSource);
  }
}

Injecting a constructor

This one's slightly messier, but you don't have to make a new file for the factory service. It's also good to have more than one tool in your belt.

Here's the Stackblitz link to a test suite for an app with a service that needs to make a websocket and a component that injects said service (it also doesn't have that "missing core-js" problem.) Angular.io even has a guide for this exact scenario, albeit it was a little tough to find.

First you make an InjectionToken, since this isn't a class:

// I only imported as rxjsWebsocket because I wanted to use webSocket in my service
import { webSocket as rxjsWebsocket, WebSocketSubject } from 'rxjs/webSocket';

// Fun fact: You can use "typeof rxjsWebsocket" as the type to cleanly say "whatever that thing is"
export const WEBSOCKET_CTOR = new InjectionToken<typeof rxjsWebsocket>(
  'rxjs/webSocket.webSocket', // This is what you'll see in the error when it's missing
  {
    providedIn: 'root',
    factory: () => rxjsWebsocket, // This is how it will create the thing needed unless you offer your own provider, which we'll do in the spec
  }
);

Then you need to tell your Service to inject it like any other dependency:

@Injectable({
  providedIn: 'root',
})
export class SocketService {
  socket$: WebSocketSubject<any>;

  constructor(
    @Inject(WEBSOCKET_CTOR) private _webSocket: typeof rxjsWebsocket
  ) {
    this.socket$ = this._webSocket<any>('https://stackoverflow.com/');
  }

  messages(): Observable<any> {
    return this.socket$.asObservable();
  }

  send(data: any): void {
    this.socket$.next(data);
  }
}

Problem solved!

Writing the test

Oh wait, the test. First you need to actually make a mock. There's several ways, but I used this for my injection token version:

// Mocking the websocket
let fakeSocket: Subject<any>; // Exposed so we can spy on it and simulate server messages
const fakeSocketCtor = jasmine
  .createSpy('WEBSOCKET_CTOR')
  .and.callFake(() => fakeSocket); // need to call fake so we can keep re-assigning to fakeSocket

If you made a service instead, you'll probably want a spy object instead

const fakeSocketFactory = jasmine.createSpyObj(WebSocketFactoryService, 'makeSocket');
fakeSocketFactory.makeSocket.and.callFake(() => fakeSocket);

You still want an exposed subject you can continually reset though.

Creating the service is easy, just use the constructor!

  beforeEach(() => {
    // Make a new socket so we don't get lingering values leaking across tests
    fakeSocket = new Subject<any>();
    // Spy on it so we don't have to subscribe to verify it was called
    spyOn(fakeSocket, 'next').and.callThrough();

    // Reset your spies
    fakeSocketCtor.calls.reset();

    // Make the service using the ctor
    service = new SocketService(fakeSocketCtor);
    // or service = new SocketService(fakeSocketFactory); if you did that one
  });

Now you're free to fight with testing Observables like you wanted to do several hours ago!

Upvotes: 3

Related Questions