Reputation: 575
I try to test a simple angular component using a marble test. For that I'm using the TestScheduler
which comes together with rxjs.
Here is a stackblitz link with the code: https://stackblitz.com/edit/angular-ivy-xwzn1z
This is a simplified version of my component:
@Component({
selector: 'detail-component',
template: ` <ng-container *ngIf="(detailsVisible$ | async)"> <p> detail visible </p> </ng-container>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DetailComponent implements OnInit, OnDestroy {
@Input() set isAdditionalContentVisible(isAdditionalContentVisible: boolean) {
this.resetSubject.next(isAdditionalContentVisible);
}
private readonly resetSubject = new Subject<boolean>();
private readonly toggleVisibilitySubject = new Subject<void>();
private readonly destroySubject = new Subject();
public detailsVisible$: Observable<boolean> = this.toggleVisibilitySubject.pipe(
scan((state, _) => !state, false),
startWith(false)
);
private readonly resetDetailsVisibilitySideEffect$: Observable<void> = this.resetSubject.asObservable().pipe(
withLatestFrom(this.detailsVisible$),
map(([resetTrigger, state]) => {
if (state !== resetTrigger) {
this.toggleVisibilitySubject.next();
}
})
);
constructor() {}
ngOnInit(): void {
this.resetDetailsVisibilitySideEffect$.pipe(takeUntil(this.destroySubject)).subscribe();
}
ngOnDestroy(): void {
this.destroySubject.next();
this.destroySubject.complete();
}
toggleAdditionalContentVisibility(): void {
this.toggleVisibilitySubject.next();
}
}
I want to test the detailsVisible$
-observable.
For that I created following test:
import { TestScheduler } from 'rxjs/testing';
describe('DetailComponent', () => {
const debug = true;
let scheduler: TestScheduler;
let component: DetailComponent;
beforeEach(() => {
component = new DetailComponent();
scheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
if (debug) {
console.log('-------------------------------');
console.log('Expected:\n' + JSON.stringify(expected, null, 2));
console.log('Actual:\n' + JSON.stringify(actual, null, 2));
}
expect(actual).toEqual(expected);
});
});
it('should finally work out', () => {
scheduler.run((helpers) => {
const { cold, hot, expectObservable, expectSubscriptions } = helpers;
const values = {
f: false,
t: true
};
const toggleVisibilityValues = {
v: void 0
};
const resetValues = {
f: false,
t: true
};
component.ngOnInit();
// marbles
// prettier-ignore
const detailsVisibleMarble = 'f-t-f-t-f-t-f';
// prettier-ignore
const toggleVisibilityMarble = '--v-v-----v--';
// prettier-ignore
const resetMarble = '------t-f---f';
// Mock observables
(component as any).toggleVisibilitySubject = cold(toggleVisibilityMarble,toggleVisibilityValues);
(component as any).resetSubject = cold(resetMarble, resetValues);
// output
expectObservable(component.detailsVisible$).toBe(detailsVisibleMarble, values);
});
});
});
I tried several things but all are resulting in the follwing output:
Expected $.length = 1 to equal 7.
Expected $[1] = undefined to equal Object({ frame: 2, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
Expected $[2] = undefined to equal Object({ frame: 4, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
Expected $[3] = undefined to equal Object({ frame: 6, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
Expected $[4] = undefined to equal Object({ frame: 8, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
Expected $[5] = undefined to equal Object({ frame: 10, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
Expected $[6] = undefined to equal Object({ frame: 12, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
<Jasmine>
So somehow the source of detailsVisible$
(toggleVisibilitySubject) is never emitting any value (I only get the startWith
-value in the result).
I do not see what I'm missing. The code itself works perfectly fine.
Thanks for any suggestions.
Edit: I also tried out to
toggle$ = this.toggleVisibilitySubject.asObservable();
public detailsVisible$ = this.toggle.pipe(...)
and in the test: component.toggle$ =cold(toggleVisibilityMarble,toggleVisibilityValues)
.
Upvotes: 1
Views: 1995
Reputation: 575
So finally I found out what the problem was. The answer of @Andrei Gătej is partly correct. So indeed the problem is that when creating the component the existing subject instance is taken and not the mocked subjects.
The solution is the following:
@Component({
selector: 'detail-component',
template: ` <ng-container *ngIf="(detailsVisible$ | async)"> <p> detail visible </p> </ng-container>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DetailComponent implements OnInit, OnDestroy {
@Input() set isAdditionalContentVisible(isAdditionalContentVisible: boolean) {
this.resetSubject.next(isAdditionalContentVisible);
}
private resetSubject = new Subject<boolean>();
private toggleVisibilitySubject = new Subject<void>();
private readonly destroySubject = new Subject();
toggleVisibility$: Observable<void> =this.toggleVisibilitySubject.asObservable();
reset$: Observable<boolean> = this.resetDetailsVisibilitySubject.asObservable();
detailsVisible$: Observable<boolean>;
resetDetailsVisibilitySideEffect$: Observable<void>;
constructor() {}
ngOnInit(): void {
this.detailsVisible$ = this.toggleVisibility$.pipe(
scan((state, _) => !state, false),
startWith(false)
);
this.resetDetailsVisibilitySideEffect$ = this.reset$.pipe(
withLatestFrom(this.detailsVisible$),
map(([resetTrigger, state]) => {
if (state !== resetTrigger) {
this.toggleVisibilitySubject.next();
}
})
);
this.resetDetailsVisibilitySideEffect$.pipe(takeUntil(this.destroySubject)).subscribe();
}
ngOnDestroy(): void {
this.destroySubject.next();
this.destroySubject.complete();
}
toggleAdditionalContentVisibility(): void {
this.toggleVisibilitySubject.next();
}
}
Note: the observables are now wired together in ngOnInit()
.
In the test it is then important that ngOnInit()
will be called after the observables have been mocked.
Upvotes: 0
Reputation: 11979
I think the problem is that when the component is created(in beforeEach()
), detailsDivible$
will already be created based on an existing Subject
instance.
It would be loosely the same as doing this:
// the initial `toggleVisibilitySubject`
f = () => 'a';
// when the component is created
details = f();
// (component as any).toggleVisibilitySubject = cold(...)
f = () => 'b';
details // "a"
With this in mind, I think one approach would be:
scheduler.run(helpers => {
/* ... */
const detailsVisibleMarble = 'f-t-f-t-f-t-f';
// prettier-ignore
const toggleVisibilityMarble = '--v-v-----v--';
// prettier-ignore
const resetMarble = '------t-f---f';
const toggleEvents$ = cold(toggleVisibilityMarble,toggleVisibilityValues)
const src$ = merge(
toggleEvents$.pipe(
tap(value => (component as any).toggleVisibilitySubject.next(undefined)),
// we just want to `feed` the subject, we don't need the value of the events
ignoreElements(),
),
// the observable whose values we're interested in
component.detailsVisible$,
)
expectObservable(src$).toBe(detailsVisibleMarble, values);
});
This might not work yet, as I think toggleVisibilityMarble
and detailsVisibleMarble
do not match. So I'd change detailsVisibleMarble
to
const detailsVisibleMarble = 'f-t-f-----t--';
Upvotes: 0