Search code examples
angulartypescriptunit-testingjasmine

Angular Unit Testing - Mocking AngularFireStore collection get call


Edit I made some headway today, and now have a different error in the same flow. Question updated below.

I'm back with another question on how to mock a complex call from AngularFireStore.

I'm hitting an error when executing my tests when calling the importData function in my service. The final get call should return an array that I'm checking, but I can't get the mock right so the test is blowing up.

The Version 2 of my mock file is below, and I think it's actually pretty close to working. I got through all errors related to the different functions, and so right now I'm blowing up on the first IF statement to check to see if the returned array length is 0 or not. The return is unfortunately 'undefined', and thus the error.

What's interesting is that if I change the final return in my mock to be return Promise.resolve() (no array inside of the () ) then the error changes to: Error: Uncaught (in promise): TypeError: Cannot read properties of undefined (reading 'docs'). When I add the array in changes to complaining about the undefined 'length' instead.

I've also wondered if this has to do with processing and fakeasync, so I've tried a couple of different versions. Right now, I have 'fakeasync' on the test. Another interesting point is that if I change it from 'fakeasync' to just 'async' with an await or nothing the test actually registers as a 'pass' but still throws the error in console.

TypeError: Error: Uncaught (in promise): TypeError: Cannot read properties of undefined (reading 'length') TypeError: Cannot read properties of undefined (reading 'length')

service.ts

importData(Id: number) {

    this.afs.collection<data>('users/' + this.authService.liveUid + .ref.where('id', '==', Id).get().then(
      a => {

        if (a.docs.length == 0) {

          if (validID(Id, 8, 9)) {
            this.http.get<DataImport>('https://url' + Id).subscribe(

         //do stuff

            )
          } else 
          {
            this.urlError = true
            this.spinnerImported = true
          }

        }
        else {
          this.spinnerImported = true
          this.sameImportName = a.docs[0].get('name')
        }
      }
    )
  }

service.spec.ts

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

  beforeEach(() => {
    TestBed.configureTestingModule(
      {
        providers: [
          { provide: HttpClient, useValue: httpClientMock },
          { provide: AngularFirestore, useValue: AngularFirestoreMock },
          { provide: AuthService, useValue: AuthServiceMock },
        ]
      });
    service = TestBed.inject(ImportService);
  });

  it('should be created', fakeasync (() => {

    service.importData(32198712)
    tick ()
  }));
});

**AngularFirestoreMock (version 1) **

export const AngularFirestoreMock = jasmine.createSpyObj('AngularFirestoreMock', {
  'doc': {
    set: function () {
      return Promise.resolve()
    }
  },
  'collection':{
    get: function () {
      return Promise.resolve()
    }
  }

}

)

AngularFirestoreMock (version 2)

export const AngularFirestoreMock = jasmine.createSpyObj('AngularFirestoreMock', {
  'doc': {
    set: function () {
      return Promise.resolve()
    }
  },
  'collection': {
    'ref': {
      where: () => {
        return {
          get: () => {
            return Promise.resolve([{ name: 'test'}])
          }
        }
      }
    }
  }})

Solution

  • Ok, I was pretty close but I was missing that the response from the final Promise.resolve() needed to have the 'doc's property which then had the array that was being evaluated.

    Right now, my test enters into the 'else' part of the first 'if' because my default 'AngularFireStoreMock' is returning an array with a > 0 length. In future tests, I'll do a returnValue to set that to null to enter the other part of my if/else statement.

    I bring this up because hitting the 'else' branch exposed another function I needed to mock, another 'get' function from the returned docs array in the [0] array position.

    AngularFireStore Mock

    export const AngularFirestoreMock = jasmine.createSpyObj('AngularFirestoreMock', {
      'doc': {
        set: function () {
          return Promise.resolve()
        }
      },
      'collection': {
        'ref': {
          where: () => {
            return {
              get: () => {
                return Promise.resolve({
                  'docs': [
                    {
                      get: () => {
                        return { name: 'Umphrii Fieldstone' }
                      }
                    }
                  ]
                })
              }
            }
          }
        }
      }
    })
    

    Analysis I learned a lot figuring this one out, so if my thought process and how I've started to understand this helps anyone, here it is:

    The call to be mocked:

    this.afs.collection<data>('users/' + this.authService.liveUid + .ref.where('id', '==', Id).get()
    

    This call contains the following calls, and when i hovered over them in VSCode I could see whether each call was considered a method or a property and then build the mock out from there.

    • collection (method)
    • ref (property)
    • where (method)
    • get (method)

    A reminder that jasmine.createSpyObj uses the first key in the object (in this scenario 'collection') as the first function/method. After that, you can just use the correct syntax for a property or a method. The final get() call returns data in the promise.resolve() because it's subscribed to with a .then.