Setup Guide: Writing Tests in NextJS with Vitest

Introduction

Vitest is a modern testing framework built on Vite. It boasts incredibly fast execution speed by leveraging worker threads, perfect compatibility with the Jest ecosystem (in terms of syntax and API), and excellent support for TypeScript/JSX without complex configuration.


In this article, I will guide you on writing unit tests, component tests, and integration tests.

  • Unit Test: Checks independent functions or logic to ensure they work correctly with different inputs.
  • Component Test: Checks the user interface and interactions of individual components.
  • Integration Test: Checks the coordination between multiple components or between the client and the API (often mocked) to ensure smooth business logic flow.

Prerequisites

This article is implemented on a NextJS project, please initialize a project before continuing.


Detail

I will continue using content from the Demo AI Agent article to write tests for it, but will need to refactor the code to better support testing as follows:


Create file app/chatbot/type.ts

export interface Message {
id: string;
role: "user" | "ai";
content: string;
steps?: string[];
}

export interface Conversation {
id: string;
name: string;
lastMsg: string;
time: string;
}


Create file app/chatbot/util.ts

import { Message } from "./type";

export const createMessage = (role: "user" | "ai", content: string, steps: string[] = []): Message => ({
id: Date.now().toString(),
role,
content,
steps,
});

export const updateMessages = (prevSteps: Message[], newStep: Message): Message[] => {
return [...prevSteps, newStep];
};


Create file app/chatbot/useChat.ts

import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import { Conversation, Message } from "./type";
import { createMessage, updateMessages } from "./util";

const NEST_BACKEND_URL = process.env.NEXT_PUBLIC_SOCKET_URL;

export function useChat() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [isTyping, setIsTyping] = useState(false);
const [currentStep, setCurrentStep] = useState("");

const tempSteps = useRef<string[]>([]);
const socketRef = useRef<Socket | null>(null);

useEffect(() => {
fetch(`${NEST_BACKEND_URL}/conversations`)
.then(res => res.json())
.then(setConversations);

socketRef.current = io(NEST_BACKEND_URL, {
path: "/socket-io-ai",
transports: ["websocket"],
});

socketRef.current.on("ai_processing", (data: { step: string }) => {
setIsTyping(true);
setCurrentStep(data.step);
tempSteps.current.push(data.step);
});

socketRef.current.on("ai_response", (data: Message) => {
setMessages(prev => [...prev, { ...data, steps: [...tempSteps.current] }]);
setIsTyping(false);
setCurrentStep("");
tempSteps.current = [];
});

return () => {
socketRef.current?.disconnect();
};
}, []);

const sendMessage = (text: string) => {
const userMsg = createMessage("user", text);
const newMsg = updateMessages(messages, userMsg);
setMessages(newMsg);
socketRef.current?.emit("sendMessage", { sender: "User", text });
tempSteps.current = [];
};

return { conversations, messages, isTyping, currentStep, sendMessage };
}

The custom hook useChat manages Socket.io connection logic and API calls. It handles loading the initial conversation list, sending user messages, and listening to real-time AI responses, including processing steps.


Create file app/chatbot/page.tsx

"use client";

import { LoadingOutlined, RobotOutlined, SendOutlined, UserOutlined } from "@ant-design/icons";
import { Avatar, Button, Input, Spin, Tag } from "antd";
import { useEffect, useRef, useState } from "react";
import { useChat } from "./useChat";

export default function ChatPage() {
const { conversations, messages, isTyping, currentStep, sendMessage } = useChat();
const [inputValue, setInputValue] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);

useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages, isTyping]);

const handleSend = () => {
if (!inputValue.trim()) return;
sendMessage(inputValue);
setInputValue("");
};

return (
<div className="flex h-screen bg-slate-50 p-4 gap-4 overflow-hidden font-sans text-slate-900">
<aside className="w-80 bg-white rounded-2xl shadow-sm border border-slate-200 flex flex-col overflow-hidden" data-testid="sidebar">
<div className="p-5 font-bold text-lg border-b border-slate-100 text-blue-600 uppercase">Conversations</div>
<div className="flex-1 overflow-y-auto">
{conversations.map(item => (
<div key={item.id} className="p-4 hover:bg-blue-50 cursor-pointer border-b flex gap-3 items-center" data-testid="conv-item">
<Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${item.id}`} />
<div className="flex-1 truncate">
<div className="font-semibold text-slate-700">{item.name}</div>
<div className="text-xs text-slate-500">{item.lastMsg}</div>
</div>
</div>
))}
</div>
</aside>

<main className="flex-1 bg-white rounded-2xl shadow-md border flex flex-col overflow-hidden">
<header className="p-4 border-b flex justify-between items-center">
<div className="flex items-center gap-3">
<Avatar icon={<RobotOutlined />} className="bg-blue-600" />
<div className="font-bold">AI Agentic Node</div>
</div>
<Tag color="blue">Socket.io Stream</Tag>
</header>

<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 space-y-8 bg-slate-50/50" data-testid="message-list">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`flex max-w-[85%] gap-3 ${msg.role === "user" ? "flex-row-reverse" : "flex-row"}`}>
<Avatar icon={msg.role === "user" ? <UserOutlined /> : <RobotOutlined />} className={msg.role === "user" ? "bg-slate-800" : "bg-blue-600"} />
<div className="flex flex-col gap-2">
{msg.steps?.map((step, i) => (
<div key={i} className="text-[12px] text-slate-500 italic" data-testid="ai-step">
{step}
</div>
))}
<div className={`p-4 rounded-2xl ${msg.role === "user" ? "bg-blue-600 text-white" : "bg-white border text-slate-700"}`}>{msg.content}</div>
</div>
</div>
</div>
))}

{isTyping && (
<div className="flex items-center gap-3 pl-11" data-testid="loading-indicator">
<span className="text-blue-600 italic">{currentStep || "Analyzing..."}</span>
<Spin indicator={<LoadingOutlined spin />} />
</div>
)}
</div>

<footer className="p-5 border-t">
<div className="flex gap-2 p-1.5 border rounded-2xl bg-slate-50">
<Input variant="borderless" placeholder="Ask AI anything..." value={inputValue} onChange={e => setInputValue(e.target.value)} onPressEnter={handleSend} data-testid="chat-input" />
<Button type="primary" shape="circle" icon={<SendOutlined />} onClick={handleSend} disabled={!inputValue.trim() || isTyping} data-testid="send-button" />
</div>
</footer>
</main>
</div>
);
}

The ChatPage is the main interface that displays the conversation list and the chat frame. This component integrates data-testid attributes to serve part identification when running tests and automatically scrolls down when new messages arrive.


Next, please install the following necessary packages:

yarn add -D vitest @vitejs/plugin-react @vitest/ui @vitest/coverage-v8 @testing-library/dom jsdom msw

  • vitest: Main testing framework.
  • @vitejs/plugin-react: Plugin that helps Vitest understand React syntax.
  • @vitest/ui: Intuitive dashboard interface to monitor test results.
  • @vitest/coverage-v8: Use for test coverage.
  • @testing-library/dom & jsdom: Simulates a browser environment in Node.js to test components.
  • msw (Mock Service Worker): A tool to intercept and mock API requests for Integration Testing.


Create file vitest.setup.ts

import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";

Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

vi.mock("socket.io-client", () => {
const mSocket = {
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
};
return { io: vi.fn(() => mSocket) };
});

window.HTMLElement.prototype.scrollTo = vi.fn();

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

This file configures the global test environment, including adding matchers from @testing-library/jest-dom, mocking browser functions that JSDOM does not yet support like matchMedia and scrollTo, and simultaneously mocking the socket.io-client library to avoid real network connection errors when testing.


Create file vitest.config.ts

import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [react()],
test: {
exclude: ["**/node_modules/**", "**/dist/**", "**/e2e/**", "**/*e2e.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
environment: "jsdom",
globals: true,
setupFiles: "./vitest.setup.ts",
},
resolve: {
tsconfigPaths: true,
},
});

  • This configuration file defines how Vitest operates: using the React plugin, setting up the jsdom environment, enabling global variables like describe, it, expect without importing them, and specifying the setup file created in the previous step.
  • Note that we have not done end-to-end testing in this article (I will guide later) so will exclude the file formats related to e2e beforehand.


Next are the test files

Create file test/ChatBot/unit.test.ts

import type { Message } from "@/app/chatbot/type";
import { createMessage, updateMessages } from "@/app/chatbot/util";
import { describe, expect, it } from "vitest";

describe("Chat Utilities Unit Test", () => {
it("should append a new message to the existing messages array", () => {
const currentMessages: Message[] = [{ id: "1", role: "user", content: "content 1" }];
const result = updateMessages(currentMessages, {
id: "2",
role: "user",
content: "content 2",
});
expect(result).toEqual([
{ id: "1", role: "user", content: "content 1" },
{
id: "2",
role: "user",
content: "content 2",
},
]);
expect(result).not.toBe(currentMessages);
});

it("should create a valid message object", () => {
const msg = createMessage("user", "Hello");
expect(msg.content).toBe("Hello");
expect(msg.role).toBe("user");
expect(msg.id).toBeDefined();
});
});

This code performs a Unit Test for utility functions. It checks whether the createMessage function creates the correct data object and whether the updateMessages function adds new messages to the list while still maintaining immutability.


Create file test/ChatBot/component.test.tsx

import ChatPage from "@/app/chatbot/page";
import * as chatHook from "@/app/chatbot/useChat";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

vi.mock("@/app/chatbot/useChat");

describe("ChatPage Component Test", () => {
it("should call sendMessage when user clicks send button", () => {
const mockSendMessage = vi.fn();
(chatHook.useChat as any).mockReturnValue({
conversations: [],
messages: [],
isTyping: false,
sendMessage: mockSendMessage,
});

render(<ChatPage />);

const input = screen.getByTestId("chat-input");
const btn = screen.getByTestId("send-button");

fireEvent.change(input, { target: { value: "Hi AI" } });
fireEvent.click(btn);

expect(mockSendMessage).toHaveBeenCalledWith("Hi AI");
expect(input).toHaveValue("");
});
});

The code performs a Component Test for ChatPage. It mocks the useChat custom hook to control input data and checks whether when the user enters text and presses the send button, the sendMessage function is called with the correct parameters.


Create file test/ChatBot/integration.test.ts

import { useChat } from "@/app/chatbot/useChat";
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer(
http.get(process.env.NEXT_PUBLIC_SOCKET_URL + "/conversations", () => {
return HttpResponse.json([{ id: "1", name: "Integration Bot" }]);
}),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("Chat Integration (Hook + MSW)", () => {
it("should fetch conversations on mount", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.conversations).toHaveLength(1);
expect(result.current.conversations[0].name).toBe("Integration Bot");
});
});
});

This is an Integration Test using MSW to mock a real API server. The test checks whether when the custom hook useChat is initialized, it automatically calls the API to get the conversation list and updates the data state successfully.


Please add the following commands to the package.json file:

{
"scripts": {
"test:vi": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run"
}
}

  • test:vi: Runs Vitest in watch mode (automatically restarts when code changes).
  • test:ui: Opens the Vitest Dashboard interface on the browser for intuitive results.
  • test:run: Runs all tests once (commonly used in CI/CD).


The test results are as follows:

$ yarn test:ui
yarn run v1.22.22
$ vitest --ui

DEV v4.1.5 /project-folder/
UI started at http://localhost:51204/__vitest__/

test/ChatBot/unit.test.ts (2 tests) 3ms
test/ChatBot/integration.test.ts (1 test) 70ms
test/ChatBot/contract.test.ts (1 test) 22ms
test/ChatBot/component.test.tsx (1 test) 106ms
test/counter.test.tsx (4 tests) 136ms

Test Files 5 passed (5)
Tests 9 passed (9)
Start at 18:17:47
Duration 1.79s (transform 126ms, setup 574ms, import 2.16s, tests 337ms, environment 2.48s)


Dashboard as follows:


Test coverage look like this

$ yarn test:vi-c
yarn run v1.22.22
$ vitest --coverage

DEV v4.1.5 /project-folder/
Coverage enabled with v8

test/ChatBot/unit.test.ts (2 tests) 6ms
test/ChatBot/integration.test.ts (1 test) 76ms
test/ChatBot/contract.test.ts (1 test) 24ms
test/ChatBot/component.test.tsx (1 test) 119ms
test/counter.test.tsx (4 tests) 159ms

Test Files 5 passed (5)
Tests 9 passed (9)
Start at 19:43:14
Duration 1.72s (transform 125ms, setup 597ms, import 2.24s, tests 384ms, environment 1.44s)

% Coverage report from v8
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 70.17 | 26.31 | 66.66 | 72.72 |
counter | 100 | 100 | 100 | 100 |
page.tsx | 100 | 100 | 100 | 100 |
feature/chatbot | 66 | 26.31 | 58.82 | 68.75 |
page.tsx | 75 | 22.22 | 57.14 | 80 | 44-94
useChat.ts | 56.66 | 100 | 50 | 58.62 | 30-32,36-39,48-52
util.ts | 100 | 100 | 100 | 100 |
-----------------|---------|----------|---------|---------|-------------------
PASS Waiting for file changes...
press h to show help, press q to quit



Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

Sitemap

DevOps Practice Series