News

Back
2023
July
13
2023

Type-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):


Summary of existing servers.

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

Back to overview