How to test a Typescript app using vitest and msw

Sandro Maglione

Sandro Maglione

Web development

In this article we are going to use vitest in combination with msw to test a Typescript application.

In the previous article I explained step by step how to use Effect to implement a Custom Newsletter form with ConvertKit.

In this article we are going to test the final implementation:

  • Setup handlers and testing server with msw
  • Install and configure vitest
  • Learn how to mock HTTP requests

Installing and setting up msw

msw (Mock Service Worker) is a library that allows to mock APIs.

pnpm install -D msw

In this example we use msw to intercept and mock the response of HTTP requests. Instead of sending a request to the server, msw allows to return mock data:

  • Test app without interacting with any external service
  • Verify correct behavior for all possible responses

Mocks

The first step is defining mocks for environmental variables and responses. We define all the mocks inside a new mocks.ts file.

With Effect we can mock Config by using ConfigProvider.fromMap. This allows to provide mock values for environmental variables.

We then use Layer.setConfigProvider to build a Layer that we will later provide to each test:

mocks/mocks.ts
import { ConfigProvider, Layer } from "effect";
 
const configProviderMock = ConfigProvider.fromMap(
  new Map([
    ["SUBSCRIBE_API", "http://localhost:3000/subscribe"],
    ["CONVERTKIT_API_URL", "http://localhost:3000"],
    ["CONVERTKIT_API_KEY", ""],
    ["CONVERTKIT_FORM_ID", "123"],
  ])
);
 
export const layerConfigProviderMock =
  Layer.setConfigProvider(configProviderMock);

We also need to create a mock for a SubscribeResponse. This is a simple object used to mock a response:

mocks/mocks.ts
import * as AppSchema from "@/lib/Schema";
import { ConfigProvider, Layer } from "effect";
 
const configProviderMock = ConfigProvider.fromMap(
  new Map([
    ["SUBSCRIBE_API", "http://localhost:3000/subscribe"],
    ["CONVERTKIT_API_URL", "http://localhost:3000"],
    ["CONVERTKIT_API_KEY", ""],
    ["CONVERTKIT_FORM_ID", "123"],
  ])
);
 
export const layerConfigProviderMock =
  Layer.setConfigProvider(configProviderMock);
 
export const subscribeResponseMock: AppSchema.SubscribeResponse = {
  subscription: { id: 0, subscriber: { id: 0 } },
};

Handlers

Our application performs some HTTP requests. While testing we do not want to send real requests, but instead we want to intercept them and return a mocked response.

This is exactly what msw allows us to do using handlers.

We define a list of handlers in a new handlers.ts file.

Each handler is defined using http from msw:

  • http.post: Intercepts a POST request
  • The first parameter is the URL of the request to intercept (e.g. "http://localhost:3000/forms/123/subscribe")
  • The second parameter is used to define a custom mocked response

msw supports plain strings, wildcards (*) and also regex for the URL parameter (Documentation)

mocks/handlers.ts
import { HttpResponse, http } from "msw";
import { subscribeResponseMock } from "./mocks";
 
export const handlers = [
  http.post("http://localhost:3000/forms/123/subscribe", () => {
    return HttpResponse.json(subscribeResponseMock);
  }),
  http.post("http://localhost:3000/subscribe", () => {
    return HttpResponse.json(subscribeResponseMock);
  }),
];

Server setup

The last step with msw is creating a server from handlers.

This step is different based on the environment where we are running our tests:

  • Node: Import setupServer from "msw/node"
  • Browser: Import setupWorker from "msw/browser"
  • React Native: Import setupServer from "msw/native"

In our case we are running the test on a Node environment:

node.ts
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";
 
export const server = setupServer(...handlers);

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.

Installing and setting up vitest

vitest is a testing framework that provides an API to define and organize tests.

It is similar to jest, it provides the same API, with methods like describe, it, etc.

pnpm install -D vitest

Remember to also add "type": "module" in package.json (documentation):

package.json
{
  "name": "convertkit-nextjs-newsletter-form",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "dev": "next dev",
    "test": "vitest",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@effect/platform": "^0.31.1",
    "@effect/schema": "^0.49.3",
    "effect": "2.0.0-next.56",
    "next": "14.0.3",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.9.4",
    "@types/react": "^18.2.38",
    "@types/react-dom": "^18.2.17",
    "eslint": "^8.54.0",
    "eslint-config-next": "14.0.3",
    "msw": "^2.0.8",
    "typescript": "^5.3.2",
    "vite-tsconfig-paths": "^4.2.1",
    "vitest": "^0.34.6"
  }
}

tsconfig paths

In our app we are using custom Typescripts paths defined inside tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2015",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/lib/*": ["./lib/*"],
      "@/app/*": ["./app/*"],
      "@/test/*": ["./test/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

We need to configure vitest to resolve these custom paths. We install vite-tsconfig-paths and add it as a plugin inside vitest.config.ts:

pnpm install -D vite-tsconfig-paths
vitest.config.ts
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  plugins: [tsconfigPaths()],
});
 

Defining and running tests

We are now ready to write some tests.

msw requires to open, reset and close the server we defined above. We can do this inside beforeAll, afterEach and afterAll:

Server.test.ts
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./node";
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

We use describe to organize tests for specific functions and it to implement the actual test:

Server.test.ts
describe("Server.main", () => {
  it("should return a valid SubscribeResponse when request successful", async () => {
    const response = await (
      await Server.main.pipe(
        Effect.provideService(
          Request,
          new globalThis.Request("http://localhost:3000/", {
            body: JSON.stringify({ email: "" }),
            method: "POST",
          })
        ),
        Effect.provide(layerConfigProviderMock),
        Logger.withMinimumLogLevel(LogLevel.Debug),
        Effect.runPromise
      )
    ).json();
 
    expect(response).toStrictEqual(subscribeResponseMock);
  });
});
  • Define a valid Request
  • Use layerConfigProviderMock to provide mocks for environmental variables
  • Verify that the response is equal to the expected mock subscribeResponseMock

In this test msw intercepts the HTTP request and returns subscribeResponseMock. This test passes since we provided all the valid configurations and parameters.

We can then verify also all other possible cases (missing body, wrong request method, invalid formatting, etc.):

Server.test.ts
it("should return an error when the request is missing an email", async () => {
  const response = await (
    await Server.main.pipe(
      Effect.provideService(
        Request,
        new globalThis.Request("http://localhost:3000/", {
          body: JSON.stringify({}),
          method: "POST",
        })
      ),
      Effect.provide(layerConfigProviderMock),
      Effect.runPromise
    )
  ).json();
 
  expect(response).toEqual({
    error: [
      {
        _tag: "Missing",
        message: "Missing key or index",
        path: ["email"],
      },
    ],
  });
});

In this second example the body is missing the required email parameter. We therefore expect the response to be a formatting error.


This is all you need to test your app!

By using vitest + msw we have a powerful testing API at our disposal, as well as a complete API and mocking library.

We are in complete control of all the services and requests. This allows to mock and verify all possible situations and responses.

Indeed testing can be fun and definitely satisfying!

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below 👇

Thanks for reading.

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.