I'm trying to find a way of using marble testing to test side efects with async pipes. I've created a simple POC in Stackblitz so you may test it for yourselves https://stackblitz.com/edit/angular-ivy-pzbtqx?file=src/app/app.component.spec.ts
I'm using the result from a service method, which returns an Observable
of either an object or null (see component file), and the *ngIf
directive with an async
pipe to either display or hide some html element depending wether the result from the method was an object or null (see html file).
Now I would like to create a Unit Test for the aforementioned case using marble testing however when I use the cold
observable as the return value from my mocked service. It is allways being interpreted as null (or to be more exact falsy) by the async
pipe.
<h1>Marble Tests POC</h1>
<div id="conditional" *ngIf="value$ | async">
<p>My Conditional message!</p>
</div>
export class AppComponent implements OnInit {
value$: Observable<{} | null>;
constructor(private myService: MyService) {}
ngOnInit(): void {
this.value$ = this.myService.getValue(false);
}
}
describe('AppComponent', () => {
let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
let mockedMyService = new MyServiceMock();
let getValueSpy: jasmine.Spy;
beforeEach(() => {
getValueSpy = spyOn(mockedMyService, 'getValue').and.returnValue(of({}));
});
beforeEach(async () => {
// module definition providers and declarations...
});
beforeEach(() => {
// fixture and component initialization...
});
it('should display message when service returns different than null', () => {
const testCase$ = cold('a', { a: {} });
// if you comment the following line or use a normal Observable [of({})] instead of the
// coldObservable the test passes without issues.
getValueSpy.and.returnValue(testCase$);
component.ngOnInit();
getTestScheduler().flush();
fixture.detectChanges();
const conditionalComponent = fixture.debugElement.query(
By.css('#conditional')
);
expect(conditionalComponent).not.toBeNull(); // Expected null not to be null.
});
});
I'm thinking the issue is that the async
pipe seems not to work with ColdObservable
s at all or at least it seems to be working in a different way than with normal Observable
s. Now I know this can be tested without marble testing; that is the old way with fakeAsync
or done
function, but I would love to use marble testing since is way simpler to reason about.
I came up with this idea from the example given on the Angular - Component testing scenarios documentation which gives the following testCase with jasmine-marbles:
it('should show quote after getQuote (marbles)', () => {
// observable test quote value and complete(), after delay
const q$ = cold('---x|', { x: testQuote });
getQuoteSpy.and.returnValue( q$ );
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
getTestScheduler().flush(); // flush the observables
fixture.detectChanges(); // update view
expect(quoteEl.textContent)
.withContext('should show quote')
.toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
});
As you can see. they use the flush()
method to run the coldObservable
and then use the detectChanges()
method to update the view.
Before someone links Jasmine marble testing observable with ngIf async pipe as duplicate please note that question does not have a good answer and the OP did not post a comprehensive solution to his problem
Thanks to akotech for the answer I'm providing bellow!
it('should display message when service returns different than null', () => {
const testCase$ = cold('a-b-a', { a: {}, b: null });
getValueSpy.and.returnValue(testCase$);
component.ngOnInit();
// Add the following detectChanges so the view is updated and the async pipe
// subscribes to the new observable returned above
fixture.detectChanges();
getTestScheduler().flush();
fixture.detectChanges();
const conditionalComponent = fixture.debugElement.query(
By.css('#conditional')
);
expect(conditionalComponent).not.toBeNull();
});
We are modifying the returned Observable
from the mocked service.
In the beforeEach
callback we are returning an Observable
from the of
operator (we will call this Obs1
). Then we are modifying this return value on the actual test returning now the TestColdObservable
(we shall call this Obs2
).
beforeEach(() => {
getValueSpy = spyOn(mockedMyService, 'getValue').and.returnValue(of({}));
// --^--
// Obs1
});
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
// ...
fixture.detectChanges();
});
// ...
it('should display message when service returns different than null', () => {
const testCase$ = cold('a', { a: {} }); // Obs2 definition
getValueSpy.and.returnValue(testCase$);
// --^--
// Obs2
component.ngOnInit();
getTestScheduler().flush(); // We flush instead of update the async pipe's subscription
// ...
}
We know that the first thing to be executed before our tests is the beforeEach
callback and in case of multiple callbacks they are executed in order. So first we set the mock to return Obs1
then we call createComponent()
and detectChanges()
which in turn invokes ngOnInit()
and refreshes the view respectively. When the view is refreshed the async
pipe subscribes to the Obs1
returned by the mock.
After executing the beforeEach
callback. We start executing the actual test and the first thing we do is modify the returned value of the mock to now return Obs2
. Then we call the ngOnInit
method to change the observable value$
so it points to Obs2
. However, instead of updating the view so the async
pipe updates it's subscription to Obs2
. We proceeded to flush
the observables leaving the async
pipe pointing to Obs1
rather than Obs2
;
[value$] ------------(Obs1)--------------------------------------(Obs2)------|------------------------------------------------
[asyncPipe] -----------------------------(Obs1)---------------------------------|-(we subscribe to an already flushed observable)
^ ^ ^ ^ ^ ^ ^
returnValue createComponent detectChanges returnValue ngOnInit |flush!| detectChanges
(Obs1) (ngOnInit) (Obs2)
beforeEach beforeEach2----------------------- test ------------------------------------------------------------------
[value$] ------------(Obs1)------------------------------------(Obs2)----------------------|----------------------
[asyncPipe] -----------------------------(Obs1)------------------------(We subscribe first)---|--------(Obs2)--------
| | | | | (Obs2) | |
^ ^ ^ ^ ^ ^ ^ ^
returnValue createComponent detectChanges returnValue ngOnInit detectChanges |flush!| detectChanges
(Obs1) (ngOnInit) (Obs2)
beforeEach beforeEach2--------------------- test --------------------------------------------------------