在 JavaScript 应用程序中测试 Azure SDK 集成

测试用于 JavaScript 的 Azure SDK 的集成代码对于确保应用程序与 Azure 服务正确交互至关重要。

在决定是否模拟云服务 SDK 调用或使用实时服务进行测试时,务必考虑速度、可靠性和成本之间的权衡。

先决条件

模拟云服务

优点

  • 通过消除网络延迟加快测试套件。
  • 提供可预测和受控的测试环境。
  • 模拟各种方案和边缘案例更轻松。
  • 降低与使用实时云服务关联的成本,尤其是在持续集成管道中。

缺点

  • 模拟可能会偏移实际 SDK,从而导致出现差异。
  • 可能会忽略实时服务的某些功能或行为。
  • 与生产相比不太现实的环境。

使用实时服务

优点

  • 提供一个非常接近生产的现实环境。
  • 适用于集成测试,以确保系统的不同部分协同工作。
  • 帮助识别与网络可靠性、服务可用性和实际数据处理相关的问题。

缺点

  • 因网络调用而速度较慢。
  • 由于潜在的服务使用成本,费用更高。
  • 设置和维护与生产匹配的实时服务环境非常复杂且耗时。

模拟和使用实时服务之间的选择取决于测试策略。 对于速度和控制至关重要的单元测试,模拟通常是更好的选择。 对于现实性至关重要的集成测试,使用实时服务可以提供更准确的结果。 平衡这些方法有助于实现全面的测试覆盖范围,同时管控成本和保持测试效率。

测试双精度型值:模拟、存根和虚设

测试双精度型值是用于代替实际用于测试目的的任何类型的替代项。 选择的双精度型值类型基于要替换的内容。 当随意使用术语时,术语模拟通常是指任何双精度型值。 在本文中,在 Jest 测试框架中专门使用并具体说明了该术语。

模拟

模拟(也称为间谍):在函数中替换,当函数被其他代码间接调用时,能够控制和监视函数的行为

在以下示例中,有 2 个函数:

  • someTestFunction:这是需要测试的函数。 此函数会调用一个依赖项,即 dependencyFunction,该依赖项未写入,不需要测试。
  • dependencyFunctionMock:这是依赖项的模拟。
// setup
const dependencyFunctionMock = jest.fn();

// perform test
// Jest replaces the call to dependencyFunction with dependencyFunctionMock
const { name } = someTestFunction()

// verify behavior
expect(dependencyFunctionMock).toHaveBeenCalled();

测试的目的是确保 someTestFunction 的行为正确,而无需实际调用依赖项代码。 测试会验证是否调用了依赖项的模拟。

模拟大型依赖项与小型依赖项

决定模拟依赖项时,可以选择模拟所需的内容,例如:

  • 一个或两个来自较大依赖项的函数。 Jest 为此目的提供了部分模拟
  • 较小依赖项的所有函数,如本文中的示例所示。

存根

存根的目的是替换函数的返回数据,以便模拟不同的方案。 这样,代码就可以调用函数并接收各种状态,包括成功结果、失败、异常和边缘案例。 状态验证可确保代码正确处理这些方案。

// ARRANGE
const dependencyFunctionMock = jest.fn();
const fakeDatabaseData = {first: 'John', last: 'Jones'};
dependencyFunctionMock.mockReturnValue(fakeDatabaseData);

// ACT
// date is returned by mock then transformed in SomeTestFunction()
const { name } = someTestFunction()

// ASSERT
expect(name).toBe(`${first} ${last}`);

上述测试的目的是确保 someTestFunction 完成的工作符合预期结果。 在此简单示例中,函数的任务是连接名字和姓氏。 通过使用虚设数据,你知道预期结果,并且可以验证函数是否正确执行工作。

Fakes

虚设会取代通常不会在生产中使用的功能,例如使用内存中数据库,而不是云数据库。

// fake-in-mem-db.spec.ts
class FakeDatabase {
  private data: Record<string, any>;

  constructor() {
    this.data = {};
  }

  save(key: string, value: any): void {
    this.data[key] = value;
  }

  get(key: string): any {
    return this.data[key];
  }
}

// Function to test
function someTestFunction(db: FakeDatabase, key: string, value: any): any {
  db.save(key, value);
  return db.get(key);
}

// Jest test suite
describe('someTestFunction', () => {
  let fakeDb: FakeDatabase;
  let testKey: string;
  let testValue: any;

  beforeEach(() => {
    fakeDb = new FakeDatabase();
    testKey = 'testKey';
    testValue = {
      first: 'John',
      last: 'Jones',
      lastUpdated: new Date().toISOString(),
    };

    // Spy on the save method
    jest.spyOn(fakeDb, 'save');
  });

  afterEach(() => {
    // Clear all mocks
    jest.clearAllMocks();
  });

  test('should save and return the correct value', () => {
    // Perform test
    const result = someTestFunction(fakeDb, testKey, testValue);

    // Verify state
    expect(result).toEqual(testValue);
    expect(result.first).toBe('John');
    expect(result.last).toBe('Jones');
    expect(result.lastUpdated).toBe(testValue.lastUpdated);

    // Verify behavior
    expect(fakeDb.save).toHaveBeenCalledWith(testKey, testValue);
  });
});

上述测试的目的是确保 someTestFunction 与数据库正确交互。 通过使用虚设内存中数据库,无需依赖实际数据库即可测试函数的逻辑,从而使测试更快、更可靠。

方案:使用 Azure SDK 将文档插入到 Cosmos DB 中

假设有一个这样的应用程序:如果提交并验证了所有信息,则应用程序需要将新文档写入到 Cosmos DB 中。 如果提交了空窗体或信息与预期格式不匹配,则应用程序不应输入数据。

Cosmos DB 将用作示例,但概念适用于大多数用于 JavaScript 的 Azure SDK。 以下函数会捕获此功能:

// insertDocument.ts
import { Container } from '../data/connect-to-cosmos';
import {
  DbDocument,
  DbError,
  RawInput,
  VerificationErrors,
} from '../data/model';
import { inputVerified } from '../data/verify';

export async function insertDocument(
  container: Container,
  doc: RawInput,
): Promise<DbDocument | DbError | VerificationErrors> {
  const isVerified: boolean = inputVerified(doc);

  if (!isVerified) {
    return { message: 'Verification failed' } as VerificationErrors;
  }

  try {
    const { resource } = await container.items.create({
      id: doc.id,
      name: `${doc.first} ${doc.last}`,
    });

    return resource as DbDocument;
  } catch (error: any) {
    if (error instanceof Error) {
      if ((error as any).code === 409) {
        return {
          message: 'Insertion failed: Duplicate entry',
          code: 409,
        } as DbError;
      }
      return { message: error.message, code: (error as any).code } as DbError;
    } else {
      return { message: 'An unknown error occurred', code: 500 } as DbError;
    }
  }
}

注意

TypeScript 类型有助于定义函数使用的数据类型。 虽然不需要 TypeScript 使用 Jest 或其他 JavaScript 测试框架,但它对于写入类型安全的 JavaScript 至关重要。

上面这个应用程序中的函数包括:

函数 说明
insertDocument 将文档插入到数据库中。 这就是我们想要测试的内容
inputVerified 根据架构验证输入数据。 确保数据采用正确的格式(例如,有效的电子邮件地址、格式正确的 URL)。
cosmos.items.create 使用 @azure/cosmos 的 Azure Cosmos DB 的 SDK 函数。 这就是我们想要模拟的内容。 它已经拥有由包所有者维护的自己的测试。 我们需要验证 Cosmos DB 函数调用是否已发出,以及是否在传入数据通过验证时返回了数据。

安装测试框架依赖项

本文将 Jest 用作测试框架。 还有其他类似的测试框架,也可以使用这些框架。

在应用程序目录的根目录中,使用以下命令安装 Jest:

npm install jest

配置包以运行测试

使用新脚本更新应用程序的 package.json,以测试源代码文件。 通过匹配部分文件名和扩展名,可定义源代码文件。 Jest 会查找遵循测试文件常见命名约定的文件:<file-name>.spec.[jt]s。 此模式意味着名称如以下示例的文件将被解释为测试文件,并将由 Jest 运行:

  • *.test.js:例如,math.test.js
  • *.spec.js:例如,math.spec.js
  • 位于 tests 目录中的文件,例如 tests/math.js

将脚本添加到 package.json,以支持使用 Jest 的该测试文件模式:

"scripts": {
    "test": "jest dist",
}

TypeScript 源代码将在 dist 子文件夹中生成。 Jest 会运行在 dist 子文件夹中找到的 .spec.js 文件。

为 Azure SDK 设置单元测试

如何使用模拟、存根和虚设来测试 insertDocument 函数?

  • 模拟:我们需要模拟才能确保函数的行为经过测试,例如:
    • 如果数据确实通过验证,则对 Cosmos DB 函数的调用仅执行 1 次
    • 如果数据未通过验证,则不会调用 Cosmos DB 函数
  • 存根:
    • 传入的数据与函数返回的新文档匹配。

测试时,考虑测试设置、测试本身和验证。 以测试行话表述时,这称为:

  • 排列:设置测试条件
  • 操作:调用函数进行测试,也称为受测系统或 SUT
  • 断言:验证结果。 结果可以是行为或状态。
    • 行为指示测试函数中可以验证的功能。 一个示例是调用了某个依赖项。
    • 状态指示从函数返回的数据。

Jest 与其他测试框架类似,具有测试文件样本可用于定义测试文件。

// boilerplate.spec.ts

describe('nameOfGroupOfTests', () => {
  beforeEach(() => {
    // Setup required before each test
  });
  afterEach(() => {
    // Cleanup required after each test
  });

  it('should <do something> if <situation is present>', async () => {
    // Arrange
    // - set up the test data and the expected result
    // Act
    // - call the function to test
    // Assert
    // - check the state: result returned from function
    // - check the behavior: dependency function calls
  });
});

使用模拟时,该样本位置需要使用模拟来测试函数,而无需调用函数中使用的基础依赖项,例如 Azure 客户端库。

创建测试文件

除了常用的测试样本代码外,含用于模拟依赖项调用的模拟的测试文件还有一些额外的设置。 测试文件有以下几个部分:

  • import:利用 import 语句,可使用或模拟任何测试。
  • jest.mock:创建所需的默认模拟行为。 每个测试都可以根据需要进行更改。
  • describeinsert.ts 文件的测试组系列。
  • testinsert.ts 文件的每个测试。

测试文件涵盖 insert.ts 文件的三个测试,可分为两种验证类型:

验证类型 测试
正常路径:should insert document successfully 调用了模拟的数据库方法,并返回了已修改的数据。
错误路径:should return verification error if input is not verified 数据验证失败,并返回了错误。
错误路径:should return error if db insert fails 调用了模拟的数据库方法,并返回了错误。

以下 Jest 测试文件演示了如何测试 insertDocument 函数。

// insertDocument.test.ts
import { Container } from '../data/connect-to-cosmos';
import { createTestInputAndResult } from '../data/fake-data';
import {
  DbDocument,
  DbError,
  isDbError,
  isVerificationErrors,
  RawInput,
} from '../data/model';
import { inputVerified } from '../data/verify';
import { insertDocument } from './insert';

// Mock app dependencies for Cosmos DB setup
jest.mock('../data/connect-to-cosmos', () => ({
  connectToContainer: jest.fn(),
  getUniqueId: jest.fn().mockReturnValue('unique-id'),
}));

// Mock app dependencies for input verification
jest.mock('../data/verify', () => ({
  inputVerified: jest.fn(),
}));

describe('insertDocument', () => {
  // Mock the Cosmo DB Container object
  let mockContainer: jest.Mocked<Container>;

  beforeEach(() => {
    // Clear all mocks before each test
    jest.clearAllMocks();

    // Mock the Cosmos DB Container create method
    mockContainer = {
      items: {
        create: jest.fn(),
      },
    } as unknown as jest.Mocked<Container>;
  });

  it('should return verification error if input is not verified', async () => {
    // Arrange - Mock the input verification function to return false
    jest.mocked(inputVerified).mockReturnValue(false);

    // Arrange - wrong shape of doc on purpose
    const doc = { name: 'test' };

    // Act - Call the function to test
    const insertDocumentResult = await insertDocument(
      mockContainer,
      doc as unknown as RawInput,
    );

    // Assert - State verification: Check the result when verification fails
    if (isVerificationErrors(insertDocumentResult)) {
      expect(insertDocumentResult).toEqual({
        message: 'Verification failed',
      });
    } else {
      throw new Error('Result is not of type VerificationErrors');
    }

    // Assert - Behavior verification: Ensure create method was not called
    expect(mockContainer.items.create).not.toHaveBeenCalled();
  });

  it('should insert document successfully', async () => {
    // Arrange - create input and expected result data
    const { input, result }: { input: RawInput; result: Partial<DbDocument> } =
      createTestInputAndResult();

    // Arrange - mock the input verification function to return true
    (inputVerified as jest.Mock).mockReturnValue(true);
    (mockContainer.items.create as jest.Mock).mockResolvedValue({
      resource: result,
    });

    // Act - Call the function to test
    const insertDocumentResult = await insertDocument(mockContainer, input);

    // Assert - State verification: Check the result when insertion is successful
    expect(insertDocumentResult).toEqual(result);

    // Assert - Behavior verification: Ensure create method was called with correct arguments
    expect(mockContainer.items.create).toHaveBeenCalledTimes(1);
    expect(mockContainer.items.create).toHaveBeenCalledWith({
      id: input.id,
      name: result.name,
    });
  });

  it('should return error if db insert fails', async () => {
    // Arrange - create input and expected result data
    const { input, result } = createTestInputAndResult();

    // Arrange - mock the input verification function to return true
    jest.mocked(inputVerified).mockReturnValue(true);

    // Arrange - mock the Cosmos DB create method to throw an error
    const mockError: DbError = {
      message: 'An unknown error occurred',
      code: 500,
    };
    jest.mocked(mockContainer.items.create).mockRejectedValue(mockError);

    // Act - Call the function to test
    const insertDocumentResult = await insertDocument(mockContainer, input);

    // Assert - verify type as DbError
    if (isDbError(insertDocumentResult)) {
      expect(insertDocumentResult.message).toBe(mockError.message);
    } else {
      throw new Error('Result is not of type DbError');
    }

    // Assert - Behavior verification: Ensure create method was called with correct arguments
    expect(mockContainer.items.create).toHaveBeenCalledTimes(1);
    expect(mockContainer.items.create).toHaveBeenCalledWith({
      id: input.id,
      name: result.name,
    });
  });
});

其他信息