The case for automated test spies

In JavaScript testing frameworks, a spy is an object that stands in for any dependency. It can override method implementations and report how many times those methods were invoked. Documentation is available for jasmine, jest, and mocha.

This guide begins with the manual approach to building a spy, then progresses toward a reusable and strongly-typed utility that handles the work automatically. We cover Jasmine first, then Jest. The roadmap includes:

  • constructing a mock service that mirrors the original shape while offering behavior and response control through Jasmine APIs
  • sketching out what the return value of an automated spy factory (autoSpy) should look like
  • building the autoSpy implementation
  • extending that implementation with conditional types to handle properties correctly
  • offering an adapted version for Jest environments
  • providing a Schematic to scaffold everything

The finished code is available on Stackblitz, and the SCuri docs on GitHub cover setup details.

Starting with a hand-written spy

Imagine a service that our component relies on. To test the component in isolation, we need a spy — essentially a stand-in for that service. Here's an AuthorService that retrieves and modifies author data:

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

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

And the corresponding AuthorComponent:

export class AuthorComponent {
  author: Author;

  constructor(private s: AuthorService) {}

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

The manual mock would look 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' });
  });
});

This approach gets the job done — see it in action on Stackblitz. But there's a catch: any change to AuthorService forces us to update the mock, creating extra maintenance overhead.

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

Planning the autoSpy design

The goal is to concentrate only on the methods relevant to a given test, leaving everything else untouched. What would that look like in practice?

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 that AuthorServiceMock is no longer necessary. Less code means fewer maintenance burdens.

With the autoSpy interface designed, we can proceed to the actual construction.

Building the autoSpy function

  1. The function accepts a constructor — something callable with new that yields an instance — and returns an object of that instance's type.

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

    The signature indicates that autoSpy takes constructors: entities that, when invoked with new, produce an instance of type T.

  2. Next, we enforce strong typing so that methods on the returned object are spies, enabling both call tracking and behavior mocking.

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

    The return type is now T augmented with additional constraints. For TypeScript, this preserves the original object shape T while extending it with:

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

    This means: an object whose keys match those of T (expressed as [k in keyof T]), where each key's value type is a blend of its original return type T[k] and jasmine.Spy.

    Let's illustrate with a concrete example. For AuthorService, the type returned by autoSpy(AuthorService) would be:

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

    We can extract this type to keep the function signature clean:

    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 deliver the actual implementation.

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

    We inspect the prototype properties — that's where JavaScript attaches methods — and for each one, instantiate a jasmine Spy.

Dealing with properties

One wrinkle remains: if the mocked dependency contains properties, autoSpy would incorrectly apply & jasmine.Spy to them as well. This complicates later assignment. A property with type string, for instance, would become string & jasmine.Spy, triggering an error when we try to assign a plain string value:

1-property

Live demonstration available on Stackblitz.

To resolve this, we leverage conditional types introduced in TS 2.8.

Previously, the type was defined as:

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

With conditional types, it becomes:

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

If the property type is a function, we add jasmine.Spy to its signature; otherwise, we keep the original type unchanged (effectively "string" & "string", which resolves to just "string"). No more errors.

2-property-conditional-types

See the working version on Stackblitz.

Adapting for Jest

With Jest, the type definition shifts slightly:

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

This introduces strong typing for the method call results:

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

Scaffolding with a Schematic

To generate the autoSpy function automatically, consult the scuri:autospy documentation for installation and usage instructions here.

Wrapping up

We began with a manual mock written with Jasmine Spies and gradually developed a function capable of generating object mocks on its own. We named it autoSpy, extended it to support both methods and properties, and finally added compatibility with Jest Spy.

Acknowledgments

This autoSpy utility is part of my SCuri project. Explore more on GitHub.