angular / angularfire

Angular + Firebase = ❤️
https://firebaseopensource.com/projects/angular/angularfire2
MIT License
7.64k stars 2.2k forks source link

[docs] Mocking of angularfire methods with angularfire 7 during tests #3050

Open alexis-mrc opened 2 years ago

alexis-mrc commented 2 years ago

Version info

Angular: 12

Firebase: 9

AngularFire: 7

How to reproduce these conditions

How to mock firebase operator during tests suits ? How to write unit tests with mocks of firestore/auth/analytics methods ?

Expected behavior

With Angularfire 6, I was able to mock angularfire methods like doc, collection and other methods to write my unit tests. I would like to be able to mock it.

Actual behavior

I am getting the error during my unit tests : Error: Either AngularFireModule has not been provided in your AppModule (this can be done manually or implictly using provideFirebaseApp) or you're calling an AngularFire method outside of an NgModule (which is not supported).

google-oss-bot commented 2 years ago

This issue does not seem to follow the issue template. Make sure you provide all the required information.

kekel87 commented 1 year ago

So, after a year with no response, I'm sharing my workaround.

For the context, jasmine is not able to spy the functions exported by Typescript 4.

(This is possible with jest for those who use it).

For those who use jasmine, the famous wrapper technique 🤮, allows to spy and mock the functions:

import { authState, signInWithEmailAndPassword, signInWithPopup, signOut } from '@angular/fire/auth';

export abstract class AngularFireShimWrapper {
  static readonly authState = authState;
  static readonly signInWithEmailAndPassword = signInWithEmailAndPassword;
  static readonly signInWithPopup = signInWithPopup;
  static readonly signOut = signOut;
}

AuthService to test :

import { Injectable } from '@angular/core';
import { Auth, GoogleAuthProvider } from '@angular/fire/auth';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';

import { AngularFireShimWrapper } from '~shared/utils/angular-fire-shim-wrapper';

import { User } from './user.model';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  user$: Observable<User | null>;

  constructor(private auth: Auth) {
    this.user$ = AngularFireShimWrapper.authState(this.auth).pipe(
      map((user) =>
        user !== null
          ? ({
              uid: user.uid,
              displayName: user.displayName,
              photoURL: user.photoURL,
              email: user.email,
            } as User)
          : null
      )
    );
  }

  signOut(): Observable<void> {
    return from(AngularFireShimWrapper.signOut(this.auth));
  }

  signInWithGoogle() {
    return from(AngularFireShimWrapper.signInWithPopup(this.auth, new GoogleAuthProvider()));
  }

  signInWithEmailAndPassword(email: string, password: string) {
    return from(AngularFireShimWrapper.signInWithEmailAndPassword(this.auth, email, password));
  }
}

Units tests :

import { Auth, User, UserCredential } from '@angular/fire/auth';
import { MockBuilder, ngMocks } from 'ng-mocks';
import { BehaviorSubject } from 'rxjs';
import { first } from 'rxjs/operators';

import { AngularFireShimWrapper } from '~shared/utils/angular-fire-shim-wrapper';
import { mockUser } from '~tests/mocks/user.spec';

import { AuthService } from './auth.service';

fdescribe('AuthService', () => {
  let authService: AuthService;
  const auth = jasmine.createSpy();
  const authState$ = new BehaviorSubject<User | null>(null);

  beforeEach(async () => {
    spyOn(AngularFireShimWrapper, 'authState').and.returnValue(authState$);

    await MockBuilder(AuthService).provide({ provide: Auth, useValue: auth });

    authService = ngMocks.findInstance(AuthService);
  });

  it('should be created', () => {
    expect(authService).toBeDefined();
  });

  it('should handle null User', (done: DoneFn) => {
    authState$.next(null);

    authService.user$.pipe(first()).subscribe({
      next: (v) => {
        expect(v).toBeNull();
        done();
      },
      error: done.fail,
    });
  });

  it('should return an observable of the user', (done: DoneFn) => {
    authState$.next(mockUser as unknown as User);

    authService.user$.pipe(first()).subscribe({
      next: (v) => {
        expect(v).toEqual(mockUser);
        done();
      },
      error: done.fail,
    });
  });

  it('should call the popup opening method', (done: DoneFn) => {
    spyOn(AngularFireShimWrapper, 'signInWithPopup').and.returnValue(Promise.resolve({} as unknown as UserCredential));

    authService
      .signInWithGoogle()
      .pipe(first())
      .subscribe({
        next: () => {
          expect(AngularFireShimWrapper.signInWithPopup).toHaveBeenCalled();
          done();
        },
        error: done.fail,
      });
  });

  it('should call the popup opening method', (done: DoneFn) => {
    spyOn(AngularFireShimWrapper, 'signInWithEmailAndPassword').and.returnValue(Promise.resolve({} as unknown as UserCredential));

    authService
      .signInWithEmailAndPassword('test@test.fr', '123456')
      .pipe(first())
      .subscribe({
        next: () => {
          expect(AngularFireShimWrapper.signInWithEmailAndPassword).toHaveBeenCalledWith(auth, 'test@test.fr', '123456');
          done();
        },
        error: done.fail,
      });
  });

  it('should call the sign method', () => {
    spyOn(AngularFireShimWrapper, 'signOut').and.returnValue(Promise.resolve());

    authService.signOut();

    expect(AngularFireShimWrapper.signOut).toHaveBeenCalled();
  });
});

giphy (1)

The logic remains the same for all functions exposed directly from version 7 of angular/fire.

docaohuynh commented 1 year ago

So, after a year with no response, I'm sharing my workaround.

For the context, jasmine is not able to spy the functions exported by Typescript 4.

(This is possible with jest for those who use it).

For those who use jasmine, the famous wrapper technique 🤮, allows to spy and mock the functions:

import { authState, signInWithEmailAndPassword, signInWithPopup, signOut } from '@angular/fire/auth';

export abstract class AngularFireShimWrapper {
  static readonly authState = authState;
  static readonly signInWithEmailAndPassword = signInWithEmailAndPassword;
  static readonly signInWithPopup = signInWithPopup;
  static readonly signOut = signOut;
}

AuthService to test :

import { Injectable } from '@angular/core';
import { Auth, GoogleAuthProvider } from '@angular/fire/auth';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';

import { AngularFireShimWrapper } from '~shared/utils/angular-fire-shim-wrapper';

import { User } from './user.model';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  user$: Observable<User | null>;

  constructor(private auth: Auth) {
    this.user$ = AngularFireShimWrapper.authState(this.auth).pipe(
      map((user) =>
        user !== null
          ? ({
              uid: user.uid,
              displayName: user.displayName,
              photoURL: user.photoURL,
              email: user.email,
            } as User)
          : null
      )
    );
  }

  signOut(): Observable<void> {
    return from(AngularFireShimWrapper.signOut(this.auth));
  }

  signInWithGoogle() {
    return from(AngularFireShimWrapper.signInWithPopup(this.auth, new GoogleAuthProvider()));
  }

  signInWithEmailAndPassword(email: string, password: string) {
    return from(AngularFireShimWrapper.signInWithEmailAndPassword(this.auth, email, password));
  }
}

Units tests :

import { Auth, User, UserCredential } from '@angular/fire/auth';
import { MockBuilder, ngMocks } from 'ng-mocks';
import { BehaviorSubject } from 'rxjs';
import { first } from 'rxjs/operators';

import { AngularFireShimWrapper } from '~shared/utils/angular-fire-shim-wrapper';
import { mockUser } from '~tests/mocks/user.spec';

import { AuthService } from './auth.service';

fdescribe('AuthService', () => {
  let authService: AuthService;
  const auth = jasmine.createSpy();
  const authState$ = new BehaviorSubject<User | null>(null);

  beforeEach(async () => {
    spyOn(AngularFireShimWrapper, 'authState').and.returnValue(authState$);

    await MockBuilder(AuthService).provide({ provide: Auth, useValue: auth });

    authService = ngMocks.findInstance(AuthService);
  });

  it('should be created', () => {
    expect(authService).toBeDefined();
  });

  it('should handle null User', (done: DoneFn) => {
    authState$.next(null);

    authService.user$.pipe(first()).subscribe({
      next: (v) => {
        expect(v).toBeNull();
        done();
      },
      error: done.fail,
    });
  });

  it('should return an observable of the user', (done: DoneFn) => {
    authState$.next(mockUser as unknown as User);

    authService.user$.pipe(first()).subscribe({
      next: (v) => {
        expect(v).toEqual(mockUser);
        done();
      },
      error: done.fail,
    });
  });

  it('should call the popup opening method', (done: DoneFn) => {
    spyOn(AngularFireShimWrapper, 'signInWithPopup').and.returnValue(Promise.resolve({} as unknown as UserCredential));

    authService
      .signInWithGoogle()
      .pipe(first())
      .subscribe({
        next: () => {
          expect(AngularFireShimWrapper.signInWithPopup).toHaveBeenCalled();
          done();
        },
        error: done.fail,
      });
  });

  it('should call the popup opening method', (done: DoneFn) => {
    spyOn(AngularFireShimWrapper, 'signInWithEmailAndPassword').and.returnValue(Promise.resolve({} as unknown as UserCredential));

    authService
      .signInWithEmailAndPassword('test@test.fr', '123456')
      .pipe(first())
      .subscribe({
        next: () => {
          expect(AngularFireShimWrapper.signInWithEmailAndPassword).toHaveBeenCalledWith(auth, 'test@test.fr', '123456');
          done();
        },
        error: done.fail,
      });
  });

  it('should call the sign method', () => {
    spyOn(AngularFireShimWrapper, 'signOut').and.returnValue(Promise.resolve());

    authService.signOut();

    expect(AngularFireShimWrapper.signOut).toHaveBeenCalled();
  });
});

giphy (1) giphy (1)

The logic remains the same for all functions exposed directly from version 7 of angular/fire.

Saved my day literally

Kwabena-Agyeman commented 11 months ago

Dont know how you came up with this, but excellent job!!!

New to angular and this helped me out A LOT