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');
});
}));
});
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With