发布于 2026-01-06 1 阅读
0

使用 Jest(和 TypeScript)模拟 AWS 并返回数据

使用 Jest(和 TypeScript)模拟 AWS

返回数据

这似乎是个很有讽刺意味的话题,嘲讽世界领先的云服务提供商。我努力想找些笑点,但转念一想,我竟然免费写博客文章来推广那些价值数千亿美元的公司的项目,也许真正被嘲讽的只有我自己?

总之,不用想太多。我很喜欢使用这些工具,也乐于分享我总结出的搭配使用方法。有些读者可能觉得Jest和其他一些 JavaScript/NodeJS 测试框架很像,但它有一些独特的功能,我认为这些功能让它与我用过的任何其他框架都截然不同。其中之一就是它自带非常强大的模拟功能。本文将重点介绍这一点。我喜欢 Jest 的其他功能还有快照和测试表,但这些内容以后再详细介绍。

目录

通往嘲讽之路

我编写过很多测试用例。我坚信自动化测试对于保持系统稳定、促进创新、重构甚至自动化维护更新都至关重要。我从测试中学到的最重要的一点是,它不仅是一项需要投入时间和精力的技能,更需要巧妙的方法。

几年前,我曾在一个团队工作,我们用 Java 编写“单元测试”,这些测试大多针对关系型数据库(更糟糕的是,还是 Oracle)的开发副本运行。如果测试无法连接到该数据库,就会失败。每当数据库模式发生变化时,一些测试可能都需要更新,否则就会失败。有时,测试失败的原因可能是因为删除或添加了数据行,或者仅仅是因为时间已过(真是岂有此理!)。

这些脆弱的测试简直是得不偿失!为了真正发挥单元测试的价值,我尝试过当时一些可用的模拟库,比如名字颇具讽刺意味的 EasyMock 和小巧玲珑的 Mockito。我确实写出了一些像样的测试,但耗时太长,而且模拟所有东西的回报并不高。据我所知,这套系统仍然存在并且仍然有效,但我怀疑单元测试是否真的能带来多少价值。

我很高兴地说,在那之后我再也没有针对生产数据库编写过“单元测试”。我的确花了很多时间研究如何使用 Docker 构建开发环境,并针对预先填充的数据库运行测试。如果你使用的是关系型数据库管理系统 (RDBMS),将完整的数据库(包括表和数据)放入 Docker 镜像中确实是一个绝佳的方法。我知道有些人会说“模拟数据库并测试业务层”,但这无法检测出我的应用程序输出无效或无意义的 SQL。总之,这种方法对我来说效果很好,将 Docker 化的数据库集成到持续集成 (CI) 流水线中也取得了不错的成果。

随着我对 AWS 的深入了解,我自然而然地又开始寻找其他解决方案——Docker 镜像,希望能找到一个“足够好”的实现,以便进行离线开发。于是,我选择了localstack。如果你只需要几个 AWS 服务,比如一个 S3 存储桶,localstack 的确非常实用。但如果你真的想构建一个云原生环境呢?那就糟透了。它几乎没有任何文档。显然,我必须假设所有功能都和真正的 AWS 一样,但事实并非如此,其中的不足之处只能靠我自己摸索。我对 localstack 最大的不满不仅在于运行 AWS 服务本身,还在于如何让这些服务处于可以进行单元测试的状态。例如,如果你想运行一个涉及 S3 存储桶的测试,你可能会想直接在 `docker build` 语句中创建存储桶docker-compose up,但 localstack 并没有提供这样的钩子,最终导致测试出现竞态条件和不稳定的情况。我其实已经成功实现了这个功能,但是就像我的 Mockito 测试一样,这太费劲了。

这才是关键。仅仅拥有一个完美的模拟模型是不够的,它必须易于设置和使用。否则,它的使用回报率就会太低。最终,测试要么会失败,要么会被跳过,要么会变成永久的待办事项。测试应该是开发流程中自然而然的一部分,而不是在功能实现完成后需要额外完成的大量工作。

一套完善的测试体系至关重要。我知道并非所有人都认同这一点,但我希望我参与的项目能够实现 100% 的单元测试覆盖率。即使是简单的部分,我也希望看到单元测试,因为我知道实际情况并非总是如此简单。如果某个部分难以测试,那很可能是代码异味,或者我们还没有找到一种有效的依赖管理测试方法。如果我们解决了这些问题,实现 100% 的测试覆盖率就并非难事,也不会花费太长时间。

这就引出了 Jest 和 AWS。我最近参与的项目完全围绕 Lambda、API Gateway、DynamoDB 和其他服务展开。在为这个项目做准备的过程中,我花了很多时间思考开发人员的工作流程和测试。我花了很多时间研究SAM 的潜力,甚至考虑过专注于SAM 的 Docker 版本

最终,我们决定采用更云原生的方法。我们使用CDK,完全不依赖任何本地执行环境。我们使用单元测试和 Jest 模拟来编写代码,然后将其部署到开发账户中的开发者命名空间堆栈进行集成测试。当我们认为某个功能已经完成时,我们会提交一个拉取请求,经过审核后合并到主分支。此时,持续集成会在我们的开发账户中构建主分支,运行一些额外的测试,然后将代码发布到更高级别的环境中。

由于我们采用的模拟 AWS 的方法,这种方法对我们来说非常有效。我们使用的模拟对象非常轻量级,几乎不需要维护或设置工作。这使我们能够专注于自定义代码的开发。

模拟 AWS

如果你因为觉得 Jest 的文档太难懂而从未用它做过任何模拟,你可能并不孤单。我猜想,作为 Jest 的维护者,要努力成为首屈一指的 JavaScript 测试工具,同时还要支持 ES6 前后的代码、ESM、TypeScript、JSX、Babel、NodeJS 等等,这确实是一项挑战。所有这些都让文档的读者难以找到所需信息。此外,还有一些我认为是陷阱的地方!Jest 的文档里竟然提到了一个 DynamoDB 模拟库。所以这就是重点,对吧? 好吧,现在我们大概知道他们为什么叫它 Jest 了,因为这简直太离谱了!aws-sdk 依赖项说得通,但它居然运行在 Java 上?开什么玩笑,仅仅为了运行一个单元测试就添加 Java 作为依赖项,这太不合理了。即使这种方法对我来说很有效,但如果我想添加 SQS、S3 或任何其他 AWS 服务,而我只有非常具体的 DynamoDB 模拟,该怎么办?不,我需要一种方法来实现整个功能。
替代文字

Jest 文档中真正有价值的部分是关于手动模拟(Manual Mocks)的章节。我的建议是忽略所有关于 ES6 类模拟的内容,因为那些只会让你偏离正确的方法。正确的方法是将需要模拟的模块放在__mocks__项目根目录(与 node_modules 目录相邻)下。如果你仔细阅读文档,就会发现你基本上需要为模块提供自己的模拟版本,这听起来工作量很大,但仔细想想其实没那么麻烦jest.fn()

DynamoDB 模拟

好了,废话不多说。下面我将介绍如何使用 Jest 模拟 AWS。首先,我们来看一段更新 DynamoDB 中数据项的代码。所有代码示例均可获取



import { DynamoDB } from 'aws-sdk';

const db = new DynamoDB.DocumentClient();

interface Pet {
  legCount: number;
  likesIceCream: boolean;
  name: string;
}

export const savePet = async (tableName: string, pet: Pet): Promise<void> => {
  await db
    .put({
      TableName: tableName,
      Item: {
        PK: pet.name,
        ...pet,
      },
    })
    .promise();
};


Enter fullscreen mode Exit fullscreen mode

Jest 中“手动模拟”的工作原理是,导入操作会先在__mocks__指定目录中查找模块,然后再访问常规node_modules源目录。因此,我实际上可以用我自己的副本拦截 aws-sdk 的导入。其工作原理是比较导入路径,aws-sdk如果我的导入__mocks__/aws-sdk.ts路径中包含 `/usr/local/bin`,那么 Jest 会拦截我的导入操作,并将模块替换为我的模拟模块。

现在你可能觉得我把整个 AWS SDK 重写成一个模拟对象的计划听起来并不那么轻量级,但这正是 Jest 的优势所在。我只需要提供我需要的部分,而忽略所有内部细节。以下是一个可以与上述代码一起使用的基本模拟对象。



export const awsSdkPromiseResponse = jest.fn().mockReturnValue(Promise.resolve(true));

const putFn = jest.fn().mockImplementation(() => ({ promise: awsSdkPromiseResponse }));

class DocumentClient {
  put = putFn;
}

export const DynamoDB = {
  DocumentClient,
};


Enter fullscreen mode Exit fullscreen mode

我的代码中正在使用 DynamoDB DocumentClient,所以模拟 SDK 需要暴露它。虽然 DynamoDB 本身是 SDK 中的一个类,但这里我只是从中提取了一个堆栈类,所以这样可以正常工作。我只调用了 DocumentClient 的一个方法,所以目前我只需要提供这一个模拟对象。

函数实现方面呢?如果你看一下我的代码,我调用了该put方法,然后promise()对它返回的对象进行操作,这正是我的模拟对象所做的。它返回一个带有promise方法的对象(就像真正的 SDK 一样),我的代码调用了该方法,而该方法又是另一个模拟对象,它只是解析 Promise 并返回布尔值true

综合以上内容,我现在可以编写如下所示的单元测试。



import { DynamoDB } from '../__mocks__/aws-sdk';
import { savePet } from './savePet';

const db = new DynamoDB.DocumentClient();

describe('savePet method', () => {
  test('Save Fluffy', async () => {
    const fluffy = { legCount: 4, likesIceCream: true, name: 'Fluffy', PK: 'Fluffy' };
    await savePet('Pets', fluffy);
    expect(db.put).toHaveBeenCalledWith({ TableName: 'Pets', Item: fluffy });
  });
});


Enter fullscreen mode Exit fullscreen mode

请注意,无需显式地模拟 SDK 或导入我的模拟对象。我这样做只是为了能够toHaveBeenCalledWith在测试中使用它。

导入路径

有些开发者习惯于不导入整个 SDK,而只导入单个客户端。如果你使用 webpack 或 parcel 等打包和摇树优化工具,这样做可以减小 Lambda 函数的大小。我知道你可以通过将 aws-sdk 设置为外部库来完全避免打包,但一些基准测试表明,从性能角度来看,这种做法更糟糕。无论如何,这取决于你,但我个人喜欢只导入客户端,因为这样代码更简洁,而且每个模拟对象的大小也更小。

所以,这里是经过重构的相同代码,现在只导入单个客户。

实施方案:



import { DocumentClient } from 'aws-sdk/clients/dynamodb';

const db = new DocumentClient();

interface Pet {
  legCount: number;
  likesIceCream: boolean;
  name: string;
}

export const savePet = async (tableName: string, pet: Pet): Promise<void> => {
  await db
    .put({
      TableName: tableName,
      Item: {
        PK: pet.name,
        ...pet,
      },
    })
    .promise();
};


Enter fullscreen mode Exit fullscreen mode

模拟(现已上线__mocks__/aws-sdk/clients/dynamodb.ts):



export const awsSdkPromiseResponse = jest.fn().mockReturnValue(Promise.resolve(true));

const putFn = jest.fn().mockImplementation(() => ({ promise: awsSdkPromiseResponse }));

export class DocumentClient {
  put = putFn;
}


Enter fullscreen mode Exit fullscreen mode

最后是测试:



import { DocumentClient } from '../__mocks__/aws-sdk/clients/dynamodb';
import { savePet } from './savePet';

const db = new DocumentClient();

describe('savePet method', () => {
  test('Save Fluffy', async () => {
    const fluffy = { legCount: 4, likesIceCream: true, name: 'Fluffy', PK: 'Fluffy' };
    await savePet('Pets', fluffy);
    expect(db.put).toHaveBeenCalledWith({ TableName: 'Pets', Item: fluffy });
  });
});


Enter fullscreen mode Exit fullscreen mode

如您所见,并没有太大变化,因此很容易选择最适合您项目的方法。

返回数据

目前我们已经找到了一种相当不错的方法来忽略 DynamoDB执行的与我们代码关系不大的操作,但是当我们想要测试get请求或检查 AWS 服务调用返回值时,如何才能重用同一个模拟对象呢?这就需要awsSdkPromiseResponse用到 Jest 的模拟对象了。因为它是一个导出的 Jest 模拟对象,所以我们可以动态地修改它的返回值。

我们来看一个get操作:



import { DocumentClient } from 'aws-sdk/clients/dynamodb';

const db = new DocumentClient();

interface Pet {
  legCount: number;
  likesIceCream: boolean;
  name: string;
}

export const getPet = async (tableName: string, petName: string): Promise<Pet> => {
  const response = await db.get({ TableName: tableName, Key: { PK: petName } }).promise();
  if (response.Item) {
    return <Pet>response.Item;
  } else {
    throw new Error(`Couldn't find ${petName}!`);
  }
};


Enter fullscreen mode Exit fullscreen mode

(注:请勿复制粘贴您的界面!此处仅为示例提供更清晰的说明。)

好的,这里的表格设计非常简单,主键是宠物的名字。如果我们传递的名字正确,就可以访问该宠物条目。否则,就会报错。让我们进一步完善这个模拟表,以支持新增功能。



export const awsSdkPromiseResponse = jest.fn().mockReturnValue(Promise.resolve(true));

const getFn = jest.fn().mockImplementation(() => ({ promise: awsSdkPromiseResponse }));

const putFn = jest.fn().mockImplementation(() => ({ promise: awsSdkPromiseResponse }));

export class DocumentClient {
  get = getFn;
  put = putFn;
}


Enter fullscreen mode Exit fullscreen mode

getFn我甚至可以对两者使用完全相同的模拟对象putFn,但这样做会使测试工作流程变得更加困难,因为我需要统计测试中 gett 操作和 put 操作的次数。再次强调,这是一个非常基本的设计决策,您可以轻松地进行调整。

基于以上内容,我可以编写另一个类似这样的测试:



import { DocumentClient } from '../__mocks__/aws-sdk/clients/dynamodb';
import { getPet } from './getPet';

const db = new DocumentClient();

describe('getPet method', () => {
  test('Save Fluffy', async () => {
    await getPet('Pets', 'Fluffy');
    expect(db.get).toHaveBeenCalledWith({ TableName: 'Pets', Key: { PK: 'Fluffy' } });
  });
});


Enter fullscreen mode Exit fullscreen mode

当然,这个测试存在两个大问题。

  1. 我可能有一些下游代码会用到这个响应并想用它做一些事情——但我在这里没有得到。
  2. 每次都会出现错误,因为模拟对象没有返回预期的类型。解决方法是修改模拟 SDK 响应返回的值。


import { DocumentClient, awsSdkPromiseResponse } from '../__mocks__/aws-sdk/clients/dynamodb';
import { getPet } from './getPet';

const db = new DocumentClient();

describe('getPet method', () => {
  test('Save Fluffy', async () => {
    const fluffy = { legCount: 4, likesIceCream: true, name: 'Fluffy', PK: 'Fluffy' };
    awsSdkPromiseResponse.mockReturnValueOnce(Promise.resolve({ Item: fluffy }));

    const pet = await getPet('Pets', 'Fluffy');
    expect(db.get).toHaveBeenCalledWith({ TableName: 'Pets', Key: { PK: 'Fluffy' } });
    expect(pet).toEqual(fluffy);
  });
});


Enter fullscreen mode Exit fullscreen mode

在这里使用后mockReturnValueOnce,我得到了 SDK 预期的响应,此时我可以继续处理。我们的测试现在都通过了!但是我们的代码覆盖率下降了,因为我们没有触发错误条件。

模拟错误

这太简单了,简直就是作弊,因为我们上面已经遇到错误了。我们只需要把它放到一个测试里。我们可以使用 `@Error`trycatch包裹一个抛出错误的调用,然后测试错误响应。最佳实践是在将断言放入代码块时告诉 Jest 预期有多少个断言。否则,代码可能不会抛出错误,但测试仍然可能通过。



  test(`Can't find Rover`, async () => {
    expect.assertions(1);
    try {
      await getPet('Pets', 'Rover');
    } catch (e) {
      expect(e.message).toBe(`Couldn't find Rover!`);
    }
  });


Enter fullscreen mode Exit fullscreen mode

我们来尝试一些更难的。如果 AWS 出现故障,我们想看看我们的函数会发生什么?(剧透:它不会工作)。与其让 `awsSdkPromiseResponse` 返回一个被我们的代码视为错误的值,我们可以让它直接抛出一个错误。



  test(`DynamoDB doesn't work`, async () => {
    awsSdkPromiseResponse.mockReturnValueOnce(Promise.reject(new Error('some error')));
    expect.assertions(1);
    try {
      await getPet('Pets', 'Rover');
    } catch (e) {
      expect(e.message).toBe(`some error`);
    }
  });


Enter fullscreen mode Exit fullscreen mode

(至于如何处理这类错误,留给读者自行决定。)

测试数据持久性

简而言之,我们不这样做。有些模拟工具和框架试图创建一个持久化数据存储,并模拟真实的数据库。在我看来,这与优秀的单元测试背道而驰。很快,我们就会看到一些测试依赖于其他测试来将数据置于特定状态,而这绝非我们所期望的结果。优秀的单元测试应该是完全独立且确定性的。我们可以通过模拟用于与其通信的 API 来实现这一点,而不是模拟 DynamoDB 数据库本身。

TypeScript

如果你不喜欢 TypeScript,理论上所有这些都可以用 JavaScript 实现,但我不太确定这是否是个好主意。这种方法之所以有效,其中一个原因是 DocumentClient 实际上拥有相当强的类型判断 API。如果我向调用传递无效的有效负载db.put,代码检查就会失败,我的 IDE 也会警告我代码无效。使用像 VSCode 这样的工具,即使不使用 TypeScript 也能获得一些好处,但我绝对不会在完全没有类型提示的情况下尝试这种方法。否则,你很可能会看到所有代码看起来都能正常运行,测试也通过了,但部署后却什么都运行不了。

后续步骤

这里我省略了很多内容,因为我只想专注于 Jest 模拟。尝试了几种不同的方法后,我的团队仍然使用 Webpack 打包 Lambda。Webpack 的学习曲线比较陡峭,但它运行良好且速度很快。如上所述,我们现在很少使用 SAM,我所在的团队主要依赖单元测试,并将他们自己的技术栈部署到开发环境中。事实上,我们已经构建了一个应用程序,使得 Lambda 和 CDK 测试可以同时运行,而且效果非常好。

替代文字

以上就是 Jest,如果你喜欢在短时间内看到大量测试通过,它绝对是一款高效的工具。我们还充分利用了它的快照和测试表等其他一些很棒的功能,不过我已经说得够多了,所以就留到另一篇文章再详细介绍吧。

文章来源:https://dev.to/elthrasher/mocking-aws-with-jest-and-typescript-199i