# 通过Jest测试TypeScript服务器运行时代码

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

---


# 通过Jest测试TypeScript服务器运行时代码

本指南介绍如何使用流行的[Jest](https://jestjs.io/)JavaScript测试框架为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`属性。

您会发现已经配置了3个特定函数来充当[Jest模拟函数](https://jestjs.io/docs/mock-function-api)（使用通过`ts-auto-mock/extension`提供的`On`函数）：`logger.error`、`nk.storageRead`和`nk.storageWrite`。
这样，您就可以根据测试场景验证是否使用特定值调用了这些函数。

现在您将编写您的第一个测试。此测试将进行检查，以确保RPC在传递给它的有效负载为`null`时返回带有特定错误消息的故障响应。

```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)实现。

在以下测试中，在`mockNkStorageWrite`（作为`jest.Mock`类型）函数上使用了`mockReturnValueOnce`函数，以强制返回`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
      }
    }]);
  });
});
```