OmniMock is a test mock library for TypeScript, with a focus on strong typing and ease of use.
Unlike other similar libraries, OmniMock was built from the ground up for TypeScript and makes no compromise.
Check out the quick start guide to get up to speed in minutes. Do come back here if you want to learn why this library was built the way it was built and why it is a good long-term investment for your projects.
OmniMock aims to bring the best possible mocking experience in TypeScript with no compromise. This comes at the cost of a few prerequisites:
A mock library attempts to fulfill multiple contradictory goals. Each author choses to solve the conflicts in a different way and this is why there are many mocking libraries out there. OmniMock uses a principled approach to solve these goals in a rational way. We believe that by basing all design decisions on this list, we create something that is not just nice to look at on github, but also increases productivity when used in the real world.
any
all over the place, or by creating ad-hock objects as mocks, you lose the connection between your production code and your test code. Features like symbol renaming and usage lookup don't apply to your tests which makes it harder to understand and refactor your codebase. What's more, loose types mean your tests might get out of sync without you even noticing, and at some point they basically become dead weight..resolve
and .reject
help you reduce the boilerplate without losing any type safety.Unit tests need to be isolated. In order to achieve this, many guides show basic examples of what we can call "manual mocking".
const fakeCard: MtgCard = {
cost: 'UW',
color: 'white',
kind: 'sorcery',
art: 'John Avon',
play: () => ({} as any),
burry: () => ({} as any),
// ...
}
const gameState: MtgState = {
player1: {
getManaPool() {
return [ { color: 'W', qty: 1 } ];
},
// ...
},
player2: {
getManaPool() {
return [ ]; // Not important
},
// ...
},
clock: {
player: 1,
phase: 'precombat main'
}
}
const result = battlefield.castSpell(gameState, fakeCard);
expect(result.type).toBe('illegal move');
expect(result.reason).toBe('not enough mana');
The setup of the mocks is huge, it is hard to maintain, contains a lot of useless data, and juggles with types. IDE features like symbol lookup and renaming don't work in test code. The fake data may become inconsistent with the expected type and unit tests exercise situations which were already ruled out by the type system.
Developpers are incentivised to rely on actual implementations of the dependencies rather than mocks. This can quickly pile up to a mountain of tech debt and deter anyone from attempting any kind of refactoring on the main codebase.
Enters a mocking library.
The main feature a mocking library brings to the table is automatic mocks. What these allow you to do is focus on what is relevant to your specific test case, and ignore all of the rest. All of this while increasing type safety across your entire test suite.
Going back to the example above, we know that the battlefield
class will only need the card's mana cost, player 1's mana reserve and the clock data. We can rewrite the test like this.
const fakeCard = mock<MtgCard>('fakeCard', {
cost: 'UW'
});
const gameState = mock<MtgState>('gameState', {
clock: {
player: 1,
phase: 'precombat main'
}
});
when(gameState.player1.getManaPool())
.return([ { color: 'W', qty: 1 } ])
.once();
const result = battlefield.castSpell(instance(gameState), instance(fakeCard));
expect(result.type).toBe('illegal move');
expect(result.reason).toBe('not enough mana');
You may argument that the above can easily be achieved by creating a manual mock of Partial<MtgCard>
and Partial<MtgState>
, and only populating the relevant field. Effectively, this would be very similar to what we just did above. However, there are two problems with this approach:
Partial<T>
to T
. This is TypeScript telling you that you are doing something wrong...Partial
. If it turns out the code uses some of the properties you thought it did not use, then it is possible that the undefined value will trickle down your system and cause an Error far from the original place the culprit value came from.By contrast, if you forget to mock something, or if you make some changes to the code of battlefield
, then OmniMock might throw an error like this: Error: Unexpected call to MtgCard.play()
, with a stacktrace pointing to the exact location where the unexpected call occurred.
Use the mock()
method to create a mock. Set expectations on the object returned by the mock()
method, and pass the instance
of that mock to your tested code.
// Use the mock to set expectations
const someServiceMock = mock(SomeService);
when(someServiceMock.doStuff()).return('hi').once();
// Pass the instance to the class you are testing
const someService = instance(myMock);
const testClass = new TestClass(someService);
instance()
always returns a reference to the same object. The expectations you set on the mock always affect the instance, even after you've obtained a reference to it.
If you do not need to set expectations on the mock, you can use mockInstance
as a shorthand for instance(mock())
.
You can mock any object type or interface without providing an actual instance of it. This is called a virtual mock because it doesn't retain any type information at runtime.
Virtual mocks have some limitations. Learn more here.
// Mock an interface by passing it as a type argument to the mock function.
// Give it a name to help print nice error messages
const mockAssemblyService = mock<AssemblyService>('mockAssemblyService');
// Mock a class from its constructor. The name and type are infered automatically.
const mockAssemblyService = mock(AssemblyService);
Passing an actual object to the mock function creates a backed mock. Backed mocks support features such as .callThrough()
and .useActual()
, and work with code which uses property enumeration. See backed mock.
const realAssemblyService = {
assemble(parts: PartsList, blueprint: Blueprint) { /* ... */ }
version: 2
};
// realAssemblyService "backs" assemblyServiceMock
const assemblyServiceMock = mock('assemblyServiceMock', realAssemblyService);
// This works well with classes too
const assemblyServiceMock = mock('assemblyServiceMock', new AssemblyService());
By default, property access on a backed mock don't trigger an error (ie. they behave like .useActual().anyTimes()).
If you want to forbid some function call or property access, use a quantifier like this:
when(assemblyServiceMock.assemble).useActual().never();
You can omit properties when you create a backed mock.
const assemblyServiceMock = mock<AssemblyService>('assemblyServiceMock', {
version: 2
});
If you have access to the class constructor, use this form instead. It supports instanceof
and prototype-based operations.
const assemblyServiceMock = mock(AssemblyService, {
version: 2
});
Note that this does not work with abstract classes and interfaces.
A mock created this way will accept any get or set operation on the specified members, but will throw an error if one attempts to get an omitted property.
assemblyService.version; // 2
assemblyService.assemble(parts, blueprint); // Error: unexpected property access: AssemblyService.assemble
This is useful to mock data types (ie. classes or types which contain raw data and almost no actual logic).
You can also mock simple functions, both as a virtual or a backed mock.
// Named virtual mock
const luckyNumberMock = mock<(name: string) => number>('luckyNumber');
// Backed mock
const luckyNumberMock = mock('luckyNumber', (name: string) => {
return name.charCodeAt(0);
});
Inline mocks allow you to create a backed or virtual mock on the spot, with a more compact syntax. This can be helpful in some situations.
Take the following code.
const mockCard: MtgCard = {
id: 1038,
cost: 'UW',
color: 'white',
kind: 'sorcery',
art: 'John Avon',
// ...
};
when(codex.getCard('Starlight'))
.return(mockCard);
Specifying all members of the card can be tedious, especially if the tested class does not actually use the card. You could write the following to make your test more succint without losing type safety and while keeping the guarantee of no undefined behavior.
const mockCard = instance(mock<MtgCard>('fake card', {
// Omit the properties you don't need.
// Omnimock will throw an error if it turns out you need them.
id: 1038,
color: 'white'
}));
when(codex.getCard('Starlight')).return(mockCard);
In fact, this pattern is so common that we created a shortcut for it: mockInstance
.
const mockCard = mockInstance<MtgCard>('fake card', {
id: 1038,
color: 'white'
});
when(codex.getCard('Starlight')).return(mockCard);
A common pattern is to provide a return value which you are not sure if it is used or not.
when(codex.getCard('Starlight')).return(mockInstance('fake card'));
// Or better, if you have access to a constructor this is more accurate:
when(codex.getCard('Starlight')).return(mockInstance(MtgCard));
Then, you keep adding the necessary attributes as you discover them. For instance, I can write the following if I get the error Unexpected property access: <fake card>.id
.
when(codex.getCard('Starlight')).return(mockInstance('fake card', {
id: 1038
}));
// Or:
when(codex.getCard('Starlight')).return(mockInstance(MtgCard, {
id: 1038
}));
You can further customize the mock by using a callback. The callback receives the mock builder.
when(codex.getCard('Starlight'))
.return(mockInstance('fake card', {}, fakeCard => {
when(fakeCard.play(anything())).call(gameState => {
expect(gameState.turn).toBe(2);
});
}));
This pattern is useful to avoid confusing the mock builder and the mock instance, since the mock builder can't easily escape from the callback. However,, this syntax can quickly become too heavy, and you might prefer to use deep chaining.
The example above is roughly equivalent to the following.
when(codex.getCard('Starlight').play(anything())).call(gameState => {
expect(gameState.turn).toBe(2);
});
Use when()
to set expectations on a mock.
Expectations are evaluated in the order in which they are declared. The first matching expectation is used.
when(myMock.doStuff()).return('hi');
Or, if you've mocked a simple function:
when(myMock()).return('hi');
when(myMock.version).useValue(2);
A collection of matchers is provided to help you narrow down your expectations:
when(myMock.someComputation(between(0, 4), same(superComputer), anyString())).return(2);
Arguments are matched by deep-comparison by default. Use same()
to compare by reference instead.
The full list of matchers is available in the docs. Otherwise, you can create your own matchers.
You can mock any arbitrarily deeply nested object.
when(myMock.members.getByName('Steve').job.getCompany().name).useValue('Pixar');
If your mock is backed, you can even forward property access and method calls:
when(myMock.members.getByName('Steve').job.getCompany().name).useActual();
Be careful when mocking deeply nested properties. The matcher resolution works its way from left to right and never backtracks. This means that you need to look at the chain segment by segment, the first expectation that matches a segment wins immediately, even before the rest of the chain is evaluated.
when(myMock.members.getByName('Steve').job.getCompany().name).useValue('Pixar');
when(myMock.members.getByName(anyString()).job.getCompany().address).useValue('1200 Park Ave');
// Throws an error because, the first expectation won at 'getByName'
// and it does not expect an access to 'address'.
// If you removed the first line, then this would return '1200 Park Ave'
instance(myMock).members.getByName('Steve').job.getCompany().address
If you use the same path more than once, then expectations are combined. This provides a more natural experience, but it also creates the following pitfall:
// These expectations are combined naturally
when(myMock.members.getByName('Steve').job.getCompany().name).useValue('Pixar');
when(myMock.members.getByName('Steve').job.getCompany().address).useValue('1200 Park Ave');
when(myMock.members.getByName('Steve').job.title).useValue('Engineer');
// This is added after to catch any member who is not Steve
when(myMock.members.getByName(anyString())).return(defaultMember);
// Pitfall: despite being added after the catch-all, this is combined with the other 'Steve' expectations.
// Because `getByName('Steve')` was specified before `getByName(anyString())`, this is actually reachable.
// If you remove all three `Steve` expectations above, then this expectation becomes unreachable.
when(myMock.members.getByName('Steve').job.ranking).useValue(12);
instance(myMock).members.getByName('Steve').job.getCompany().name // 'Pixar'
instance(myMock).members.getByName('Steve').address // Error: Unexpected member access
instance(myMock).members.getByName('John').address // default address
instance(myMock).members.getByName('Steve').job // {}
instance(myMock).members.getByName('Steve').job.ranking // 12
To avoid the pitfall shown in the example above, always declare your expectations from the most specific to the most general.
There are many ways you can define what gets returned from a matched call or member access.
// Mock the return value of a method
when(myMock.sayMyName()).return('Heisenberg');
// Mock the value of a property or getter
when(myMock.name).useValue('Heisenbug');
instance(myMock).sayMyName(); // Heisenberg
instance(myMock).name; // Heisenbug
Use a fake if you need some test code to run when the method is called on the mock.
when(myMock.divide(anyNumber(), 0)).callFake((a, b) => {
expect(a).toBe(42);
});
Note that faking a call with no arguments is the same as replacing the method with a custom function
when(myMock.doSomething()).callFake(() => 'something was done');
// Is equivalent to:
when(myMock.doSomething).useValue(() => 'something was done');
You may delegate method calls and member access to the backing instance of backed mocks.
const myMock = mock('myMock', {
sayHello: (name: string) => `Hello ${name}!`,
name: 'Willy'
});
when(myMock.sayHello()).callThrough();
when(myMock.name).useActual();
instance(myMock).sayHello('beautiful'); // "Hello beautiful!"
instance(myMock).name; // "Willy"
when(myMock.divide(anyNumber(), 0)).throw(new Error('Division by zero'));
// is equivalent to:
when(myMock.divide(anyNumber(), 0)).call(() => { throw new Error('Division by zero') });
instance(myMock).divide(22, 0); // throws: Error('Division by zero')
OmniMock provides helpers for dealing with promises
when(myMock.fetchRemoteData(1)).resolve('the data');
when(myMock.fetchRemoteData(0)).rejet('Invalid id');
By mocking, you control interactions of the tested code with the external world. Verification is the process of ensuring that the tested code made the right number of calls.
By deault, any call which is not matched by any expectation throws an exception. Expectations accept any number of calls by default. You may customize how many calls of a specific expectation you expect to see with quantifiers.
At the end of the test, you should call verify
to ensure all expected calls were realized.
import { verify } from 'omnimock';
when(myMock.getName()).return('apollo').times(2);
when(myMock.luckyNumber).useValue(23).atLeastOnce();
verify(myMock); // Throws an error because the expectations above were not met
instance(myMock).getName();
instance(myMock).luckyNumber;
verify(myMock); // Throws an error because myMock.getName was expected twice but it was called only once
// You can verify a subset of the expectations.
verify(myMock.luckyNumber); // This does not throw
when(myMock.getHero('Bilbo').speak()).return('Time for some tea').once();
verify(myMock.getHero('Bilbo')); // throws an error because speak() was expected once but was not called
verify(myMock.getHero('Gloin')); // Does not throw
// Callign verify on the base mock verifies all expectations, however deeply nested
verify(myMock); // Throws
Quantifiers are additional calls you can make on an expectation to specify how many times this expectation should be matched.
when(myMock.find('dogs')).return([]).once();
when(myMock.find('cats')).return([]).atLeastOnce();
when(myMock.find('snails')).return([]).atMostOnce();
when(myMock.find('mice')).return([]).anyTimes(); // This is the default behavior
when(myMock.find('deers')).return([]).times(5);
when(myMock.find('bears')).return(['teddy']).atLeastOnce();
verify(myMock); // Error: unmatched expectation: myMock.find('bears'), expected at least one calls but got 0
Place a call to verify
in the teardown of your test to make sure no expectation goes unnoticed.
afterEach(() => {
verify(myMock);
});
Any call which does not match any expectation will throw an Error.
instance(myMock).find('bears'); // Error: Unexpected call to myMock.find('bears')
OmniMock prints helpful error messages when something goes wrong to help you quickly identify the error.
But there may be times when your mock got really complex and you don't understand why some call was not matched the way you wanted.
For those times we made the debug
utility. Use it to print all of the expectations currently registered on a mock:
import { debug } from 'omnimock';
console.log(debug(myMock));
Reset all or part of the expectations set on a mock using the reset
function.
import { reset } from 'omnimock';
when(myMock.getName()).return('apollo');
when(myMock.luckyNumber).useValue(23);
// Reset all expectations set on this mock
reset(myMock);
instance(myMock).getName(); // Error: Unexpected access to getName
instance(myMock).luckyNumber; // Error: Unexpected access to getName
when(myMock.getName()).return('apollo');
when(myMock.luckyNumber).useValue(23);
// Reset only the getName member access and leave everything else
reset(myMock.getName);
instance(myMock).getName(); // Error: Unexpected access to getName
instance(myMock).luckyNumber; // 23
You can record a value passed to a mock from the tested code in order to use it in the test suite.
To do so, use a fake call and save the value to the closure of the test.
let captured: string = '';
when(myMock.getContact(anyString())).call((contact) => {
captured = contact;
return mockInstance();
});
instance(myMock).getContact('Mom');
expect(captured).toBe('Mom');
OmniMock is merely a DSL to configure an ES6 Proxy to behave a specific way, with a sprinkle of verification logic to check if the proxy was used as intended.
Both mock controls and mock instances are ES6 Proxies. All of the calls or member accesses on these objects go through the internals of OmniMock, where they are dissected and registered. The state of the mock is stored internally in closures which are not accessible from the outside world.
The mock control exposes some metadata which public APIs like when()
and instance()
use to interact with the mock's state. This metadata is actually encoded in the type system and this is why TypeScript is able to tell a mock control from a mock instance, even though they look like they have the same type.
As stated in the previous section, all mocks in OmniMock are ES6 Proxies. A mock captures any incoming call or property access and applies some logic to determine what to do next.
If the proxy has access to an actual instance of the type it is mocking, we say that this proxy is backed (as in, it has a backing instance). The proxy will inherit some properties of the backing instance. Virtual proxies on the other hand behave in a more generic way.
The table below summarizes the differences between a backed mock and a virtual mock.
Property | Virtual | Backed |
---|---|---|
constructor (instanceof) | ✗ | ✓ |
enumerate original props | ✗1 | ✓ |
callable | always yes | same as backing |
1Only those properties which have been when
-ed are enumerable. There is no way to retrieve the original properties at runtime.
Because there are many ways to design a mocking library, there are many mocking libraries. This page describes the differences between some popular choices.