This puzzle was quite challenging to crack, but the key solution lies in making the mock injectable.
It might seem like a workaround initially, as it involves unnecessary work. Most Angular-specific libraries offer a service that serves as a factory for decoupling, which is excellent. However, rxjs, despite its heavy use in the Angular framework, is not an Angular library.
In short, you need to encapsulate `webSocket` within something injectable.
A Brief Overview of the Core Issue
The general unmockability of the `webSocket` constructor stems from how modules function. While there are some workarounds depending on the module types used and control over exporting modules, they are temporary fixes. The trend towards read-only imports has gradually rendered most existing workarounds ineffective. This issue on Jasmine's GitHub discusses the workarounds and eventually outlines why finding a universal solution remains elusive.
So, What's the Game Plan?
Jasmine offers official guidance in its FAQ:
- Utilize dependency injection for components requiring mocking, allowing you to inject a spy or mock object from the spec. This method often leads to improved maintainability in both specs and test code. The need to mock modules typically indicates tightly coupled code, urging developers to address the root cause rather than rely on testing tools as solutions.
Solid advice indeed! It highlights the dependency nature of the issue, with the desired functionality closely intertwined with the constructor. On another note, faulting the rxjs team proves challenging, given the situation at hand. Solving this problem demands a solution tailored to the framework.
You have two viable options at your disposal:
- Create a service.
- Formulate a factory function.
Developing a Service
While straightforward, this approach surprisingly wasn't my initial attempt. Simply create a new service with a single public method sporting the same signature:
@Injectable()
export class WebSocketFactoryService {
constructor(){}
public makeSocket<T>(urlConfigOrSource: string | WebSocketSubjectConfig<T>): WebSocketSubject<T> {
return webSocket<T>(urlConfigOrSource);
}
}
Using Constructor Injection
This method may appear a bit messy, but it eliminates the need to craft a separate file for the factory service. Having multiple tools in your arsenal always proves beneficial:
Check out the StackBlitz link featuring a test suite for an application necessitating websocket creation within a service and subsequent injection into a component (no more "missing core-js" woes either). Interestingly, Angular.io provides a guide for this specific scenario, albeit locating it took some effort.
Start by defining an InjectionToken
since this isn't a class:
// I opted for 'rxjsWebsocket' import naming solely for webSocket usage in my service
import { webSocket as rxjsWebsocket, WebSocketSubject } from 'rxjs/webSocket';
// Fascinatingly, utilizing 'typeof rxjsWebsocket' accurately denotes 'whatever that thing is'
export const WEBSOCKET_CTOR = new InjectionToken<typeof rxjsWebsocket>(
'rxjs/webSocket.webSocket', // Error indicator if missing
{
providedIn: 'root',
factory: () => rxjsWebsocket, // Default creation mechanism unless a custom provider is designated, as we'll do in the spec
}
);
Subsequently, instruct your Service to treat it as any other dependency for injection:
@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!
Tackling Test Scenarios
Oh wait, the tests! Initially, you must devise a mock replica. Various methods exist, but I employed the following setup for my injection token version:
// Simulating the websocket activity
let fakeSocket: Subject<any>; // Exposed for spying purposes and emulating server interactions
const fakeSocketCtor = jasmine
.createSpy('WEBSOCKET_CTOR')
.and.callFake(() => fakeSocket); // Fake invocation required for continuous reassignment to fakeSocket
If you opted for a service instead, consider leveraging a spy object:
const fakeSocketFactory = jasmine.createSpyObj(WebSocketFactoryService, 'makeSocket');
fakeSocketFactory.makeSocket.and.callFake(() => fakeSocket);
Regardless of your choice, having an openly accessible subject simplifies resetting efforts.
Creating the service entails using the constructor provided!
beforeEach(() => {
// Instantiate a fresh socket to prevent lingering values across tests
fakeSocket = new Subject<any>();
// Apply spying tactics to avoid subscription requirements for verification purposes
spyOn(fakeSocket, 'next').and.callThrough();
// Reset your spies
fakeSocketCtor.calls.reset();
// Employ the ctor to establish the service
service = new SocketService(fakeSocketCtor);
// Alternatively, leverage 'fakeSocketFactory' for different approach
});
Now you're ready to delve into the realm of testing Observables, just as you intended hours ago!