[Fixed] Jasmine reusable FireStore mock for Angular service testing

Issue

I’m trying to make a reusable firestore mock to test several angular services.
My services look like this :

@Injectable({
  providedIn: 'root',
})
export class DataSheetService {
  dataSheetTypesDbRef: AngularFirestoreCollection<DataSheetType>;

  constructor(private db: AngularFirestore) {
    this.dataSheetTypesDbRef = this.db.collection<DataSheetType>(DBNAMES.dataSheetsTypes);
  }

  getDataSheetsTypes(): Observable<DataSheetType[]> {
    return this.dataSheetTypesDbRef.snapshotChanges().pipe(
      map((actions) => {
        return actions.map((a) => {
          const data = a.payload.doc.data();
          const id = a.payload.doc.id;
          return { id, ...data };
        });
      })
    );
  }

  saveDataSheetType(newType): Observable<DataSheetType> {
    return from(
      this.dataSheetTypesDbRef
        .add(typeToSave)
        .then((docRef) => {
          return { id: docRef.id, ...typeToSave };
        })
        .catch((e) => {
          throw new Error(e);
        })
    );
  }
}

I’ve been able to make a simple function to mock firestore and test the firestore collection and snapshot, but I can’t change the returned data on the fly, so I need to rewrite it everytime.

const formatData = (data) => {
  const dataToReturn = data?.map((data) => {
    const { id, ...docData } = data;
    return {
      payload: {
        doc: {
          data: () => docData,
          id: id || Math.random().toString(16).substring(2),
        },
      },
    };
  });
  return dataToReturn;
};

const collectionStub = (data) => ({
  snapshotChanges: () => of(formatData(data)),
});
export const angularFireDatabaseStub = (data) => ({ collection: jasmine.createSpy('collection').and.returnValue(collectionStub(data)) });

And my test :

describe('DataSheetService', () => {
  const payload = [{ name: 'lamps' }, { name: 'mirrors' }];
  let service: DataSheetService;
  let angularFirestore: AngularFirestore;
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        DataSheetService,
        { provide: AngularFirestore, useValue: angularFireDatabaseStub(payload) },
        { provide: AuthService, useClass: AuthServiceMock },
      ],
    });
    service = TestBed.inject(DataSheetService);
    angularFirestore = TestBed.inject(AngularFirestore);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
    expect(angularFirestore.collection).toHaveBeenCalled();
  });

  it('should return list of data sheets types', async () => {
    const types$ = service.getDataSheetsTypes();
    types$.subscribe((types) => {
      expect(types.length).toBe(2);
      expect(Object.keys(types[1])).toContain('id');
      expect(types[1]).toEqual(jasmine.objectContaining({ name: 'mirrors' }));
    });
  });
});

with angularFireDatabaseStub function all tests pass, but what I’m trying to achieve is to make a third case where the returned data from the service.getDataSheetsTypes method changes, as this data is harcoded in the call of angularFireDatabaseStub, I’m trying to make a different approach.

export class FireStoreMock {
  returnData: any[];
  constructor(data) {
    this.returnData = data;
  }

  setReturnData(data: any[]) {
    this.returnData = data;
    console.log(this.returnData);
  }

  formatData(data) {
    const dataToReturn = data?.map((data) => {
      const { id, ...docData } = data;
      return {
        payload: {
          doc: {
            data: () => docData,
            id: id || Math.random().toString(16).substring(2),
          },
        },
      };
    });
    return dataToReturn;
  }

  snapshotChanges() {
    return of(this.formatData(this.returnData)).pipe(
      tap((res) => console.log("snapshot res", res))
    );
  }

  collection() {
    console.log("collection called");
    const _this = this;
    return {
      snapshotChanges: _this.snapshotChanges.bind(this),
    };
  }
}

With this class I’m able to set an inital return data and, in theory, to set a new one by calling the setReturnData method, but when I import the class to the test, the first one fails, and even if I call the setReturnData method before second case (this method actually logs the updated return data), the data is not changed in the test.
This is my updated test for the FireStoreMock class

describe('DataSheetService', () => {
  const payload = [{ name: 'lamps' }, { name: 'mirrors' }];
  const payloadAlt = [{ name: 'lamps' }, { name: 'tables' }];
  let service: DataSheetService;
  let angularFirestore: FireStoreMock;
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        { provide: AngularFirestore, useValue: new FireStoreMock(payload) },
      ],
    });
    service = TestBed.inject(DataSheetService);
    angularFirestore = new FireStoreMock(payload);
    spyOn(angularFirestore, 'collection')
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
    expect(angularFirestore.collection).toHaveBeenCalled(); // <-- this one fails
  });

  it('should return list of data sheets types', async () => {
    const types$ = service.getDataSheetsTypes();
    angularFirestore.setReturnData(payloadAlt)
    types$.subscribe((types) => {
      console.log('types', types); // <- this logs [{ name: 'lamps' }, { name: 'mirrors' }]
      expect(types.length).toBe(2);
      expect(Object.keys(types[1])).toContain('id');
      expect(types[1]).toEqual(jasmine.objectContaining({ name: 'tables' }));
    });
  });
});

Is there any way to achieve this behaviour…?

Solution

I would just use jasmine.createSpyObj for it and not mock it explicitly. Similar to this.


describe('DataSheetService', () => {
  const payload = [{ name: 'lamps' }, { name: 'mirrors' }];
  const payloadAlt = [{ name: 'lamps' }, { name: 'tables' }];
  let service: DataSheetService;
  let angularFirestore: jasmine.SpyObj<FireStoreMock>;
  beforeEach(() => {
    const spy = jasmine.createSpyObj('AngularFirestore', ['collection']);
    TestBed.configureTestingModule({
      providers: [
        { provide: AngularFirestore, useValue: spy },
      ],
    });
    spyOn(angularFirestore, 'collection'); // move spy here because the service gets
    service = TestBed.inject(DataSheetService); // instantiated on the next line
    angularFirestore = TestBed.inject(AngularFireStore) as jasmine.SpyObj<FireStoreMock>;// and if you spy after this line
  });                                              // it will be too late.

  it('should be created', () => {                 // moving the spy should make this test
    expect(service).toBeTruthy();                 // pass
    expect(angularFirestore.collection).toHaveBeenCalled(); // <-- this one fails
  });

  it('should return list of data sheets types', async () => {
    // mock the next call to collection to return this object
    angularFirestore.collection.and.returnValue({
      snapshotChanges: () => of(/* */), // your mock data inside of of
    });
    // this test may need some more work but you get the idea now
    // looking at the line above, you can mock the next function call of what you're
   // mocking and then call your service and get the results you expect.
    const types$ = service.getDataSheetsTypes();
    types$.subscribe((types) => {
      console.log('types', types); // <- this logs [{ name: 'lamps' }, { name: 'mirrors' }]
      expect(types.length).toBe(2);
      expect(Object.keys(types[1])).toContain('id');
      expect(types[1]).toEqual(jasmine.objectContaining({ name: 'tables' }));
    });
  });
});

Leave a Reply

(*) Required, Your email will not be published