Mock Service Worker - Enterprise-CMCS/macpro-mako GitHub Wiki

Introduction

Mock Service Worker (msw) is an API mocking library that allows us to write reusable mock responses and use them across any frameworks, tools, and environments. It does this by intercepting http(s), graphql, and ws requests and handling responses.

How We Are Using It

Our mocks are contained inside the mocks directory. We have handlers to mock all of the front-end calls to the API and all the back-end calls to 3rd party services (AWS services, OpenSearch, Kafka, etc). The front-end, back-end, and email tests set up the mock servers in their vitest.setup.ts files, this allows the mocks to be used in the tests without additional set up in each test file. This reduces the amount of code needed for tests, eliminating the need to recreate the same mocks over and over again for different tests. It also prevents the tests from simply testing the mocks. Often the mocks are written too close to the actual code being tested which can cause the tests to deviate from the actual functionality.

Mock Data

We have mock data created for data used in the system, e.g. users, items, attachment, lambdas, secrets, etc. The handlers return this data based on the request. That allows us to have tailored responses for different use cases.

Users

We have mock users for every role type in the system, and a user with no roles. They are all UserData types to match what is coming back from IDM. The convenience method convertUserAttributes (see below) can be used to convert the object from UserData to CognitoUserAttributes which is the format used by useGetUser and other internal authentication and authorization checks. Additional user exports available from mocks are:

  • default from mocks/data/users - a list of all of the usernames as objects [{ username: <username> }]
  • userResponses - a list of all of the UserData objects
  • TEST_STATE_SUBMITTER_USER - default State Submitter and default test user as a CognitoUserAttributes
  • TEST_CO_STATE_SUBMITTER_USER - State Submitter from Colorado, used to test invalid authorization, as a CognitoUserAttributes
  • TEST_CMS_REVIEWER_USER - default CMS Reviewer as a CognitoUserAttributes
  • TEST_HELP_DESK_USER - a user with the role for help desk as a CognitoUserAttributes
  • TEST_READ_ONLY_USER - a user with the role for read-only as a CognitoUserAttributes
  • TEST_SUPER_USER - a user with the role for super user as a CognitoUserAttributes

Items

Items represent the SPA and Waiver packages created in the system and returned from OpenSearch. They are stored in a map where the key is their Id and the value is an object in ItemResult typed object. There are different types of items included to cover different scenarios. All of the Item Ids are exported as constants named to indicate their use. In addition to all of the exported Ids and the map of Items (exported as default from mocks/data/items), the following are also exported:

  • TEST_*_ITEM - various items as opensearch.main.ItemResult types, representing different SPAs and Waiver types
  • itemList - a list of all of the test items as ItemResult types
  • docList - a list of all of the test items as opensearch.main.Document types
  • getFilteredItemList - a helper method that takes in an array of authorities and returns the corresponding items as an opensearch.main.ItemResult array
  • getFilteredDocList - a helper method that takes in an array of authorities and returns the data within the _source field for all of the corresponding items as an opensearch.main.Document array

Introduction to msw Handler

Note

The mocks directory has a consts.ts file with the test specific environment variables. This will override the local environment variables and ensure that the endpoints are always the same.

A handler is written using the http namespace imported from msw and calling a method corresponding to a http method (GET, POST, PUT, etc), which takes in the request path it should be incepting and a callback for what to do once that request is intercepted. The callbacks provide access to the params in the path, the request, and cookies sent with the request.

See the msw documentation for more information on handling requests.

Here is an example of the handler using the path params:

import { http, HttpResponse, PathParams } from "msw";
// ... additional imports

const defaultOSMainDocumentHandler = http.get(
  `https://vpc-opensearchdomain-mock-domain.us-east-1.es.amazonaws.com/test-namespace-main/_doc/:id`,
  async ({ params }) => {
    const { id } = params;

    // ... additional handler code
  },
);

Here is an example of the handler using the request body:

Note

Typescript will require that there is a type specified for the request body or it will complain about trying to access data within the request. The types for the mocks directory are contained in index.d.ts, add new types there.

import { http, HttpResponse, PathParams } from "msw";
// ... additional imports

const defaultOSMainSearchHandler = http.post<PathParams, SearchQueryBody>(
  "https://vpc-opensearchdomain-mock-domain.us-east-1.es.amazonaws.com/test-namespace-main/_search",
  async ({ request }) => {
    const { query } = await request.json();

    // ... additional handler code
  },
);

Conventions

msw allows you to specify handlers that the mock server should always be using. In our application, this is indicated by naming the handler default<mocked endpoint description>Handler. All of the default handlers are exported in an array at the end of the file. For instance, the handlers for the OpenSearch main endpoints are exported at the end of handlers/opensearch/main.ts like this:

export const mainSearchHandlers = [
  defaultOSMainDocumentHandler,
  defaultOSMainMultiDocumentHandler,
  defaultOSMainSearchHandler,
  defaultUpdateHandler,
];

If the handler file is nested within a directory, the default handlers for each file are imported into an index.ts file and exported as a group. For instance, the handlers for all of the OpenSearch endpoints are exported from handlers/opensearch/index.ts like this:

import { changelogSearchHandlers } from "./changelog";
import { cpocSearchHandlers } from "./cpocs";
import { indexHandlers } from "./indices";
import { mainSearchHandlers } from "./main";
import { securityHandlers } from "./security";
import { subtypeSearchHandlers } from "./subtypes";
import { typeSearchHandlers } from "./types";

export const opensearchHandlers = [
  ...changelogSearchHandlers,
  ...cpocSearchHandlers,
  ...indexHandlers,
  ...mainSearchHandlers,
  ...securityHandlers,
  ...subtypeSearchHandlers,
  ...typeSearchHandlers,
];

These handlers are then imported in the handlers/index.ts file and used to to create the default handlers for the different types of mock servers we use.

import { apiHandlers } from "./api";
import { awsHandlers } from "./aws";
import { countiesHandlers } from "./counties";
import { opensearchHandlers } from "./opensearch";

// Handlers that mock calls to the API (used by the tests in `react-app`)
export const defaultApiHandlers = [...apiHandlers, ...countiesHandlers];

// Handlers that mock calls to 3rd party services from the API (used by the tests in `lib`)
export const defaultServiceHandlers = [...awsHandlers, ...opensearchHandlers, ...countiesHandlers];

// Handlers that mock calls to the API and 3rd party services (used by the tests in `email`)
export default [...apiHandlers, ...awsHandlers, ...opensearchHandlers, ...countiesHandlers];

The mock servers are actually created in the server.ts file using those exported handlers.

import { setupServer } from "msw/node";

import handlers, { defaultApiHandlers, defaultServiceHandlers } from "./handlers";

// mocked server with the API handlers
export const mockedApiServer = setupServer(...defaultApiHandlers);

// mocked server with the 3rd party services handlers
export const mockedServiceServer = setupServer(...defaultServiceHandlers);

// mocked server with all of the handlers
export const mockedServer = setupServer(...handlers);

The vitest.setup.ts in each group of tests imports the correct mocked server and starts it up, like this example from lib/vitest.setup.ts:

import { mockedServiceServer as mockedServer } from "mocks/server";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
// ... additional imports

beforeAll(() => {
  // ... addition test setup
  console.log("starting MSW listener for lib tests");
  mockedServer.listen({
    onUnhandledRequest: "warn",
  });
});

beforeEach(() => {
  // ... setting all the test environment variables
});

afterEach(() => {
  // ... additional test tear down
  mockedServer.resetHandlers();
});

afterAll(() => {
  // ... additional test tear down
  mockedServer.close();
});

Testing Errors and Outliers

msw also us to override handlers using the use method on the mocked server specified in the test setup. This allows us to create handlers to be used on a per test basis. Our application uses these handlers for testing error and outlier conditions.

See the msw documentation for more information on overrides.

Here is an example of an error handlers for returning a 500 error when searching for a CPOC:

export const errorOSCpocSearchHandler = http.post(
  "https://vpc-opensearchdomain-mock-domain.us-east-1.es.amazonaws.com/test-namespace-cpocs/_search",
  () => new HttpResponse("Internal server error", { status: 500 }),
);

Here is an example of how to use this error handler for a single test:

import { emptyOSCpocSearchHandler, errorOSCpocSearchHandler } from "mocks";
import { mockedServiceServer } from "mocks/server"; // must be the same mocked server that is specified in the vitest.setup.ts file
import { describe, expect, it } from "vitest";
import { handler } from "./getCpocs";
// ... additional imports

describe("getCpocs Handler", () => {
  it("should return 500 if an error occurs during processing", async () => {
    // using the same mocked server from the `vitest.setup.ts` tell it to use
    // the error handler for this instance
    mockedServiceServer.use(errorOSCpocSearchHandler);

    const event = { body: JSON.stringify({}) } as APIGatewayEvent;

    const res = await handler(event);

    expect(res.statusCode).toEqual(500);
    expect(res.body).toEqual(JSON.stringify({ message: "Internal server error" }));
  });
});

The override is cleared at the end of the test because we specified this in the test setup

afterEach(() => {
  // ... additional test tear down
  mockedServer.resetHandlers();
});

Conventions

In our application, the override handler are named <override result><mocked endpoint description>Handler, for instance emptyOSCpocSearchHandler or errorOSCpocSearchHandler. The override handlers are exported directly from the file.

If the handler file is nested within a directory, the override handlers are exported from an index.ts file. For instance, the override handlers for all of the OpenSearch endpoints are exported from handlers/opensearch/index.ts like this:

export { emptyOSCpocSearchHandler, errorOSCpocSearchHandler } from "./cpocs";
export {
  errorCreateIndexHandler,
  errorUpdateFieldMappingHandler,
  errorBulkUpdateDataHandler,
  rateLimitBulkUpdateDataHandler,
  errorDeleteIndexHandler,
} from "./indices";
export { errorOSMainMultiDocumentHandler } from "./main";
export { errorSecurityRolesMappingHandler } from "./security";
export { errorOSSubtypeSearchHandler } from "./subtypes";
export { errorOSTypeSearchHandler } from "./types";

These handlers are then exported from the handlers/index.ts file like this.

export * from "./api";
export * from "./aws";
export * from "./opensearch";

Helper Functions

Authentication and Authorization

The mocks use an environment variable MOCK_USER_USERNAME to set the identity of the current test user. All of the authentication and authorization mocks use this variable when setting up their user information.

Important

The correct user must be set before rendering the component or calling the endpoint. Otherwise, it will use the default State Submitter because that is reset in the test setup afterEach function.

setMockUsername

Used to change the value of MOCK_USER_USERNAME. It accepts a username, a user data object containing a Username, or null (for testing unauthenticated users). Here are some examples of it being used:

import {
  setMockUsername,
  TEST_CMS_REVIEWER_USER,
  TEST_HELP_DESK_USER,
  TEST_READ_ONLY_USER,
  TEST_STATE_SUBMITTER_USER,
} from "mocks";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

describe("SpasList", () => {
  // ... setup function

  afterEach(() => {
    vi.clearAllMocks();
  });

  it("should return no columns if the user is not logged in", async () => {
    setMockUsername(null);

    // ... component setup and test code
  });

  describe.each([
    ["State Submitter", TEST_STATE_SUBMITTER_USER.username, true, false],
    ["CMS Reviewer", TEST_CMS_REVIEWER_USER.username, true, true],
    ["CMS Help Desk User", TEST_HELP_DESK_USER.username, false, false],
    ["CMS Read-Only User", TEST_READ_ONLY_USER.username, false, true],
  ])("as a %s", (title, username, hasActions, useCmsStatus) => {
    let user;
    beforeAll(async () => {
      skipCleanup();

      setMockUsername(username);

      // ... component setup
    });

    beforeEach(() => {
      setMockUsername(username);
    });

    afterAll(() => {
      cleanup();
    });

    // ... additional test code
  });
  // ... additional test code
});

setDefaultStateSubmitter

Used to change the value of MOCK_USER_USERNAME to the username of the mocked State Submitter user. Most of the tests are performed as this user so the afterEach in the vitest.setup.ts files call this method to reset the test user. Here is an example of the afterEach in the react-app/vitest.setup.ts:

afterEach(() => {
  vi.useRealTimers();
  vi.clearAllMocks();

  setDefaultStateSubmitter();
  // Reset any request handlers that we may add during the tests,
  // so they don't affect other tests.
  mockedServer.resetHandlers();

  if (process.env.SKIP_CLEANUP) return;
  cleanup();
});

setDefaultReviewer

Used to change the value of MOCK_USER_USERNAME to the username of the mocked CMS Reviewer user. Here is an example of using it in a test:

import { fireEvent, screen, waitFor } from "@testing-library/react";
import {
  setDefaultReviewer,
  setDefaultStateSubmitter,
} from "mocks";
import { beforeEach, describe, expect, test, vi } from "vitest";
// ... additional imports

describe("ActionForm", () => {
  beforeEach(() => {
    setDefaultStateSubmitter();
    vi.clearAllMocks();
  });

  test("doesn't render form if user access is denied", async () => {
    setDefaultReviewer(); // set the user before rendering the component

    // ... rendering code

    expect(screen.queryByText("Action Form Title")).not.toBeInTheDocument();
    setDefaultStateSubmitter(); // not necessary, but it's good practice to reset it to the default test user at the end
  });
  // ... additional test code
});

mockUseGetUser

Used to mock the response expected from the UseGetUser hook. Here is an example of using it to mock the hook:

import { UseQueryResult } from "@tanstack/react-query";
import { mockUseGetUser } from "mocks";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import * as api from "@/api";
import { OneMacUser } from "@/api";
// ... additional imports

describe("Timeout Modal", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.spyOn(api, "useGetUser").mockImplementation(() => {
      const response = mockUseGetUser();
      return response as UseQueryResult<OneMacUser, unknown>;
    });

    // ... additional setup code
  });

  // ... additional test code
});

getRequestContext

Used to set a user context for Lambda handler events. The Lambda even requires the requestContext to determine if the user is logged in and if they have access to the data. The helper accepts a user name, a user data object with a Username, and if nothing is sent in it used the MOCK_USER_USERNAME environment variable if it is set. If one of those values is set, it uses the username to create an APIGatewayEventRequestContext. Here is an example of using it when testing a Lambda handler:

import { APIGatewayEvent } from "aws-lambda";
import {
  getRequestContext,
  WITHDRAWN_CHANGELOG_ITEM_ID,
} from "mocks";
import items from "mocks/data/items";
import { describe, expect, it } from "vitest";

import { handler } from "./item";

describe("getItemData Handler", () => {
  it("should return 200 with package data, children, and changelog if authorized", async () => {
    const packageData = items[WITHDRAWN_CHANGELOG_ITEM_ID];

    const event = {
      body: JSON.stringify({ id: WITHDRAWN_CHANGELOG_ITEM_ID }),
      requestContext: getRequestContext(),
    } as APIGatewayEvent;

    const res = await handler(event);

    expect(res).toBeTruthy();
    expect(res.statusCode).toEqual(200);
    expect(res.body).toEqual(JSON.stringify(packageData));
  });

  // ... additional test code
});

findUserByUsername

Used to find a mocked user by username, if a user is not found it returns undefined.

convertUserAttributes

Used to convert a user data object from a UserData type to a CognitoUserAttributes type.

Search

The OpenSearch handlers often need to parse queries to know that to return, so there are some query parsing helpers in mocks/handlers/search.utils.ts:

getTermKeys

Used to get the keys from term and terms search criteria. The function takes in the query or queries and returns a list of keys as strings.

getTermValues

Used to get the value of a search term key. The function takes in a query or queries and the search term key and returns a string, an array or strings, or undefined.

Here is an example of getTermKeys and getTermValues being used in mocks/handlers/opensearch/changelog.ts:

async ({ request }) => {
  const { query } = await request.json();
  const must = query?.bool?.must;
  const mustTerms = must ? getTermKeys(must) : []; // get the terms out of the query

  // ... additional code

  const packageId =
    Array.isArray(packageIdValue) && packageIdValue.length > 0
      ? packageIdValue[0]?.toString()
      : packageIdValue?.toString();

  // ... additional code

  const item = items[packageId] || null;

  if (item?._source) {
    let changelog: TestChangelogItemResult[] =
      (item._source?.changelog as TestChangelogItemResult[]) || [];
    if (changelog.length > 0) {
      mustTerms.forEach((term) => { // looping through the term keys
        const filterValue = getTermValues(must, term); // use the query and the term key to get the values for it
        // ... additional code
      });
    }
  }
};

getFilterValue

Used to get a value from the query based on the query type and key. The function takes in a query, a query type, and a query key and returns a string, an array of strings, or undefined.

There are additional helper functions to return the filter value in specific formats, which are more useful in the handlers. They all have the same arguments as getFilterValue.

getFilterValueAsBoolean

Used to return the value as a boolean or undefined.

getFilterValueAsNumber

Used to return the value as a number or undefined. Here is an example of it being used in mocks/handlers/opensearch/types.ts:

async ({ request }) => {
  const { query } = await request.json();
  const must = query?.bool?.must;

  const authorityId = getFilterValueAsNumber(must, "match", "authorityId");
  // ... additional code
};

getFilterValueAsNumberArray

Used to return the values as a number array. If the value is undefined, an empty array is returned. Here is an example of it being used in mocks/handlers/opesearch/subtypes.ts:

async ({ request }) => {
  const { query } = await request.json();
  const must = query?.bool?.must;

  const authorityId = getFilterValueAsNumber(must, "match", "authorityId");
  const typeIds = getFilterValueAsNumberArray(must, "terms", "typeId");
  // ... additional code
}

getFilterValuesAsString

Used to return the value as a string or undefined.

getFilterValueAsStringArray

Used to return the values as a string array. If the value is undefined, an empty array is returned. If the value was a comma delimited string, the string is split into different strings in the array. Here is an example of it being used in mocks/handlers/api/search.ts:

async ({ params, request }) => {
  const { index } = params;
  const { query, aggs } = await request.json();

  const must = query?.bool?.must;

  if (index === "main") {
    const authorityValues =
      getFilterValueAsStringArray(must, "terms", "authority.keyword") ||
      getFilterValueAsStringArray(must, "terms", "authority") || [];
    // ... additional code
  }
  // ... additional code
}

matchFilter

Used to determine if the Item matches the query. The function takes in the Item, the query key, and the query value. If any of the arguments are undefined, it returns false. If the query value is an array, it checks if the value from the Item is included in the match value. If the query value is a string, it checks if the value from the Item matches the query value.

filterItemsByTerm

Used to return only the Items that match the match key and value. The function takes in a list of Items, the query key, and the query value.

getAggregations

Used to return aggregations that can be added to the OpenSearch response. Only returns the aggregations included in the query.

Kafka

mockedKafka

Used to set up a global mock for Kafka in the lib/vitest.setup.ts. It uses the mockedProducer and mockedAdmin (see below), so those are globally mocked in the lib tests as well.

import { ConfigResourceTypes } from "kafkajs";
import { mockedKafka } from "mocks";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
type CreateType<T> = T & { default: T };

vi.mock("kafkajs", async (importOriginal) => ({
  ...(await importOriginal<typeof import("kafkajs")>()),
  ConfigResourceTypes: await importOriginal<CreateType<typeof ConfigResourceTypes>>(),
  Kafka: mockedKafka,
}));

mockedProducer and mockedAdmin

Convenience objects that mock out the Kafka Producer and Admin. Because they are used by mockedKafka (see above), they can be used like mocks within the tests. Here is an example of how mockedProducer is used as a spy:

import { mockedProducer } from "mocks";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { getProducer, produceMessage } from "./kafka";

describe("Kafka producer functions", () => {
  let brokerString: string | undefined;

  beforeEach(() => {
    brokerString = process.env.brokerString;
    process.env.brokerString = "broker1,broker2";
  });

  afterEach(() => {
    process.env.brokerString = brokerString;
  });

  it("should create a Kafka producer", () => {
    const producer = getProducer();
    expect(producer).toEqual(mockedProducer); // this is the case because it's mocked globally
  });
  // ... additional test code
});

They can also be used to do a per test mock implementation. Here is an example of mockedAdmin being used as a spy and changing the mocked the implementation for a single test:

import { mockedAdmin, TOPIC_ONE, TOPIC_THREE } from "mocks";
import { afterEach, describe, expect, it, vi } from "vitest";

import { createTopics, deleteTopics } from "./topics-lib";

describe("topics-lib test", () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("tries to delete existing topics fails for bad filename", async () => {
    await deleteTopics("", [TOPIC_ONE]);
    expect(mockedAdmin.deleteTopics).toBeCalledTimes(1); // being used as a spy
  });

  it("deletes existing topics fails for bad filename", async () => {
    // creating a mock implementation for this test
    await expect(deleteTopics("", ["topic1"])).rejects.toThrowError(
      "ERROR: The deleteTopics function only operates against topics that match /.*--.*--.*--.*/g",
    );
  });
  // ...additional tests
});
⚠️ **GitHub.com Fallback** ⚠️