News
BackType-Safe Mocking of Interfaces in TypeScript
As we have already mentioned previously, here at cloudscale.ch we love automated testing. We are also great fans of type-safe languages. Since we started increasingly using TypeScript in our front-end GUI, the question has arisen more frequently about how we can write good tests for our TypeScript code. This is why we want to look in more detail at the world of unit testing and TypeScript and to share with you a code snippet that has made our life significantly easier.
The code used in the examples in this article is a somewhat simplified version of the actual code that calculates data for the following React component (this view will be familiar to our users):
The data read from the API is used to generate both the server list and the summary.
The aim is to establish the following information for a given list of servers:
- Number of servers
- Total server memory
- Total storage of all server volumes
- Total daily server costs
To start, here is an overview of the corresponding TypeScript code:
export interface Server {
name: string
daily: number
memory: number
volumes: Volume[]
}
export interface Volume {
type: 'ssd' | 'bulk'
size: number
}
export interface ServerSummary {
count: number
totalMemory: number
totalStorage: number
totalCost: number
}
export const getServerSummary = (servers: Server[]): ServerSummary => {
const count = servers.length;
const totalCost = servers.reduce((accu, s) => accu + s.daily, 0);
const totalMemory = servers.reduce((accu, s) => accu + s.memory, 0);
const volumes = servers.reduce<Volume[]>((accu, s) => accu.concat(s.volumes), []);
const totalStorage = volumes.reduce((accu, v) => accu + v.size, 0);
return {count, totalCost, totalMemory, totalStorage};
}
The two first interfaces, Server
and Volume
, are the data types for the input. Next comes the ServerSummary
interface, which contains the four values to be calculated. Lastly, we can see the getServerSummary
function, which is our test subject. To calculate the totals, we use Array.reduce()
here.
In the next step, we will look at the associated unit test, which is also implemented in TypeScript:
test('test getServerSummary', () => {
// arrange
const servers: Server[] = [
{name: 'server1', daily: 1, memory: 4, volumes: [{size: 50, type: 'ssd'}]},
{name: 'server2', daily: 2, memory: 8, volumes: [{size: 10, type: 'ssd'}, {size: 200, type: 'bulk'}]},
]
// act
const actual = getServerSummary(servers)
// assert
const expected: ServerSummary = {
count: 2,
totalCost: 3,
totalMemory: 12,
totalStorage: 260,
};
expect(actual).toEqual(expected)
});
This is a classic unit test in line with the arrange, act and assert (AAA) pattern:
- Arrange: we define two
Server
instances with test data. - Act: we call the
getServerSummary
function. - Assert: we compare the actual result with the expected result.
Although this test works perfectly, close observation reveals the following: as we are using TypeScript and the properties Server.name
and Volume.type
are not optional, we also have to populate them in the test data (see Arrange) even though they are not relevant for this test case. If we remove name
and type
, the unit test will continue to run, but the TypeScript compiler will issue an error message.
Having to specify the test data that are not required might not be a problem in this small example, but can very quickly become inconvenient in complicated, nested structures.
The following represents an initial naive attempt to solve the problem using TypeScript Type Assertions:
const servers: Server[] = [
{daily: 1, memory: 4, volumes: [{size: 50}]} as unknown as Server,
{daily: 2, memory: 8, volumes: [{size: 10, type: 'ssd'}, {size: 200,}]} as unknown as Server,
]
We now no longer need to indicate the superfluous attributes, but we have also sacrificed type safety: if we now incorrectly use a wrong name or type, such as {sizeGb: 'x'}
, for {size: 50}
, we will get no compiler error and an unintuitive test result.
After several experiments and a range of improvements, we ended up with the following test helper:
function mockPartially<T extends object>(mockedProperties: Partial<T> = {}): T {
const handler = {
get(target: T, prop: keyof T & string) {
if (prop in mockedProperties) {
return mockedProperties[prop];
}
throw new Error(`Mock does not implement property: ${prop}, but it was accessed.`);
},
};
return new Proxy<T>({} as T, handler);
}
mockPartially()
sets up a proxy object for any type T
. An object with any subset of properties of T
can be passed as mockedProperties
. This is made possible by the Partial
type constructor. Partial<T>
creates a new type where all properties of T
are set as optional. Using the proxy object, we can implement any behavior when accessing properties of the object. In our case we throw an error if the property in question was not specified in mockedProperties
. If the property has been indicated, its value is returned unchanged.
We import mockPartially
as mP
, which enables us to define the test data as follows:
const servers: Server[] = [
mP<Server>({daily: 1, memory: 4, volumes: [mP<Volume>({size: 50})]}),
mP<Server>({daily: 2, memory: 8, volumes: [mP<Volume>({size: 10}), mP<Volume>({size: 200})]}),
]
This solution has the following advantages for us:
- We can omit any unnecessary input data.
- If we forget required data, we receive a clear error message, such as:
Mock does not implement property: volumes, but it was accessed.
- In the case of false test data, such as
{sizeGb: 50}
or{size: 'x'}
, we receive error messages from the TypeScript compiler that are easy to understand.
We hope you have found this in-depth insight into the day-to-day life of our software developers interesting and that you might be able to use our mockPartially
helper yourself.
(Not) mocking!
Your cloudscale.ch team