Jest를 사용하여 TypeScript 서버 런타임 코드 테스트 #

이 가이드에서는 널리 사용되는 Jest JavaScript Testing 프레임워크를 사용하여 TypeScript 서버 런타임 코드에 대한 테스트를 구성하고 작성하는 방법을 보여줍니다.

코드에 대한 테스트를 작성하면 코드를 예상대로 작동시킬 수 있으며 나중에 인식하지 못한 채 중대한 변경을 하지 않을 수 있습니다.

Jest용 TypeScript 서버 프로젝트 구성 #

Jest 테스트를 작성하려면 몇 가지 패키지를 설치해야 합니다.

1
npm i -D jest @types/jest ts-jest ts-auto-mock jest-ts-automock ttypescript

tsconfig.json 파일에서 다음을 compilerOptions 섹션에 추가:

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

package.json에서 새 "test" 스크립트를 추가:

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

"jest"에 대한 새 섹션도 추가:

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

마지막으로 프로젝트의 루트(또는 구성의 "setupFiles" 섹션에 지정된 경로)에 jest-config.ts을(를) 만듭니다.

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

테스트 가능한 RPC 작성 #

이는 플레이어가 인벤토리에 항목을 추가하기 위해 호출할 수 있는 예제 RPC입니다. 이 경우 인벤토리는 Nakama 저장소 엔진의 항목입니다.

코드는 들어오는 페이로드를 검사하여 특정 기준을 충족하는지 확인하고 모든 잠재적인 실패 지점에서 RPC가 오류를 기록하고 사용자에게 적절한 실패 응답을 반환합니다.

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
const AddItemRpc = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): 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 = {};

  // 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;
}

export default AddItemRpc;

첫 번째 테스트 작성 #

다음 가져오기를 사용하여 rpc-add-item.test.ts(이)라는 파일을 만듭니다.

1
2
3
import { createMock } from 'ts-auto-mock';
import { On, method } from 'ts-auto-mock/extension';
import AddItemRpc from "../rpc-add-item";

이러한 가져오기를 통해 Nakama 서버 런타임 유형 및 함수의 모의를 만들 수 있습니다. 이를 통해 Nakama와 상호 작용하지 않고 신중하게 구성된 시나리오에서 개별적으로 코드를 테스트할 수 있습니다.

관련된 테스트를 그룹화할 수 있는 describe 블록을 만듭니다. 내부에서 RPC를 호출하는 데 필요한 다양한 모의 객체에 대한 변수를 정의합니다.

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

  // Further code goes here
});

다음으로 beforeEach 블록을 생성합니다. 전달한 함수는 각 테스트 전에 실행됩니다. 이는 테스트 전에 몇 가지 설정을 지정해야 하는 경우에 유용합니다. 여기에서 이것을 사용하여 이전에 정의한 모의 객체를 구성합니다.

 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;
    }));
});

createMock 함수는 <> 구문을 사용하여 일반 유형 을 취하여 모의 유형을 결정합니다. 그런 다음 해당 유형의 모든 속성에 대해 기본값/빈 값을 사용하여 해당 유형의 객체를 생성합니다. 위의 userId 속성과 같이 객체를 함수에 전달하여 특정 속성 값을 재정의할 수 있습니다.

Jest 모의 함수로 작동하도록 3개의 특정 함수를 구성했음을 알 수 있습니다. (ts-auto-mock/extension을(를) 통해 사용할 수 있는 On 함수 사용): logger.error, nk.storageReadnk.storageWrite. 이는 테스트 시나리오에 따라 이러한 함수가 특정 값으로 호출되었음을 확인할 수 있도록 하기 위해서입니다.

이제 첫 번째 테스트를 작성합니다. 이 테스트에서는 특정 오류 메시지에 전달되는 페이로드가 null일 때 RPC가 특정 오류 메시지와 함께 실패 응답을 반환하는지 확인합니다.

 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);
});

다음 작업을 수행합니다:

  • AddItemRpc 함수를 호출하여 정의한 모의 객체와 페이로드의 null을(를) 전달합니다.
  • 다음을 사용하여 결과를 JSON 객체로 가져옵니다. JSON.parse
  • 예상 오류 응답 문자열 정의

expect 함수를 사용하여 다음을 확인합니다:

  • resultPayload.success 값은 false
  • resultPayload.error 값은 예상되는 오류 문자열입니다. 'no payload provided'
  • logger.error 동일한 오류 메시지와 함께 함수가 호출되었습니다.

테스트를 실행하려면:

1
npm run test

이 테스트 실행 시 통과 여부를 확인해야 합니다.

expectedError 값을 다른 값으로 변경하고 테스트를 다시 실행하여 이 테스트가 실제로 작동하는지 확인할 수 있습니다. 예를 들어 값을 'payload provided'(으)로 변경하면 다음 Jest 출력이 생성됩니다.

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

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

Nakama 함수의 반환 값 모의 #

대체로 테스트를 작성할 때 테스트 중인 코드의 결과를 변경하기 위해 특정 함수의 반환 값을 모의하고 싶을 것입니다. 이 경우 Jest를 사용하면 다양한 mockReturnValue 함수을 사용하여 가능합니다.

다음 테스트에서 mockReturnValueOnce 함수는 mockNkStorageWrite(jest.Mock 유형으로) 함수에 사용되어 강제로 null을(를) 반환합니다. 이것은 차례로 AddItemRpc 함수가 실패하고 실패 응답을 반환해야 함을 의미합니다.

 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);
});

mockReturnValueOnce 함수는 모의 함수에서 값을 반환하는 데 사용할 수 있으므로 테스트 결과를 완전히 변경하는 데 사용할 수 있습니다.

다음 두 가지 예를 고려합니다:

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

첫 번째에서 nk.storageRead 호출 응답은 결과를 반환하지 않도록 모의되어 지정된 모음, 키 및 사용자에 대한 저장소 객체가 없다는 Nakama의 응답을 효과적으로 에뮬레이트합니다.

두 번째는 플레이어의 현재 인벤토리에 3개의 다이아몬드 곡괭이가 있음을 나타내는 단일 객체가 있는 배열을 반환합니다.

이는 플레이어의 인벤토리 상태가 다를 때 RPC의 작동 상태를 테스트할 수 있으므로 유용합니다.

전체 예 #

다음은 다양한 성공 및 실패 시나리오를 다루는 rpc-add-item.test.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
 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
import { createMock } from 'ts-auto-mock';
import { On, method } from 'ts-auto-mock/extension';
import AddItemRpc from "../rpc-add-item";

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

  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
      }
    }]);
  });
});