Testing TypeScript Server Runtime Code with Jest #

This guide shows you how to configure and write tests for your TypeScript server runtime code using the popular Jest JavaScript Testing framework.

Writing tests for your code ensures that your code behaves as expected and safe guards you from making breaking changes in the future without realizing.

Configuring your TypeScript server project for Jest #

To write Jest tests you need to install a few different packages.

1
npm i -D jest @jest/globals ts-jest ts-auto-mock jest-ts-auto-mock ts-patch

In your tsconfig.json file add the following to your compilerOptions section:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "compilerOptions": {
    "plugins": [
      {
        "transform": "ts-auto-mock/transformer",
        "cacheBetweenTests": false
      }
    ]
  }
}

In your package.json add a new "test" script:

1
2
3
4
5
{
  "scripts": {
    "test": "jest"
  }
}

Also add a new section for "jest":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "jest": {
    "globals": {
      "ts-jest": {
        "compiler": "ts-patch/compiler"
      }
    },
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "setupFiles": [
      "<rootDir>jest-config.ts"
    ]
  }
}

Finally create a jest-config.ts in the root of your project (or the path specified in the "setupFiles" section of your config).

1
import 'jest-ts-auto-mock';

Writing a testable RPC #

This is an example RPC that a player might call to add an item to their inventory. The inventory in this case is an entry in the Nakama Storage Engine.

The code will check the incoming payload to ensure it meets specific criteria and at all potential failure points the RPC will log the error and return an appropriate failure response to the user.

Create a file called rpc-add-item.ts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const AddItemRpc = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string | null): string {
  // Define success and failure responses.
  const success = JSON.stringify({ success: true });
  const failure = function(error: string) {
      logger.error(error);
      return JSON.stringify({
          success: false,
          error
      })
  };

  // If the payload is empty, return a failure.
  if (!payload || payload === "") {
      return failure("no payload provided");
  }

  const itemToAdd = JSON.parse(payload);

  // If the payload contains no item name, return a failure.
  if (!itemToAdd.name) {
      return failure("item has no name");
  }

  // If the payload contains no quantity, or a non-numeric quantity, return a failure.
  if (!itemToAdd.quantity || isNaN(Number(itemToAdd.quantity))) {
      return failure("item has no quantity");
  }

  // If the payload quantity is negative, return a failure.
  if (itemToAdd.quantity <= 0) {
      return failure("quantity provided must be greater than 0");
  }

  // Define a storage read request to get the player's current inventory.
  const readRequest: nkruntime.StorageReadRequest = {
      collection: "player",
      key: "inventory",
      userId: ctx.userId
  };

  let inventory: Inventory = {};

  // Attempt to get the player's current inventory from storage.
  const result = nk.storageRead([readRequest]);
  if (result.length > 0) {
      inventory = result[0].value;
  }

  // If the player already has the item, increase the quantity, otherwise add it.
  if (inventory[itemToAdd.name]) {
    inventory[itemToAdd.name] += itemToAdd.quantity;
  } else {
    inventory[itemToAdd.name] = itemToAdd.quantity;
  }

  // Define the storage write request to update the player's inventory.
  const writeRequest: nkruntime.StorageWriteRequest = {
      collection: "player",
      key: "inventory",
      userId: ctx.userId,
      permissionWrite: 1,
      permissionRead: 1,
      value: inventory
  };

  // Write the updated inventory to storage.
  const storageWriteAck = nk.storageWrite([writeRequest]);

  // Return a failure if the write does not succeed.
  if (!storageWriteAck || storageWriteAck.length == 0) {
      return failure("error saving inventory");
  }

  return success;
}

type Inventory = {
    [key: string]: any;
};

export default AddItemRpc;

Writing the first test #

Create a file called rpc-add-item.test.ts with the following imports.

1
2
3
4
import { createMock } from 'ts-auto-mock';
import { On, method } from 'ts-auto-mock/extension';
import AddItemRpc from "./rpc-add-item";
import { describe, expect, beforeEach, test } from '@jest/globals';

These imports allow you to create mocks of the Nakama server runtime types and functions. This allows you to test your code in isolation and under carefully crafted scenarios, without interacting with Nakama.

Create a describe block which allows you to group together related tests. Inside, define variables for the various mock objects we need to call our RPC.

1
2
3
4
5
describe('AddItemRpc', function() {
    let mockCtx: any, mockLogger: any, mockNk: any, mockLoggerError: any, mockNkStorageRead: any, mockNkStorageWrite: any, mockStorageWriteAck: any;

  // Further code goes here
});

Next create a beforeEach block. The function you pass in will be run before each test. This is useful if you need to do some setup before a test. Here you will use this to configure the mock objects you defined earlier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
beforeEach(function () {
    // Create mock objects to pass to the RPC.
    mockCtx = createMock<nkruntime.Context>({ userId: 'mock-user' });
    mockLogger = createMock<nkruntime.Logger>();
    mockNk = createMock<nkruntime.Nakama>();
    mockStorageWriteAck = createMock<nkruntime.StorageWriteAck>();

    // Configure specific mock functions to use Jest spies via jest-ts-auto-mock
    mockLoggerError = On(mockLogger).get(method(function (mock) {
        return mock.error;
    }))
    mockNkStorageRead = On(mockNk).get(method(function (mock) {
        return mock.storageRead;
    }));
    mockNkStorageWrite = On(mockNk).get(method(function (mock) {
        return mock.storageWrite;
    }));
});

The createMock function takes a generic type using the <> syntax to determine what type the mock should be. It will then proceed to create an object of that type with default/empty values for all of that type’s properties. You can override specific property values by passing an object into the function, such as the userId property above.

You will notice that you configured 3 specific functions to act as Jest mock functions (using the On function made available via ts-auto-mock/extension): logger.error, nk.storageRead and nk.storageWrite. This is so that you can verify that these functions have been called with specific values depending on the test scenario.

Now you will write your first test. This test will check to make sure that the RPC returns a failure response with a specific error message when the payload passed to it is null.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
test('returns failure if payload is null', function() {
    const payload = null;
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'no payload provided';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
});

Here you:

  • Call the AddItemRpc function, passing in the mocked objects you defined ands null for the payload
  • Get the result as a JSON object using JSON.parse
  • Define the expected error response string

Using the expect function you then verify that the:

  • resultPayload.success value is false
  • resultPayload.error value is the expected error string 'no payload provided'
  • logger.error function was called with the same error message

To run your tests:

1
npm run test

If you run this test you should see that it passes.

You can verify that this test is indeed working by changing the expectedError value to something else and re-running the test. For example, changing the value to 'payload provided' will result in the following Jest output.

1
2
3
4
Error: expect(received).toBe(expected) // Object.is equality

Expected: "payload provided"
Received: "no payload provided"

Mocking the return value of Nakama functions #

Often when writing tests you will want to mock the return value of a particular function in order to change the outcome of the code being tested. With Jest this is possible through the use of various mockReturnValue functions.

In the following test, the mockReturnValueOnce function is used on the mockNkStorageWrite (as a jest.Mock type) function to force it to return null. This in turn means that the AddItemRpc function should fail and return a failure response.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
test('returns failure if nk.storageWrite returns null', function () {
  (mockNkStorageWrite as jest.Mock).mockReturnValueOnce(null);

  const payload = JSON.stringify({
    name: 'Diamond Pickaxe',
    quantity: 1
  });
  const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
  const resultPayload = JSON.parse(result);
  const expectedError = 'error saving inventory';

  expect(resultPayload.success).toBe(false);
  expect(resultPayload.error).toBe(expectedError);
  expect(mockLoggerError).toBeCalledWith(expectedError);
});

The mockReturnValueOnce function can be used to return any value from the mocked function and can therefore be used to completely change the result of the test.

Consider the following two examples:

1
(mockNkStorageRead as jest.Mock).mockReturnValueOnce({});
1
2
3
4
5
6
7
(mockNkStorageRead as jest.Mock).mockReturnValueOnce([
  {
    value: {
      'Diamond Pickaxe': 3
    }
  }
]);

In the first, the response of the nk.storageRead call is mocked to return no results, effectively emulating a response from Nakama that no storage objects exist for the specified collection, key, and user.

The second returns an array with a single object, indicating that the player’s current inventory contains 3 Diamond Pickaxes.

This is useful as it allows you to test how the RPC behaves when the player’s inventory is in different states.

Full example #

Below is the full code for the rpc-add-item.test.ts file covering various different success and failure scenarios.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import { createMock } from 'ts-auto-mock';
import { On, method } from 'ts-auto-mock/extension';
import AddItemRpc from "./rpc-add-item";
import { describe, expect, beforeEach, test } from '@jest/globals';

describe('AddItemRpc', function () {
    let mockCtx: any, mockLogger: any, mockNk: any, mockLoggerError: any, mockNkStorageRead: any, mockNkStorageWrite: any, mockStorageWriteAck: any;

  beforeEach(function () {
    mockCtx = createMock<nkruntime.Context>({ userId: 'mock-user' });
    mockLogger = createMock<nkruntime.Logger>();
    mockNk = createMock<nkruntime.Nakama>();
    mockStorageWriteAck = createMock<nkruntime.StorageWriteAck>();
    mockLoggerError = On(mockLogger).get(method(function (mock) {
      return mock.error;
    }))
    mockNkStorageRead = On(mockNk).get(method(function (mock) {
      return mock.storageRead;
    }));
    mockNkStorageWrite = On(mockNk).get(method(function (mock) {
      return mock.storageWrite;
    }));
  });

  test('returns failure if payload is null', function () {
    const payload = null;
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'no payload provided';


    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload is empty string', function () {
    const payload = "";
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'no payload provided';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload is empty JSON object', function () {
    const payload = JSON.stringify({});
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'item has no name';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload has no quantity', function () {
    const payload = JSON.stringify({
      name: 'Diamond Pickaxe'
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'item has no quantity';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload has 0 quantity', function () {
    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 0
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'item has no quantity';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload has non-numeric quantity', function () {
    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: "test"
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'item has no quantity';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if payload has negative quantity', function () {
    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: -1
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'quantity provided must be greater than 0';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if nk.storageWrite returns null', function () {
    (mockNkStorageWrite as jest.Mock).mockReturnValueOnce(null);

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'error saving inventory';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns failure if nk.storageWrite returns an empty array', function () {
    (mockNkStorageWrite as jest.Mock).mockReturnValueOnce([]);

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);
    const expectedError = 'error saving inventory';

    expect(resultPayload.success).toBe(false);
    expect(resultPayload.error).toBe(expectedError);
    expect(mockLoggerError).toBeCalledWith(expectedError);
  });

  test('returns sucess if nk.storageWrite returns an ack', function () {
    (mockNkStorageWrite as jest.Mock).mockReturnValueOnce([mockStorageWriteAck]);

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });

    const result = AddItemRpc(mockCtx, mockLogger, mockNk, payload);
    const resultPayload = JSON.parse(result);

    expect(resultPayload.success).toBe(true);
    expect(resultPayload.error).toBe(undefined);
    expect(mockLoggerError).toBeCalledTimes(0);
  });

  test('calls nk.storageRead with correct arguments', function () {
    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    AddItemRpc(mockCtx, mockLogger, mockNk, payload);

    expect(mockNkStorageRead).toBeCalledWith([{
      collection: "player",
      key: "inventory",
      userId: mockCtx.userId
    }]);
  });

  test('calls nk.storageWrite with just the new item when no item already exists in inventory', function () {
    (mockNkStorageRead as jest.Mock).mockReturnValueOnce({});

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    AddItemRpc(mockCtx, mockLogger, mockNk, payload);

    expect(mockNkStorageWrite).toBeCalledWith([{
      collection: "player",
      key: "inventory",
      userId: mockCtx.userId,
      permissionRead: 1,
      permissionWrite: 1,
      value: {
        'Diamond Pickaxe': 1
      }
    }]);
  });

  test('calls nk.storageWrite with incremented item quantity when item already exists in inventory', function () {
    (mockNkStorageRead as jest.Mock).mockReturnValueOnce([
      {
        value: {
          'Diamond Pickaxe': 3
        }
      }
    ]);

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    AddItemRpc(mockCtx, mockLogger, mockNk, payload);

    expect(mockNkStorageWrite).toBeCalledWith([{
      collection: "player",
      key: "inventory",
      userId: mockCtx.userId,
      permissionRead: 1,
      permissionWrite: 1,
      value: {
        'Diamond Pickaxe': 4
      }
    }]);
  });

  test('calls nk.storageWrite with incremented item quantity and other items when item already exists in inventory', function () {
    (mockNkStorageRead as jest.Mock).mockReturnValueOnce([
      {
        value: {
          'Diamond Pickaxe': 3,
          'Iron Sword': 1,
          'Coal': 64
        }
      }
    ]);

    const payload = JSON.stringify({
      name: 'Diamond Pickaxe',
      quantity: 1
    });
    AddItemRpc(mockCtx, mockLogger, mockNk, payload);

    expect(mockNkStorageWrite).toBeCalledWith([{
      collection: "player",
      key: "inventory",
      userId: mockCtx.userId,
      permissionRead: 1,
      permissionWrite: 1,
      value: {
        'Diamond Pickaxe': 4,
        'Iron Sword': 1,
        'Coal': 64
      }
    }]);
  });
});