AngularFireDatabase、Jest 和单元测试 firebase 实时数据库

AngularFireDatabase, Jest and unit testing firebase realtime database

我有一个服务有 2 个方法,return 来自 firebase 实时数据库的数据

getAllProducts -> returns an observable array of products
getSingleProduct -> returns an observable single product

我正在尝试使用 Jest 创建单元测试来模拟 firebase,这样我就可以测试这两种方法:

测试文件

    import {TestBed, async} from '@angular/core/testing';
    import {ProductService} from './product.service';
    import {AngularFireModule} from '@angular/fire';
    import {environment} from 'src/environments/environment';
    import {AngularFireDatabase} from '@angular/fire/database';
    import {getSnapShotChanges} from 'src/app/test/helpers/AngularFireDatabase/getSnapshotChanges';
    import {Product} from './product';
    
    class angularFireDatabaseStub {
      getAllProducts = () => {
        return {
          db: jest.fn().mockReturnThis(),
          list: jest.fn().mockReturnThis(),
          snapshotChanges: jest
            .fn()
            .mockReturnValue(getSnapShotChanges(allProductsMock, true))
        };
      };
      getSingleProduct = () => {
        return {
          db: jest.fn().mockReturnThis(),
          object: jest.fn().mockReturnThis(),
          valueChanges: jest.fn().mockReturnValue(of(productsMock[0]))
        };
      };
    }
    
    describe('ProductService', () => {
      let service: ProductService;
    
      beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [AngularFireModule.initializeApp(environment.firebase)],
          providers: [
            {provide: AngularFireDatabase, useClass: angularFireDatabaseStub}
          ]
        });
        service = TestBed.inject(ProductService);
      });
    
      it('should be created', () => {
        expect(service).toBeTruthy();
      });
    
      it('should be able to return all products', async(() => {
        const response$ = service.getAllProducts();
        response$.subscribe((products: Product[]) => {
          expect(products).toBeDefined();
          expect(products.length).toEqual(10);
        });
      }));
    });

allProductsMocksingleProductMock 只是本地文件中的虚拟数据。

抛出的错误是this.db.list is not a function.

如果我将存根更改为基本常量而不是 class,则 allProducts 测试会通过,但显然我随后无法测试 getSingleProduct 方法:

    const angularFireDatabaseStub = {
      db: jest.fn().mockReturnThis(),
      list: jest.fn().mockReturnThis(),
      snapshotChanges: jest
        .fn()
        .mockReturnValue(getSnapShotChanges(allProductsMock, true))
      };
    }

那么我怎样才能使存根更加通用并能够测试 getSingleProduct 方法呢?

帮手

getSnapshotChanges是帮手:

    import {of} from 'rxjs';
    
    export function getSnapShotChanges(data: object, asObservable: boolean) {
      const actions = [];
      const dataKeys = Object.keys(data);
      for (const key of dataKeys) {
        actions.push({
          payload: {
            val() {
              return data[key];
            },
            key
          },
          prevKey: null,
          type: 'value'
        });
      }
      if (asObservable) {
        return of(actions);
      } else {
        return actions;
      }
    }

更新

我确实找到了一种方法来进行这两项测试,但是必须两次设置 TestBed 并不是很枯燥。肯定有一种方法可以将两个存根组合起来,并将它们仅注入到 TestBed 中一次?

import {TestBed, async} from '@angular/core/testing';
import {ProductService} from './.service';
import {AngularFireModule} from '@angular/fire';
import {environment} from 'src/environments/environment';
import {AngularFireDatabase} from '@angular/fire/database';
import {productsMock} from '../../../../mocks/products.mock';
import {getSnapShotChanges} from 'src/app/test/helpers/AngularFireDatabase/getSnapshotChanges';
import {Product} from './product';
import {of} from 'rxjs';

const getAllProductsStub = {
  db: jest.fn().mockReturnThis(),
  list: jest.fn().mockReturnThis(),
  snapshotChanges: jest
    .fn()
    .mockReturnValue(getSnapShotChanges(productsMock, true))
};
const getSingleProductStub = {
  db: jest.fn().mockReturnThis(),
  object: jest.fn().mockReturnThis(),
  valueChanges: jest.fn().mockReturnValue(of(productsMock[0]))
};

describe('getAllProducts', () => {
  let service: ProductService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [AngularFireModule.initializeApp(environment.firebase)],
      providers: [{provide: AngularFireDatabase, useValue: getAllProductsStub}]
    }).compileComponents();
    service = TestBed.inject(ProductService);
  });

  it('should be able to return all products', async(() => {
    const response$ = service.getAllProducts();
    response$.subscribe((products: Product[]) => {
      expect(products).toBeDefined();
      expect(products.length).toEqual(10);
    });
  }));
});

describe('getSingleProduct', () => {
  let service: ProductService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [AngularFireModule.initializeApp(environment.firebase)],
      providers: [{provide: AngularFireDatabase, useValue: getSingleProductStub}]
    }).compileComponents();
    service = TestBed.inject(ProductService);
  });

  it('should be able to return a single  product using the firebase id', async(() => {
    const response$ = service.getSingleProduct('-MA_EHxxDCT4DIE4y3tW');
    response$.subscribe((product: Product) => {
      expect(product).toBeDefined();
      expect(product.id).toEqual('-MA_EHxxDCT4DIE4y3tW');
    });
  }));
});

使用 class 方法,您的做法有点不对。不过,您可以同时使用 class 或常量。此外,您不应该在单元测试中导入 AngularFireModule,并且绝对不初始化它。这会大大减慢您的测试速度,因为我可以想象它需要加载整个 firebase 模块,仅用于您实际模拟 firebase 的单元测试。

所以你需要模拟的是AngularFireDatabase。这个class有listobjectcreatePushId三个方法。我怀疑对于这个测试用例,您只会使用前两个。因此,让我们创建一个执行此操作的对象:

// your list here
let list: Record<string, Product> = {};
// your object key here
let key: string = '';

// some helper method for cleaner code
function recordsToSnapshotList(records: Record<string, Product>) {
  return Object.keys(records).map(($key) => ({
    exists: true,
    val: () => records[$key],
    key: $key
  }))
}

// and your actual mocking database, with which you can override the return values
// in your individual tests
const mockDb = {
  list: jest.fn(() => ({
    snapshotChanges: jest.fn(() => new Observable((sub) => sub.next(
      recordsToSnapshotList(list)
    ))),
    valueChanges: jest.fn(() => new Observable((sub) => sub.next(
      Object.values(list)
    )))
  })),
  object: jest.fn(() => ({
    snapshotChanges: jest.fn(() => new Observable((sub) => sub.next(
      recordsToSnapshotList({ [key]: {} as Product })[0]
    ))),
    valueChanges: jest.fn(() => new Observable((sub) => sub.next(
      Object.values({ [key]: {} })[0]
    )))    
  }))
}

现在是初始化和实施测试的时候了:

describe('ProductService', () => {
  let service: ProductService;

  // using the mockDb as a replacement for the database. I assume this db is injected
  // in your `ProductService`
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [{ provide: AngularFireDatabase, useValue: mockDb }]
    });

    service = TestBed.inject(ProductService);
  });

  it('should be able to return all products', async((done) => {
    // setting the return value of the observable
    list = productsMock;

    service.getAllProducts().subscribe((products: Product[]) => {
      expect(products?.length).toEqual(10);
      done();
    });
  }));

  it('should be able to return a single product using the firebase id', async((done) => {
    key = '-MA_EHxxDCT4DIE4y3tW';

    service.getSingleProduct(key).subscribe((product: Product) => {
      expect(product?.id).toEqual(key);
      done();
    });
  }));
});

通过使用 listkey 变量,您可以使用不同类型的值进行多个测试来测试边缘情况。看看它是否仍然 return 符合您的预期 return