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

**URL:** https://heroiclabs.com/docs/kr/nakama/guides/server-framework/typescript-testing/
**Summary:** Summary

---


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

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

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

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

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

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

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

```json
{
  "compilerOptions": {
    "plugins": [
      {
        "transform": "ts-auto-mock/transformer",
        "cacheBetweenTests": false
      }
    ]
  }
}
```

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

```json
{
  "scripts": {
    "test": "jest"
  }
}
```

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

```json
{
  "jest": {
    "globals": {
      "ts-jest": {
        "compiler": "ttypescript"
      }
    },
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "setupFiles": [
      "<rootDir>jest-config.ts"
    ]
  }
}
```

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

```typescript
import 'jest-ts-auto-mock';
```

## 테스트 가능한 RPC 작성

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

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

`rpc-add-item.ts`(이)라는 파일을 생성합니다.

```TypeScript
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`(이)라는 파일을 만듭니다.

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

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

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

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

  // Further code goes here
});
```

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

```typescript
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 모의 함수](https://jestjs.io/docs/mock-function-api)로 작동하도록 3개의 특정 함수를 구성했음을 알 수 있습니다. (`ts-auto-mock/extension`을(를) 통해 사용할 수 있는 `On` 함수 사용): `logger.error`, `nk.storageRead` 및 `nk.storageWrite`. 
이는 테스트 시나리오에 따라 이러한 함수가 특정 값으로 호출되었음을 확인할 수 있도록 하기 위해서입니다.

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

```typescript
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` 동일한 오류 메시지와 함께 함수가 호출되었습니다.

테스트를 실행하려면:

```bash
npm run test
```

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

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

```bash
Error: expect(received).toBe(expected) // Object.is equality

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

## Nakama 함수의 반환 값 모의

대체로 테스트를 작성할 때 테스트 중인 코드의 결과를 변경하기 위해 특정 함수의 반환 값을 모의하고 싶을 것입니다. 이 경우 Jest를 사용하면 다양한 [`mockReturnValue` 함수](https://jestjs.io/docs/mock-function-api#methods)을 사용하여 가능합니다.

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

```typescript
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` 함수는 모의 함수에서 값을 반환하는 데 사용할 수 있으므로 테스트 결과를 완전히 변경하는 데 사용할 수 있습니다.

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

```typescript
(mockNkStorageRead as jest.Mock).mockReturnValueOnce({});
```

```typescript
(mockNkStorageRead as jest.Mock).mockReturnValueOnce([
  {
    value: {
      'Diamond Pickaxe': 3
    }
  }
]);
```

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

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

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

## 전체 예

다음은 다양한 성공 및 실패 시나리오를 다루는 `rpc-add-item.test.ts` 파일의 전체 코드입니다.

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