DEV Community

Kazuhiro "Kaz" Sera
Kazuhiro "Kaz" Sera

Posted on • Edited on

Easier TypeScript API Testing with Vitest + MSW

Introduction

Recently, I took time to write unit tests to ensure if my Slack Web API client library works as expected.

As someone who has developed web services for a long time, I've often found mocking HTTP requests in test scenarios to be bothersome and less flexible than I would prefer.

The Game Changer

However, I discovered a great combination that transformed my API call testing in TypeScript: Vitest and Mock Service Worker (MSW). Their well-crafted design makes them incredibly easy to use, enhancing the overall testing experience.

How It Works

For those eager to see the actual code, you can find it here: https://github.com/seratch/slack-web-api-client/blob/main/test/retry-handler.test.ts

Here’s a step-by-step guide to setting up a new project and writing effective tests:

Setting Up New Project:

Let's start by creating a new project and install the required dependences:

mkdir my-test-app
cd my-test-app
npm init -y
npm i slack-web-api-client
npm i --save-dev typescript vitest msw
Enter fullscreen mode Exit fullscreen mode

Configuring TypeScript:

Add a basic tsconfig.json for writing in TypeScript (note that you don't need to use exactly the same one):

{
  "compilerOptions": {
    "outDir": "./dist",
    "target": "es2021",
    "noImplicitAny": true,
    "module": "commonjs",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "allowJs": false
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

Start Writing Test Code:

Start by setting up Vist and MSW in a new test source file under the ./test directory:

import { setupServer } from "msw/node";
import { HttpResponse, http } from "msw";
import { afterAll, afterEach, beforeAll, describe, test, expect } from "vitest";

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
Enter fullscreen mode Exit fullscreen mode

Just by including these lines of code, you're ready to capture all outgoing HTTP requests via the fetch function and reproduce any scenario you'd like!

Now, let's add our first simple test:

import { SlackAPIClient } from "slack-web-api-client";

describe("Slack API client", async () => {
  test("can perform api.test API call", async () => {
    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return HttpResponse.json({ ok: true });
      }),
    );
    const client = new SlackAPIClient();
    const response = await client.api.test();
    expect(response.ok).true;
  });
});
Enter fullscreen mode Exit fullscreen mode

Run this test using npx vitest and check the output. If you see the following output on your terminal, congratulations! You've successfully run your first test using MSW!

$ npx vitest

 DEV  v1.5.1 /new-app

 ✓ test/sample.test.ts (1)
   ✓ Slack API client (1)
     ✓ can perform api.test API call

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:18:37
   Duration  653ms (transform 87ms, setup 0ms, collect 256ms, tests 39ms, environment 0ms, prepare 101ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit
Enter fullscreen mode Exit fullscreen mode

When you modify the server.use(...) section as shown below,

    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } });
      }),
    );
Enter fullscreen mode Exit fullscreen mode

the same test should then start to fail:

test/sample.test.ts (1) 1048ms
   ❯ Slack API client (1) 1047ms
     × can perform api.test API call 1045ms

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  test/sample.test.ts > Slack API client > can perform api.test API call
SlackAPIConnectionError: Failed to call api.test (cause: SlackAPIConnectionError: Failed to call api.test (status: 429, body: "ratelimited"))
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:581:21
 ❯ test/sample.test.ts:24:22
     22|     );
     23|     const client = new SlackAPIClient();
     24|     const response = await client.api.test();
       |                      ^
     25|     expect(response.ok).true;
     26|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { apiName: 'api.test', status: -1, body: '', headers: undefined }
Caused by: SlackAPIConnectionError: Failed to call api.test (status: 429, body: "ratelimited")
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:602:13
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:577:18
 ❯ test/sample.test.ts:24:22
Enter fullscreen mode Exit fullscreen mode

However, this library attempts a retry when it receives a rate-limited error response from Slack. Therefore, after adjusting the scenario to be more realistic,

    const responses = [
      HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } }),
      HttpResponse.json({ ok: true }),
    ];
    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return responses.shift();
      }),
    );
Enter fullscreen mode Exit fullscreen mode

or using one-time handlers works well too:

    server.use(
      http.post("https://slack.com/api/api.test", () => HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } }), { once: true }),
      http.post("https://slack.com/api/api.test", () => HttpResponse.json({ ok: true })),
    );
Enter fullscreen mode Exit fullscreen mode

the test will start passing again!

 RERUN  test/sample.test.ts x3

 ✓ test/sample.test.ts (1) 1044ms
   ✓ Slack API client (1) 1042ms
     ✓ can perform api.test API call 1041ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:28:21
   Duration  1.21s

 PASS  Waiting for file changes...
Enter fullscreen mode Exit fullscreen mode

The interaction here is very smooth. Every time you save a change to the test code, the test is immediately executed again. Additionally, the outputs from the Vitest framework are so easy to understand that you won't be confused about what to do next.

Wrap Up

For me, using Vitest and MSW has significantly changed the testing experience for SDK development. I highly recommend trying these tools!

Top comments (3)

Collapse
 
kettanaito profile image
Artem Zakharchenko

Thanks for writing this piece!

Please note that MSW has a concept of one-time handlers. Give those a try to emulate that rate limited -> successful response flow.

server.use(
  http.post("https://slack.com/api/api.test", () => {
    return HttpResponse.json(rateLimitedResponse)
  }, { once: true }),
  http.post("https://slack.com/api/api.test", () => {
    return HttpResponse.json(okResponse)
  })
)
Enter fullscreen mode Exit fullscreen mode

You can also use generators to track the number of times the same resolver is being hit. We showcase that usage for Polling but it's applicable for anything in general.

Glad you like MSW and hope it makes your developer's life a bit easier!

Collapse
 
seratch profile image
Kazuhiro "Kaz" Sera

Oh, wow! Thanks for sharing this. The one-time handlers seem to be exactly what I needed for this use case.

Collapse
 
kettanaito profile image
Artem Zakharchenko

My pleasure! Let me know if you or the team has any questions in how to improve your MSW setup.
I also have a sponsorship tier that gives your company a monthly 1h-long consulting session with me, if you'd like. You can learn more on the GitHub Sponsors profile.