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
autoSpyimplementation - 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
-
The function accepts a constructor — something callable with
newthat 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
autoSpytakes constructors: entities that, when invoked withnew, produce an instance of typeT. -
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
Taugmented with additional constraints. For TypeScript, this preserves the original object shapeTwhile 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 typeT[k]andjasmine.Spy.Let's illustrate with a concrete example. For
AuthorService, the type returned byautoSpy(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> { //.. } -
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:

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.
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' });
});
});

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.

