Easier TypeScript API Testing with Vitest + MSW
Tags: typescript, vitest, msw, slack
Published: 2024-04-25 08:56:48

## 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](https://vitest.dev/) and [Mock Service Worker (MSW)](https://mswjs.io/). 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:

```bash
mkdir my-test-app
cd my-test-app
npm init -y
npm i slack-web-api-client
npm i --save-dev typescript vitest msw
```

### Configuring TypeScript:


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

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

### Start Writing Test Code:

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

```typescript
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());
```

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:

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

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!

```bash
$ 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
```

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

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

the same test should then start to fail:

```bash
 ❯ 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
```

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,

```typescript
    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();
      }),
    );
```

or using [one-time handlers](https://mswjs.io/docs/api/http#once) works well too:

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

the test will start passing again!

```bash
 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...
```

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!