angularunit-testingrxjsbehaviorsubjectjasmine-marbles

Angular - why doesn't my marbles test work for my BehaviorSubject that contains user permissions?


I'm writing an app with Angular 8.

I decided to use an rxjs store with a BehaviorSubject for simple state management.

Basically, my code is a service that will store, load and update the user permissions. Sometimes this will come from the server after an HTTP call and sometimes this information will come from local storage. All subscribers will get updated after the user permissions have changed.

Here is my StackBlitz: https://stackblitz.com/edit/rxjs-state-management-get-set Please click the 'add profile' button to see it in action.

Here is my StackBlitz with the unit tests set up: https://stackblitz.com/edit/rxjs-state-management-get-set-xpptmr

I have written a few unit tests in there using the basic subscriber and done technique but I want to dig a little deeper.

I have read that we can use 'marbles' to test the value of an Observable over time: https://rxjs-dev.firebaseapp.com/guide/testing/internal-marble-tests

I want to use marbles to test the 'load' function in my code.

Code snippet of service store:

@Injectable({
    providedIn: 'root'
})
export class PersonaService {

    private readonly data: BehaviorSubject<IPersonaData>;
    public readonly sharedData: Observable<IPersonaData>;

    constructor() {
        this.data = new BehaviorSubject<IPersonaData>(null);
        this.sharedData = this.data.asObservable() as Observable<IPersonaData>;
    }
   
    public load(): void {

        const storedPersonaData: IPersonaData = {
            currentPersonaIndex: null,
            personas: []
        };

        const storedCurrentIndex: string = localStorage.getItem(Constants.KEYS.defaultPersonaIndex) as string;

        if (storedCurrentIndex != null) {
            storedPersonaData.currentPersonaIndex = parseInt(storedCurrentIndex, Constants.RADIX.BASE_10);
        }

        const storedPersonas: string = localStorage.getItem(Constants.KEYS.personas) as string;

        if (storedPersonas != null) {
            storedPersonaData.personas = JSON.parse(storedPersonas) as IPersona[];
        }

        this.data.next(storedPersonaData);
    }

    public clear(): void {
        this.data.next(null);
    }

}

Basically the 'load' function checks to see if there is any data stored in the local storage and if there is, it will update the BehaviorSubject value. I want to test how the value changes over time e.g. it was value 1 before calling the load function and not it is value 2 after calling the function.

03/07/2020 Attempt at marbles test ---- Edit:

Here is my code snippet for my marbles test:

  fit("clear", () =>
    testScheduler.run(({ expectObservable }) => {
      const newPersonaData: IPersonaData = {
        currentPersonaIndex: 1,
        personas: [...mockPersonas] //TODO: is this the best way to make a copy of it???
      };

      //put the data in the behaviour subject first
      localStorage.setItem(
        Constants.KEYS.defaultPersonaIndex,
        newPersonaData.currentPersonaIndex.toString()
      );
      localStorage.setItem(
        Constants.KEYS.personas,
        JSON.stringify(newPersonaData.personas)
      );
       
      //service.sharedData - initial value should be null
      service.load(); //service.sharedData - value should be newPersonaData

      service.clear(); //service.sharedData - value should be null

      expectObservable(service.sharedData).toBe("abc", {
        a: null,
        b: newPersonaData,
        c: null
      });
    }));

Error:

finished in 0.368sRan 1 of 9 specs - run all1 spec, 1 failure, randomized with seed 98448Spec List | FailuresSpec List | Failures
PersonaService > clear
Expected $.length = 1 to equal 3. Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: Object({ currentPersonaIndex: 1, personas: [ Object({ PersonaId: 154, Current: true, Description: 'Employee', SecuritySelectionId: 804356, DefaultSelectionId: 0, ProfileId: 17, ProfileName: '', IsSingleEmployee: true, DefaultEmpId: 714, EmpNo: '305', Level: -2, LevelDescription: 'Employee 1', AccessBand: 0, AuthBand: 0, PersonaIndex: 0, ExpiryDate: ' ', CoveringUserName: '', CoveringStartDate: ' ', CoveringEndDate: ' ', mobileaccess: true, canclock: true, mustsavelocation: true, canbookholiday: true, viewroster: true, canbookabsence: true, canviewbalancedetails: true, canviewactivity: true, canviewtimesheet: false, canviewtimesheetapproval: false, View_Shift_Swap: false, View_Flexi: true, View_Team_Calendar: false, Cancel_Absence: true, Delete_Absence: false, Mobile_Upload_Photo: true, Mobile_Upload_Absence_Photo: true, Receive_Notifications: false, CanViewRosterV2: false, AbsenceActions: [ Object({ .... Expected $[2] = undefined to equal Object({ frame: 2, notification: Notification({ kind: 'N', value: null, error: undefined, hasValue: true }) }).
Error: Expected $.length = 1 to equal 3. Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: Object({ currentPersonaIndex: 1, personas: [ Object({ PersonaId: 154, Current: true, Description: 'Employee', SecuritySelectionId: 804356, DefaultSelectionId: 0, ProfileId: 17, ProfileName: '', IsSingleEmployee: true, DefaultEmpId: 714, EmpNo: '305', Level: -2, LevelDescription: 'Employee 1', AccessBand: 0, AuthBand: 0, PersonaIndex: 0, ExpiryDate: ' ', CoveringUserName: '', CoveringStartDate: ' ', CoveringEndDate: ' ', mobileaccess: true, canclock: true, mustsavelocation: true, canbookholiday: true, viewroster: true, canbookabsence: true, canviewbalancedetails: true, canviewactivity: true, canviewtimesheet: false, canviewtimesheetapproval: false, View_Shift_Swap: false, View_Flexi: true, View_Team_Calendar: false, Cancel_Absence: true, Delete_Absence: false, Mobile_Upload_Photo: true, Mobile_Upload_Absence_Photo: true, Receive_Notifications: false, CanViewRosterV2: false, AbsenceActions: [ Object({ .... Expected $[2] = undefined to equal Object({ frame: 2, notification: Notification({ kind: 'N', value: null, error: undefined, hasValue: true }) }). at <Jasmine> at TestScheduler.deepEqual [as assertDeepEqual] (https://rxjs-state-management-get-set-xpptmr.stackblitz.io/~/src/testing/persona.service.spec.ts:8:20) at eval (https://rxjs-state-management-get-set-xpptmr.stackblitz.io/turbo_modules/rxjs@6.5.5/internal/testing/TestScheduler.js:133:23) at <Jasmine>

Example: https://stackblitz.com/edit/rxjs-state-management-get-set-xpptmr?embed=1&file=src/testing/persona.service.spec.ts


Solution

  • What we nee to do is use ReplaySubject to capture all of the values that the Observable had. If we use BehaviourSubject, it will only give us the final value and not all of the values that it was in the past.

    example:

    it('2a clear - should clear the data from the BehaviorSubject - should be null, data, null', () => testScheduler.run(({ expectObservable }) => {
    
        const replaySubject$ = new ReplaySubject<IPersonaData>();
        service.sharedData.subscribe(replaySubject$);
    
        const newPersonaData: IPersonaData = {
            currentPersonaIndex: 1,
            personas: [...mockPersonas] //TODO: is this the best way to make a copy of it???
        };
    
        //put the data in the behaviour subject first
        appSettings.setString(Constants.KEYS.defaultPersonaIndex, newPersonaData.currentPersonaIndex.toString());
        appSettings.setString(Constants.KEYS.personas, JSON.stringify(newPersonaData.personas));
    
        service.load();
        service.clear();
    
        expectObservable(replaySubject$).toBe('(abc)', { a: null, b: newPersonaData, c: null });
    
    }));