A spy is what javascript testing frameworks call an object that can be used instead of any dependency and can override method calls and provide information on the number of calls, etc.
Links to the docs for jasmine, jest, and mocha.

In this article, we'll start with manually creating a spy and work our way to having a reusable and strong-typed function that does that for us. First for Jasmine and then for Jest. The steps would be:

  • implement a mock service having the shape of the original and allowing control over its behavior and responses using Jasmine APIs
  • design what an automated spy-creator (autoSpy) function would return
  • implement the autoSpy
  • augment implementation to use conditional types for working around properties
  • give alternative implementation for Jest users
  • finally, give a Schematic for generating it

The final result on Stackblitz or refer to the SCuri docs on GitHub.

Manually create a spy#

Let's take an example of a service being a dependency of our component. We would like to create a spy, a mock of the service if you will, that we'll use in the service's stead. Presenting the AuthorService, that can fetch or update authors:

class AuthorService {
  getAuthor(id: string): Author {
    // .. implementation not important
  }

  updateAuthor(author: Author): 'success' | 'error' {
    // .. implementation not important
  }
}

And our AuthorComponent

export class AuthorComponent {
  author: Author;

  constructor(private s: AuthorService) {}

  ngOnInit() {
    this.author = this.s.getAuthor('1');
  }
}

We would manually create the mock like this:

class AuthorServiceMock implements AuthorService {
  mockGetAuthorResponse: Author;
  mockUpdateAuthorResponse: 'success' | 'error';
  getAuthor() {
    return this.mockGetAuthorResponse;
  }
  updateAuthor() {
    return this.mockUpdateAuthorResponse;
  }
}

describe('AuthorComponent', () => {
  it('should display the author when found by id', () => {
    const service = new AuthorServiceMock();
    service.mockGetAuthorResponse = { name: 'test' } as Author;
    const c = new AuthorComponent(service);
    c.ngOnInit();
    expect(c.author).toEqual({ name: 'test' });
  });
});

Ok, this approach works - live code on Stackblitz. An issue would arise if we change AuthorService. Then we'd need to update the mock too.

EMBEDED - https://stackblitz.com/edit/manual-spy?embed=1&file=src/author.spec.ts&hideExplorer=1&view=editor

Design the autoSpy#

What we actually want is to focus on the methods we need for the test case and let the rest be. How would that look like?

describe('AuthorComponent', () => {
  it('should do display the author when found by id', () => {
    const service = autoSpy(ServiceMock);
    service.getAuthor.mockReturn({ name: 'me' } as Author);
    const c = new AuthorComponent(service);
    c.ngOnInit();
    expect(c.author).toEqual({ name: 'me' });
  });
});

Notice there's no need for the AuthorServiceMock. Less code, less maintenance chores!
Ok, we've designed our autoSpy, now for the actual implementation.

Implementation of autoSpy#

  1. The function would need to take a constructor (i.e. a new-able something resulting in a thing) and return the object of the constructor type (i.e. something looking like the new-ed up thing)

    function autoSpy(o: new (...args: any[]) => T): T {
      return {} as T;
    }
    

    The type says that the autoSpy accepts constructors i.e. the thing that when called with new will return an instance of type T.

  2. Next, we'd like to strong type that the methods on the returned object are spies so we can spy on calls as well as mock methods

    // prettier-ignore
    function autoSpy(o: new (...args: any[]) => T): T &
      {
        [k in keyof T]: jasmine.Spy;
      }
    {
      return {} as T;
    }
    

    Now the return type is T and something more. For Typescript this means the original shape of the object T is still there, and we augment it with

    {
        [k in keyof T]: T[k] & jasmine.Spy;
    }
    

    This translates to: an object with the same property names (keys) as the type T <=> [k in keyof T] and each of them has the return type of the combination of the original return type T[k] plus jasmine.Spy.

    Let's do an example to make it clearer. For our AuthorService the return type of autoSpy(AuthorService) would be

    type returned = {
      getAuthor(id: string): Author & jasmine.Spy;
      updateAuthor(a: Author): ('success' | 'error') & jasmine.Spy;
    };
    

    We could extract the type to make the function signature readable:

    export type SpyOf<T> = T &
      {
        [k in keyof T]: jasmine.Spy;
      };
    
    export function autoSpy<T>(obj: new (...args: any[]) => T): SpyOf<T> {
      //..
    }
    
  3. Finally, we need to actually implement the promised result

    // SpyOf<T> represents the complex type described in 2.
    
    export function autoSpy<T>(obj: new (...args: any[]) => T): SpyOf<T> {
      const res: SpyOf<T> = {} as any;
    
      const keys = Object.getOwnPropertyNames(obj.prototype);
      keys.forEach((key) => {
        res[key] = jasmine.createSpy(key);
      });
    
      return res;
    }
    

    Read the prototype properties, it's where the methods get attached to in javascript, and for each method instantiate a jasmine Spy!

Properties#

There is one small issue here and that is if the dependency we are mocking has properties autoSpy would slap & jasmine.Spy on them too, which would later make it harder to assign values to. So if a property has a type string it would now be of type string & jasmine.Spy. That makes for an error when assigning a string value to it:

1-property

See live code on Stackblitz

To fix that we'll use the conditional types that TS 2.8 introduces.

Originally we had:

export type SpyOf<T> = T &
  {
    [k in keyof T]: jasmine.Spy;
  };

With conditional types:

export type SpyOf<T> = T &
  {
    [k in keyof T]: T[k] extends Function ? jasmine.Spy : never;
  };

If the type of the property is a function then add jasmine.Spy to its type signature, otherwise add the same type so (name: "string" & "string") which amounts to "leave it as is". And we get no error

2-property-conditional-types

See live code on Stackblitz

Jest support#

The type would change a bit with Jest:

type SpyOf<T> = T &
  {
    // changes                                  👇 👇 👇         👇 👇 👇
    [k in keyof T]: T[k] extends (...args: any[]) => infer R ? jest.Mock<R> : T[k];
  };

export function autoSpy<T>(obj: new (...args: any[]) => T): SpyOf<T> {
  const res: SpyOf<T> = {} as any;

  const keys = Object.getOwnPropertyNames(obj.prototype);
  keys.forEach((key) => {
    // change 👇 👇 👇
    res[key] = jest.fn(key);
  });

  return res;
}

Which adds the strong typing of the result of the method call:

describe('AuthorComponent', () => {
  it('should do display the author when found by id', () => {
    const service = autoSpy(ServiceMock);
    // typescript will spot an error because 'namee' 👇 is not part of Author interface
    service.mockGetAuthorResponse.mockReturn({ namee: 'me' });
    const c = new AuthorComponent(service);
    c.ngOnInit();
    expect(c.author).toEqual({ namee: 'me' });
  });
});

3-jest-type

Schematic#

For generating the autoSpy function using a schematic refer to the scuri:autospy documentation on install and use here.

Summary#

We started with a manual mock implementation in Jasmine Spy terms and worked our way to a function that would create the object mocks for us. Called that function autoSpy. Added support for both methods and properties. Finally, added support for Jest Spy too.

Thanks for reading#

This autoSpy can be used as part of my SCuri project. Head over to GitHub to check it out.