Jest is a JavaScript testing framework that's widely used in JavaScript projects. I personally use it on an Angular project written in TypeScript. It makes it easy to create mocks of services for unit tests, and the tests themselves are easy to read, understand, and extend when necessary.
When a component changes, its testing should also change to check the correctness of the new implementation. It is however possible to alter a component or an injected service without any of the tests failing or giving any warning. Some of these cases and the ways TypeScript types help minimize their occurrence are the topic of this post.
Let's use this TestService
testing file as an example:
describe('TestService', () => {
let authenticationServiceMock;
let SUT: TestService;
beforeEach(() => {
const authorizationSources = ['system', 'override', 'automation'];
authenticationServiceMock = {
getAuthSources: jest.fn(() => authorizationSources),
isSourceAuthorized: jest.fn((sourceCandidate: string) => authorizationSources.includes(sourceCandidate)),
login: jest.fn((username: string, password: string) => of(username === 'admin' && password === '123')),
};
TestBed.configureTestingModule({
providers: [
{ provide: AuthenticationService, useValue: authenticationServiceMock },
]
});
SUT = TestBed.get(TestService);
});
test('should be created', () => {
expect(SUT).toBeTruthy();
});
test('can login', () => {
const user = 'wrong';
const pass = 'wrongpass';
SUT.login(user, pass).subscribe(
result => {
expect(result).toBe(false);
}
);
expect(authenticationServiceMock.login as jest.Mock).toHaveBeenCalledTimes(1);
expect((authenticationServiceMock.login as jest.Mock).mock.calls[0][0]).toBe(user);
});
});
Several things here could be improved to avoid spending time changing the test file with minor details each time something is changed in the tested service. It could also be made more type-safe.
- The AuthenticationService mock has no type, so any changes to AuthenticationService would cause this test to continue passing when it shouldn't. It could also fail even though TestService would also change along with its dependency, but then the test would fail, again due to the outdated mock implementation of AuthenticationService
- If we gave AuthenticationService a type, we would still need to cast its functions to
jest.Mock
to use jasmine matchers liketoHaveBeenCalledTimes
, or to access the jest mockInstancemock
property to check the arguments in a function call. - When using the
mock.calls
array, it's just aany[][]
type, and if we wanted to get the actual type of the parameters to thelogin
method, we would have to cast it to the explicit and wordy mock type, like so:
expect((authenticationServiceMock.login as jest.Mock<Observable<boolean>, [string, string]>).mock.calls[0][0]).toBe(user);
- Even using this syntax, again any changes to
authenticationService
or tologin
's signature would require us to manually fix all of these casts, and it wouldn't even be that clear that thejest.Mock
cast is the issue. Imaginelogin
used to take[string, number]
as input and we now refactored it to be[string, string]
. We would get a very wordy error message, from which it would be hard to tell that we just need to switch the second argument's type tostring
.
The most basic thing we can do, is tell the compiler that our mock is of type AuthenticationService
, but all of its methods are also of type jest.Mock
. To do this we first need to extract all of the method names from AuthenticationService
, and then create a Record
type where the keys are the method names and the values are all jest.Mock
:
type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never }[keyof T];
This type alias uses Mapped Types and Index Types to create a type that's a union of property names from the type T
. In our case FunctionPropertyNames<AuthenticationService>
means exactly "login" | "getAuthSources" | "isSourceAuthorized"
. Our mock service type alias will therefore be:
type MockService<aService> = aService & Record<FunctionPropertyNames<aService>, jest.Mock>;
let authenticationServiceMock: MockService<AuthenticationService>;
Now we can use our mock anywhere the original service would be required (because it has the original service type), but whenever we access one of its properties, if it's a function, it will have the additional type jest.Mock
. For example:
expect(authenticationServiceMock.login).toHaveBeenCalledTimes(1);
expect((authenticationServiceMock.login).mock.calls[0][0]).toBe(user);
No more awkward casting whenever we want to expect
anything!
Do note that the mock
object still uses the <any, any>
type signature, because we did not say what the return type and parameters should be for each function. To do that we'll need to map directly over the original service type (again using Mapped Types), so we can tell each function property to be a mock of the proper type:
type BetterMockService<aService> = aService &
{ [K in keyof aService]: aService[K] extends (...args: infer A) => infer B ?
aService[K] & jest.Mock<B, A> : aService[K] };
Now we're creating a type that has all of the same properties as aService
, but for each property that's a function, it has the additional type of jest.Mock<B, A>
, where B
is its return type and A
is a tuple of its parameters' types. We don't need to include aService
in the intersection, since the new mapped type already has all of the properties of the original, but I've kept it here to show the similarity to the previous solution.
Hopefully this idea or adaptations of it can help you when typing mocks in your unit tests. Let me know of other typing tricks you use.
Top comments (2)
Totally agree on the idea of properly typing mocks!
Been thinking about doing same thing in my spec files. Now I can't just use your solution, thanks!
Hopefully you feel free to use it, as that's it's purpose! Thanks for your input, would love to hear back if you've implemented this it something similar.