Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

AngularFireDatabase, Jest and unit testing firebase realtime database

I have a service that has 2 methods that return data from firebase realtime db

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

I am trying to create unit tests using Jest to mock firebase so I can test these 2 methods:

Test file

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

allProductsMock and singleProductMock are just dummy data in the local file.

The error thrown is this.db.list is not a function.

If I change the stub to a basic constant and not a class, then the allProducts test passes, but obviously I am then stuck testing the getSingleProduct method:

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

So how can I make the stub more versatile and be able to also test the getSingleProduct method?

Helper

getSnapshotChanges is a helper:

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

UPDATE

I did find one way to do both tests, but it's not very DRY having to setup the TestBed twice. Surely there must be a way to combine both stubs and inject them into the TestBed only once?

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

like image 544
rmcsharry Avatar asked Jun 26 '20 15:06

rmcsharry


1 Answers

With the class approach, you are going at it a bit wrong. Nevertheless, you can use both a class or a constant. Also, you should -not- import the AngularFireModule in your unit tests, and definitely not initialize it. This will slow your tests down a lot, because I can imagine it needs to load in the entire firebase module, just for your unit tests where you are actually mocking out the firebase.

So the thing you need to mock is AngularFireDatabase. This class has three methods, list, object, and createPushId. I suspect for this test case you will only use the first two. So let's create an object which does this:

// 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]
    )))    
  }))
}

Now it's time for initialization and implementation of the tests:

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

By using the list and key variables, you can have multiple tests with different kinds of values to test edge cases. To see if it still returns what you expect it to return

like image 114
Poul Kruijt Avatar answered Oct 16 '22 11:10

Poul Kruijt