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:
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:
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)
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:
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";
export const server = setupServer(...handlers);
There is more 🤩
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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):
{
"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
:
{
"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
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
:
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:
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.):
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.