Skip to content

Commit 5cb90d1

Browse files
authored
Merge pull request #61 from ekanshgupta2046/unit-testing-for-components
feat: added unit testing for frontend components using vitest
2 parents 489c875 + e694198 commit 5cb90d1

File tree

11 files changed

+1624
-43
lines changed

11 files changed

+1624
-43
lines changed

frontend/package-lock.json

Lines changed: 1187 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "vite",
88
"build": "tsc -b && vite build",
99
"lint": "eslint .",
10-
"preview": "vite preview"
10+
"preview": "vite preview",
11+
"test": "vitest"
1112
},
1213
"dependencies": {
1314
"@hookform/resolvers": "^3.10.0",
@@ -67,6 +68,8 @@
6768
"devDependencies": {
6869
"@eslint/js": "^9.36.0",
6970
"@tailwindcss/postcss": "^4.1.14",
71+
"@testing-library/jest-dom": "^6.9.1",
72+
"@testing-library/react": "^16.3.0",
7073
"@types/node": "^24.6.0",
7174
"@types/react": "^19.1.16",
7275
"@types/react-dom": "^19.1.9",
@@ -76,9 +79,11 @@
7679
"eslint-plugin-react-hooks": "^5.2.0",
7780
"eslint-plugin-react-refresh": "^0.4.22",
7881
"globals": "^16.4.0",
82+
"jsdom": "^27.0.1",
7983
"typescript": "~5.9.3",
8084
"typescript-eslint": "^8.45.0",
81-
"vite": "npm:[email protected]"
85+
"vite": "npm:[email protected]",
86+
"vitest": "^4.0.4"
8287
},
8388
"overrides": {
8489
"vite": "npm:[email protected]"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import { MemoryRouter } from "react-router-dom";
4+
import Browse from "../pages/Browse";
5+
6+
describe("Browse Component", () => {
7+
beforeEach(() => {
8+
vi.spyOn(window, "alert").mockImplementation(() => {}); // prevent alert popups
9+
});
10+
11+
afterEach(() => {
12+
vi.restoreAllMocks();
13+
});
14+
15+
it("renders the heading and product cards", () => {
16+
render(
17+
<MemoryRouter>
18+
<Browse />
19+
</MemoryRouter>
20+
);
21+
22+
// Header
23+
expect(screen.getByText(/Browse Products/i)).toBeInTheDocument();
24+
expect(
25+
screen.getByText(/Find what you need from fellow students/i)
26+
).toBeInTheDocument();
27+
28+
// Product cards visible
29+
expect(screen.getByText("Desk Lamp")).toBeInTheDocument();
30+
expect(screen.getByText("C++ Programming Book")).toBeInTheDocument();
31+
});
32+
33+
it("filters products by search query", () => {
34+
render(
35+
<MemoryRouter>
36+
<Browse />
37+
</MemoryRouter>
38+
);
39+
40+
const searchInput = screen.getByPlaceholderText(
41+
/Search for textbooks, electronics, notes/i
42+
);
43+
44+
// Type "lamp" in search bar
45+
fireEvent.change(searchInput, { target: { value: "lamp" } });
46+
47+
// Only Desk Lamp should show
48+
expect(screen.getByText("Desk Lamp")).toBeInTheDocument();
49+
expect(screen.queryByText("Laptop Stand")).not.toBeInTheDocument();
50+
});
51+
52+
it("opens and applies filter drawer", () => {
53+
render(
54+
<MemoryRouter>
55+
<Browse />
56+
</MemoryRouter>
57+
);
58+
59+
// Click Filter button
60+
const filterButton = screen.getByRole("button", { name: /Filter/i });
61+
fireEvent.click(filterButton);
62+
63+
// Select Electronics category
64+
const categorySelect = screen.getByLabelText(/Category/i);
65+
fireEvent.change(categorySelect, { target: { value: "electronics" } });
66+
67+
// Apply filters
68+
const applyBtn = screen.getByRole("button", { name: /Apply Filters/i });
69+
fireEvent.click(applyBtn);
70+
71+
expect(window.alert).toHaveBeenCalledWith(
72+
expect.stringContaining("Category: electronics")
73+
);
74+
});
75+
76+
it("clears filters when clicking 'Clear'", () => {
77+
render(
78+
<MemoryRouter>
79+
<Browse />
80+
</MemoryRouter>
81+
);
82+
83+
const filterButton = screen.getByRole("button", { name: /Filter/i });
84+
fireEvent.click(filterButton);
85+
86+
// Change category to "notes"
87+
const categorySelect = screen.getByLabelText(/Category/i);
88+
fireEvent.change(categorySelect, { target: { value: "notes" } });
89+
90+
// Click Clear
91+
const clearButton = screen.getByRole("button", { name: /Clear/i });
92+
fireEvent.click(clearButton);
93+
94+
expect(categorySelect).toHaveValue("");
95+
});
96+
97+
it("triggers alert when 'Add to Cart' is clicked", () => {
98+
render(
99+
<MemoryRouter>
100+
<Browse />
101+
</MemoryRouter>
102+
);
103+
104+
const addToCartButton = screen.getAllByRole("button", {
105+
name: /Add to Cart/i,
106+
})[0];
107+
108+
fireEvent.click(addToCartButton);
109+
110+
expect(window.alert).toHaveBeenCalledWith(
111+
expect.stringContaining("added to cart")
112+
);
113+
});
114+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import { BrowserRouter } from "react-router-dom";
3+
import Navbar from "@/components/Navbar";
4+
5+
// Helper to render with Router context
6+
const renderNavbar = () => render(
7+
<BrowserRouter>
8+
<Navbar />
9+
</BrowserRouter>
10+
);
11+
12+
describe("Navbar Component", () => {
13+
14+
beforeEach(() => {
15+
localStorage.clear();
16+
document.documentElement.classList.remove("dark");
17+
});
18+
19+
it("renders brand name and nav links", () => {
20+
renderNavbar();
21+
expect(screen.getByText("UniLoot")).toBeInTheDocument();
22+
expect(screen.getByText("Home")).toBeInTheDocument();
23+
expect(screen.getByText("Browse")).toBeInTheDocument();
24+
expect(screen.getByText("Sell")).toBeInTheDocument();
25+
});
26+
27+
it("renders Sign In and Sign Up buttons", () => {
28+
renderNavbar();
29+
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
30+
expect(screen.getByRole("button", { name: /sign up/i })).toBeInTheDocument();
31+
});
32+
33+
it("toggles mobile menu visibility", () => {
34+
renderNavbar();
35+
const menuButton = screen.getByRole("button", { name: "" }); // the menu (hamburger) icon button
36+
fireEvent.click(menuButton);
37+
expect(screen.getByText("Home")).toBeVisible();
38+
fireEvent.click(menuButton);
39+
expect(screen.queryByText("Home")).toBeInTheDocument(); // still in DOM but hidden
40+
});
41+
42+
it("toggles theme between light and dark", () => {
43+
renderNavbar();
44+
const themeButton = screen.getAllByRole("button").find(btn => btn.innerHTML.includes("svg"));
45+
expect(document.documentElement.classList.contains("dark")).toBe(false);
46+
fireEvent.click(themeButton!);
47+
expect(document.documentElement.classList.contains("dark")).toBe(true);
48+
fireEvent.click(themeButton!);
49+
expect(document.documentElement.classList.contains("dark")).toBe(false);
50+
});
51+
52+
it("stores theme preference in localStorage", () => {
53+
renderNavbar();
54+
const themeButton = screen.getAllByRole("button").find(btn => btn.innerHTML.includes("svg"));
55+
fireEvent.click(themeButton!);
56+
expect(localStorage.getItem("theme")).toBe("dark");
57+
});
58+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { vi } from "vitest";
3+
import SignIn from "../pages/Signin";
4+
import { BrowserRouter } from "react-router-dom";
5+
import { mockLogin } from "../lib/api";
6+
7+
// Mock toast
8+
vi.mock("../hooks/use-toast", () => ({
9+
toast: vi.fn(),
10+
}));
11+
12+
// Mock the API
13+
vi.mock("../lib/api", async () => {
14+
const actual = await vi.importActual("../lib/api");
15+
return {
16+
...actual,
17+
mockLogin: vi.fn(),
18+
};
19+
});
20+
21+
describe("SignIn Component", () => {
22+
const renderWithRouter = () =>
23+
render(
24+
<BrowserRouter>
25+
<SignIn />
26+
</BrowserRouter>
27+
);
28+
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
});
32+
33+
test("renders all form fields", () => {
34+
renderWithRouter();
35+
36+
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
37+
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
38+
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
39+
});
40+
41+
test("shows validation errors for empty submission", async () => {
42+
renderWithRouter();
43+
44+
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
45+
46+
await waitFor(() => {
47+
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
48+
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
49+
});
50+
});
51+
52+
test("shows error for invalid email", async () => {
53+
renderWithRouter();
54+
55+
fireEvent.change(screen.getByLabelText(/email/i), {
56+
target: { value: "invalidemail" },
57+
});
58+
fireEvent.change(screen.getByLabelText(/password/i), {
59+
target: { value: "password123" },
60+
});
61+
62+
fireEvent.input(screen.getByLabelText(/email/i), {
63+
target: { value: "invalid-email" },
64+
});
65+
fireEvent.input(screen.getByLabelText(/password/i), {
66+
target: { value: "password123" },
67+
});
68+
fireEvent.submit(screen.getByRole("button", { name: /sign in/i }));
69+
70+
// wait for react-hook-form to show validation message
71+
await screen.findByText(/invalid email address/i);
72+
73+
});
74+
75+
test("submits form successfully", async () => {
76+
(mockLogin as vi.Mock).mockResolvedValueOnce({
77+
success: true,
78+
message: "Login successful!",
79+
user: { email: "[email protected]", id: "user-1" },
80+
});
81+
82+
renderWithRouter();
83+
84+
fireEvent.change(screen.getByLabelText(/email/i), {
85+
target: { value: "[email protected]" },
86+
});
87+
fireEvent.change(screen.getByLabelText(/password/i), {
88+
target: { value: "password123" },
89+
});
90+
91+
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
92+
93+
await waitFor(() => {
94+
expect(mockLogin).toHaveBeenCalledWith({
95+
96+
password: "password123",
97+
});
98+
});
99+
});
100+
101+
test("handles API error gracefully", async () => {
102+
(mockLogin as vi.Mock).mockRejectedValueOnce({
103+
success: false,
104+
message: "Invalid credentials",
105+
});
106+
107+
renderWithRouter();
108+
109+
fireEvent.change(screen.getByLabelText(/email/i), {
110+
target: { value: "[email protected]" },
111+
});
112+
fireEvent.change(screen.getByLabelText(/password/i), {
113+
target: { value: "wrongpass" },
114+
});
115+
116+
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
117+
118+
await waitFor(() => {
119+
expect(mockLogin).toHaveBeenCalled();
120+
});
121+
});
122+
});

0 commit comments

Comments
 (0)