Reputation: 1514
I would like to find a way to test unsubscribe function calls on Subscriptions and Subjects.
I came up with a few possible solutions, but every one of these have pros and cons. Please keep in mind that I do not want to alter the access modifier of a variable for testing purposes.
In that case I have a private class variable which stores a subscription:
component.ts:
private mySubscription: Subscription;
//...
ngOnInit(): void {
this.mySubscription = this.store
.select(mySelector)
.subscribe((value: any) => console.log(value));
}
ngOnDestroy(): void {
this.mySubscription.unsubscribe();
}
component.spec.ts:
spyOn(component['mySubscription'], 'unsubscribe');
component.ngOnDestroy();
expect(component['mySubscription'].unsubscribe).toHaveBeenCalledTimes(1);
pros:
cons:
component.ts: same as in option 1
component.spec.ts:
spyOn(Subscription.prototype, 'unsubscribe');
component.ngOnDestroy();
expect(Subscription.prototype.unsubscribe).toHaveBeenCalledTimes(1);
pros:
cons:
subscription.helper.ts:
export class SubscriptionHelper {
static unsubscribeAll(...subscriptions: Subscription[]) {
subscriptions.forEach((subscription: Subscription) => {
subscription.unsubscribe();
});
}
}
component.ts: same as in option 1, but ngOnDestroy is different:
ngOnDestroy(): void {
SubscriptionHelper.unsubscribeAll(this.mySubscription);
}
component.spec.ts:
spyOn(SubscriptionHelper, 'unsubscribeAll');
component.ngOnDestroy();
expect(SubscriptionHelper.unsubscribeAll).toHaveBeenCalledTimes(1);
pros:
cons:
What do you guys suggest? How do you test the cleanup in unit test?
Upvotes: 31
Views: 42171
Reputation: 1514
In Angular 16 we'll have access to a new token: DestroyRef.
With it, we can create a util function, which takes care of unsubscribing from Subscriptions:
export function destroyable<T>(observable: Observable<T>): Observable<T> {
const subject = new Subject();
inject(DestroyRef).onDestroy(() => {
subject.next(true);
subject.complete();
});
return observable.pipe(takeUntil(subject));
}
Usage in component:
ngOnInit(): void {
destroyable(this.store.select(mySelector1))
.subscribe((value: any) => console.log(value));
destroyable(this.store.select(mySelector2))
.subscribe((value: any) => console.log(value));
}
This solution is fully unit testable:
import * as util from '../util/destroyable';
it('should wrap interval in destroyable', () => {
jest.spyOn(util, 'destroyable');
fixture.detectChanges();
expect(util.destroyable).toHaveBeenCalledWith(store.select(mySelector1));
});
I hope it helps you with dealing with Subscriptions.
Upvotes: 1
Reputation: 101
.component.ts
:
...
private someSubscription!: Subscription;
...
ngOnInit(): void {
this.someSubscription = ... .subscribe(() => ...);
}
...
ngOnDestroy(): void {
this.someSubscription.unsubscribe();
}
...
.component.spec.ts
:
...
it('should unsubscribe', () => {
component['someSubscription'] = of().subscribe();
const unsubscriptionSpy = jest.spyOn(component['someSubscription'], 'unsubscribe');
component.ngOnDestroy();
expect(unsubscriptionSpy).toHaveBeenCalled();
});
...
When the code inside ngOnDestroy (component) is modified, the test fails. Therefore, this solution was working fine for me.
Upvotes: 3
Reputation: 1110
The most pragmatic approach is getting things into your hands and just use language feature.
const cmp = fixture.componentInstance as any;
cmp.mySubscription = jasmine.createSpyObj<Subscription>(['unsubscribe']);
Please remember it's absolutelly not a pattern for testing private code. You still have to test public pieces to indirectly prove the private code.
Upvotes: 0
Reputation: 8452
Lucien Dulac and Paritosh got it almost right:
component.ts
:
private subscriptions = new Subscription();
constructor(): void {
this.subscriptions.add(...);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
component.spec.ts
:
spyOn(component['subscriptions'], 'unsubscribe');
component.ngOnDestroy();
expect(component['subscriptions'].unsubscribe).toHaveBeenCalled();
Upvotes: -1
Reputation: 616
I figure out a way to make it work.
First of all, I do not create a subscription instance for each subscription, I usually use only one instance of subscription and add into as many subscriptions I need by using me method 'add' that implements a teardownLogic and allows child subscriptions.
private subscriptions: Subscription;
//...
ngOnInit(): void {
this.mySubscription.add(
this.store
.select(mySelector)
.subscribe((value: any) => console.log(value))
);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
By doing that, in my tests I can rely on the Subscriptions prototype and detects the unsubscribe method calls. In that case after I call the ngOnDestroy.
const spy = spyOn(Subscription.prototype, 'unsubscribe');
component.ngOnDestroy();
expect(spy).toHaveBeenCalledTimes(1);
Upvotes: 11
Reputation: 3778
I had exactly the same problem, here's my solution:
component.ts:
private subscription: Subscription;
//...
ngOnInit(): void {
this.subscription = this.route.paramMap.subscribe((paramMap: ParamMap) => {
// ...
});
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
component.spec.ts:
let dataMock;
let storeMock: Store;
let storeStub: {
select: Function,
dispatch: Function
};
let paramMapMock: ParamMap;
let paramMapSubscription: Subscription;
let paramMapObservable: Observable<ParamMap>;
let activatedRouteMock: ActivatedRoute;
let activatedRouteStub: {
paramMap: Observable<ParamMap>;
};
beforeEach(async(() => {
dataMock = { /* some test data */ };
storeStub = {
select: (fn: Function) => of((id: string) => dataMock),
dispatch: jasmine.createSpy('dispatch')
};
paramMapMock = {
keys: [],
has: jasmine.createSpy('has'),
get: jasmine.createSpy('get'),
getAll: jasmine.createSpy('getAll')
};
paramMapSubscription = new Subscription();
paramMapObservable = new Observable<ParamMap>();
spyOn(paramMapSubscription, 'unsubscribe').and.callThrough();
spyOn(paramMapObservable, 'subscribe').and.callFake((fn: Function): Subscription => {
fn(paramMapMock);
return paramMapSubscription;
});
activatedRouteStub = {
paramMap: paramMapObservable
};
TestBed.configureTestingModule({
// ...
providers: [
{ provide: Store, useValue: storeStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub }
]
})
.compileComponents();
}));
// ...
it('unsubscribes when destoryed', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(paramMapSubscription.unsubscribe).toHaveBeenCalled();
});
This works for me, I hope it will for you too !
Upvotes: 14
Reputation: 127
Does this look good to you ?
component.mySubscription = new Subscription();
spyOn(component.mySubscription, 'unsubscribe');
component.ngOnDestroy();
expect(component.mySubscription.unsubscribe).toHaveBeenCalledTimes(1);
Upvotes: 9
Reputation: 17762
I move away from COMMENTS, to gain some more space, even if mine is just an open reasoning. I hope this is acceptable by SO.
I would like to test that the component calls unsubscribe on every subscription
I think this is an interesting case of test design.
We all know that it is important not to leave subscriptions alive when a component is destroyed, and so it is worth thinking how to test this.
At the same time this could be seen as an implementation detail and it certainly does not belong to the "publicly accessible behavior" which a SW component exposes via its APIs. And public APIs should be the focus area of tests if you do not want to couple tightly tests and code, which is something that impedes refactoring.
Plus AFAIK RxJS does not provide a mechanism to list all active subscriptions at a given moment, so the task of finding a way to test this remains on us.
So, if you want to reach your objective of testing that all subscriptions are unsubscribed, you need to adopt a specific design to allow this, e.g. the use of an array where you store all of your subscriptions and then a check to see if all of them have been closed. This is the line of your SubscriptionHelper
.
But even this does not necessarly put you in a safe place. You can always create a new subscription and not add it to the array, maybe simply because you forget to do this in your code.
So, you can test this only if you stick to your coding discipline, i.e. you add subscriptions to the array. But this is equivalent of making sure you unsubscribe all of your subscriptions in ngOnDestroy
method. Even this is coding discipline.
So, does it really make sense to have such test even if the end objective of the test is important? Shouldn't it be something to acheive via code review?
Very curious to read opinions
Upvotes: 2
Reputation: 11570
Regarding your last point
I can not test that the unsubscribe function was called on a specific subscription.
You can do that actually.
// get your subscrption
const mySubscription = component['mySubscription'];
// use the reference to confirm that it was called on that specific subscription only.
expect(SubscriptionHelper.unsubscribeAll).toHaveBeenCalledWith(mySubscription);
Now, when it comes to testing, it should just check/access/call the public members, and test/expect its effects.
Using the luxury of javascript, you are expecting/validating private members, even that is fine I think. Even I do that, I may be wrong. Productive criticism and comments are welcomed.
Upvotes: 1