用于 Angular 服务测试的 Jasmine 可重用 FireStore 模拟

Jasmine reusable FireStore mock for Angular service testing

我正在尝试制作一个可重复使用的 firestore 模拟来测试多个 angular 服务。 我的服务是这样的:

@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);
        })
    );
  }
}

我已经能够制作一个简单的函数来模拟 firestore 并测试 firestore 集合和快照,但我无法即时更改 returned 数据,所以我需要重写它每次。

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)) });

还有我的测试:

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' }));
    });
  });
});

使用 angularFireDatabaseStub 函数,所有测试都通过了,但我想要实现的是第三种情况,其中来自 service.getDataSheetsTypes 方法的 returned 数据发生变化,由于此数据在 angularFireDatabaseStub 的调用中被硬编码,我正在尝试采用不同的方法。

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),
    };
  }
}

有了这个 class 我可以设置一个初始的 return 数据,理论上,可以通过调用 setReturnData 方法设置一个新数据,但是当我导入 class 进行测试,第一个失败,即使我在第二种情况之前调用 setReturnData 方法(此方法实际上记录了更新的 return 数据),数据在测试中也没有更改。 这是我对 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' }));
    });
  });
});

有什么方法可以实现这种行为...?

我只会使用 jasmine.createSpyObj 而不是明确地模拟它。类似于 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' }));
    });
  });
});