Muokkaa

Jaa


Testing Azure SDK integration in JavaScript applications

Testing your integration code for the Azure SDK for JavaScript is essential to ensure your applications interact correctly with Azure services.

When deciding whether to mock out cloud service SDK calls or use a live service for testing purposes, it's important to consider the trade-offs between speed, reliability, and cost.

Prerequisites

Mocking cloud services

Pros:

  • Speeds up test suite by eliminating network latency.
  • Provides predictable and controlled test environments.
  • Easier to simulate various scenarios and edge cases.
  • Reduces costs associated with using live cloud services, especially in continuous integration pipelines.

Cons:

  • Mocks may drift from the actual SDK, leading to discrepancies.
  • Might ignore certain features or behaviors of the live service.
  • Less realistic environment compared to production.

Using a live service

Pros:

  • Provides a realistic environment that closely mirrors production.
  • Useful for integration tests to ensure different parts of the system work together.
  • Helps identify issues related to network reliability, service availability, and actual data handling.

Cons:

  • Slower due to network calls.
  • More expensive due to potential service usage costs.
  • Complex and time-consuming to set up and maintain a live service environment that matches production.

The choice between mocking and using live services depends on your testing strategy. For unit tests where speed and control are paramount, mocking is often the better choice. For integration tests where realism is crucial, using a live service can provide more accurate results. Balancing these approaches helps achieve comprehensive test coverage while managing costs and maintaining test efficiency.

Test doubles: Mocks, stubs, and fakes

A test double is any kind of substitute used in place of something real for testing purposes. The type of double you choose is based on what you want it to replace. The term mock is often meant as any double when the term is used casually. In this article, the term is used specifically and illustrated specifically in the Jest test framework.

Mocks

Mocks (also called spies): Substitute in a function and be able to control and spy on the behavior of that function when it's called indirectly by some other code.

In the following examples, you have 2 functions:

  • someTestFunction: This is the function you need to test. It calls a dependency, dependencyFunction, which you didn't write and don't need to test.
  • dependencyFunctionMock: This is a mock of the dependency.
// setup
const dependencyFunctionMock = jest.fn();

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

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

The purpose of the test is to ensure that someTestFunction behaves correctly without actually invoking the dependency code. The test validates that the mock of the dependency was called.

Mock large versus small dependencies

When you decide to mock a dependency, you can choose to mock just what you need such as:

  • A function or two from a larger dependency. Jest offers partial mocks for this purpose.
  • All functions of a smaller dependency, as shown in the example in this article.

Stubs

The purpose of stubs is to replace a function's return data to simulate different scenarios. This allows your code to call the function and receive various states, including successful results, failures, exceptions, and edge cases. State verification ensures your code handles these scenarios correctly.

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

The purpose of the preceding test is to ensure that the work done by someTestFunction meets the expected outcome. In this simple example, the function's task is to concatenate the first and family names. By using fake data, you know the expected result and can validate that the function performs the work correctly.

Fakes

Fakes substitute a functionality that you wouldn't normally use in production, such as using an in-memory database instead of a cloud database.

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

The purpose of the preceding test is to ensure that someTestFunction correctly interacts with the database. By using a fake in-memory database, you can test the function's logic without relying on a real database, making the tests faster and more reliable.

Scenario: Inserting a document into Cosmos DB using Azure SDK

Imagine you have an application that needs to write a new document to Cosmos DB if all the information is submitted and verified. If an empty form is submitted or the information doesn't match the expected format, the application shouldn't enter the data.

Cosmos DB is used as an example, however the concepts apply to most of the Azure SDKs for JavaScript. The following function captures this functionality:

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

Note

TypeScript types help define the kinds of data a function uses. While you don't need TypeScript to use Jest or other JavaScript testing frameworks, it is essential for writing type-safe JavaScript.

The functions in this application above are:

Function Description
insertDocument Inserts a document into the database. This is what we want to test.
inputVerified Verifies the input data against a schema. Ensures data is in the correct format (for example, valid email addresses, correctly formatted URLs).
cosmos.items.create SDK function for Azure Cosmos DB using the @azure/cosmos. This is what we want to mock. It already has its own tests maintained by the package owners. We need to verify that the Cosmos DB function call was made and returned data if the incoming data passed verification.

Install test framework dependency

This article uses Jest as the test framework. There are other test frameworks, which are comparable you can also use.

In the root of the application directory, install Jest with the following command:

npm install jest

Configure package to run test

Update the package.json for the application with a new script to test our source code files. Source code files are defined by matching on partial file name and extension. Jest looks for files following the common naming convention for test files: <file-name>.spec.[jt]s. This pattern means files named like the following examples will be interpreted as test files and run by Jest:

  • *.test.js: For example, math.test.js
  • *.spec.js: For example, math.spec.js
  • Files located in a tests directory, such as tests/math.js

Add a script to the package.json to support that test file pattern with Jest:

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

The TypeScript source code is generated into the dist subfolder. Jest runs the .spec.js files found in the dist subfolder.

Set up unit test for Azure SDK

How can we use mocks, stubs, and fakes to test the insertDocument function?

  • Mocks: we need a mock to make sure the behavior of the function is tested such as:
    • If the data does pass verification, the call to the Cosmos DB function happened only 1 time
    • If the data doesn't pass verification, the call to the Cosmos DB function didn't happen
  • Stubs:
    • The data passed in matches the new document returned by the function.

When testing, think in terms of the test setup, the test itself, and the verification. In terms of test vernacular, this is known as:

  • Arrange: set up your test conditions
  • Act: call your function to test, also known as the system under test or SUT
  • Assert: validate the results. Results can be behavior or state.
    • Behavior indicates functionality in your test function, which can be verified. One example is that some dependency was called.
    • State indicates the data returned from the function.

Jest, similar with other test frameworks, has test file boilerplate to define your test file.

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

When using mocks, that boiler place needs to use mocking to test the function without calling the underlying dependency used in the function, such as the Azure client libraries.

Create the test file

The test file with mocks to simulate a call to a dependency has some extra setup in additional to the common test boilerplate code. There are several parts to the test file below:

  • import: The import statements allow you to use or mock out any of your test.
  • jest.mock: Create the default mock behavior you want. Each test can alter as needed.
  • describe: Test group family for the insert.ts file.
  • test: Each test for the insert.ts file.

The test file covers three tests for the insert.ts file, which can be divided into two validation types:

Validation type Test
Happy path: should insert document successfully The mocked database method was called, and returned the altered data.
Error path: should return verification error if input is not verified Data failed validation and returned an error.
Error path:should return error if db insert fails The mocked database method was called, and returned an error.

The following Jest test file shows how to test the insertDocument function.

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

Additional information