diff --git a/package-lock.json b/package-lock.json
index 5c6bcfd..3f658e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@rolldown/plugin-babel": "^0.2.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
@@ -1171,6 +1172,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
diff --git a/package.json b/package.json
index 1e069d2..0cbd761 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.103.0",
@@ -20,6 +21,7 @@
"@rolldown/plugin-babel": "^0.2.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
diff --git a/src/components/CharacterCardSmall.test.tsx b/src/components/CharacterCardSmall.test.tsx
new file mode 100644
index 0000000..b75859f
--- /dev/null
+++ b/src/components/CharacterCardSmall.test.tsx
@@ -0,0 +1,72 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import CharacterCardSmall from "./CharacterCardSmall";
+import "@testing-library/jest-dom/vitest";
+import userEvent from "@testing-library/user-event";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("CharacterCardSmall", () => {
+ it("displays the character name", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Rick Sanchez")).toBeInTheDocument();
+ });
+
+ it("displays the character image", () => {
+ render(
+ ,
+ );
+
+ const image = screen.getByAltText("character image");
+ expect(image).toHaveAttribute("src", "rick.jpg");
+ });
+
+ it("calls onDetails when Details is clicked", async () => {
+ const user = userEvent.setup();
+ const onDetails = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Details" }));
+ expect(onDetails).toHaveBeenCalledOnce();
+ });
+
+ it("matches snapshot", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/CharacterDetailCard.test.tsx b/src/components/CharacterDetailCard.test.tsx
new file mode 100644
index 0000000..9e609ef
--- /dev/null
+++ b/src/components/CharacterDetailCard.test.tsx
@@ -0,0 +1,212 @@
+import React from "react";
+import { cleanup, render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+
+import CharacterDetailCard from "./CharacterDetailCard";
+import type { Character } from "../types/Character";
+
+const mockCharacter: Character = {
+ id: 1,
+ name: "Rick Sanchez",
+ species: "Human",
+ status: "Alive",
+ gender: "Male",
+ origin: {
+ name: "Earth (C-137)",
+ url: "https://rickandmortyapi.com/api/location/1",
+ },
+ image: "rick.jpg",
+ episode: [
+ "https://rickandmortyapi.com/api/episode/1",
+ "https://rickandmortyapi.com/api/episode/2",
+ ],
+};
+
+function renderWithQueryClient(ui: React.ReactNode) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ return render(
+ {ui},
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("CharacterDetailCard", () => {
+ it("displays the character name", () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Rick Sanchez",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("displays the character image", () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ const image = screen.getByAltText("Rick Sanchez");
+ expect(image).toHaveAttribute("src", "rick.jpg");
+ });
+
+ it("displays the character speice", () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.getByText("Human")).toBeInTheDocument();
+ });
+
+ it("displays the character status", () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.getByText(/Alive/)).toBeInTheDocument();
+ });
+
+ it("displays the character's origin", () => {
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(screen.getByText("Earth (C-137)")).toBeInTheDocument();
+ });
+
+ it("displays the character episodes", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [
+ {
+ id: 1,
+ name: "Pilot",
+ episode: "S01E01",
+ },
+ {
+ id: 2,
+ name: "Lawnmower Dog",
+ episode: "S01E02",
+ },
+ ],
+ }),
+ );
+
+ renderWithQueryClient(
+ ,
+ );
+
+ expect(await screen.findByText("S01E01 - Pilot")).toBeInTheDocument();
+
+ expect(
+ await screen.findByText("S01E02 - Lawnmower Dog"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onNext when next character button is clicked", async () => {
+ const user = userEvent.setup();
+ const onNext = vi.fn();
+
+ renderWithQueryClient(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Next character",
+ }),
+ );
+
+ expect(onNext).toHaveBeenCalledOnce();
+ });
+
+ it("calls onPrevious when previous character button is clicked", async () => {
+ const user = userEvent.setup();
+ const onPrevious = vi.fn();
+
+ renderWithQueryClient(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Previous character",
+ }),
+ );
+
+ expect(onPrevious).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/components/Searchbar.test.tsx b/src/components/Searchbar.test.tsx
new file mode 100644
index 0000000..e5be8e1
--- /dev/null
+++ b/src/components/Searchbar.test.tsx
@@ -0,0 +1,39 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import Searchbar from "./Searchbar";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("Searchbar", () => {
+ it("Searches based on a searchQuery", () => {
+ const onSearchChange = vi.fn();
+ render();
+ const input = screen.getByRole("searchbox");
+
+ expect(input).toHaveValue("Rick");
+ });
+ it("Searches based on a searchQuery with capital letters", () => {
+ const onSearchChange = vi.fn();
+ render();
+ const input = screen.getByRole("searchbox");
+
+ expect(input).toHaveValue("rIcK");
+ });
+
+ it("calls onSearchChange when user types", async () => {
+ const user = userEvent.setup();
+ const onSearchChange = vi.fn();
+
+ render();
+
+ const input = screen.getByRole("searchbox");
+
+ await user.type(input, "Rick");
+
+ expect(onSearchChange).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/__snapshots__/CharacterCardSmall.test.tsx.snap b/src/components/__snapshots__/CharacterCardSmall.test.tsx.snap
new file mode 100644
index 0000000..a9a10af
--- /dev/null
+++ b/src/components/__snapshots__/CharacterCardSmall.test.tsx.snap
@@ -0,0 +1,54 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CharacterCardSmall > matches snapshot 1`] = `
+
+
+
+
'%3e%3cpath%20d='M0%2020C0%208.9543%208.95431%200%2020%200H97.85H175.7C186.746%200%20195.7%208.95431%20195.7%2020V134.068V225.85C195.7%20234.89%20188.372%20242.218%20179.332%20242.218H178.965C170.128%20242.218%20162.963%20249.382%20162.963%20258.22C162.963%20267.057%20155.799%20274.221%20146.962%20274.221H97.85H20C8.95429%20274.221%200%20265.267%200%20254.221V134.068V49.0929V20Z'%20fill='%2382B70C'/%3e%3c/g%3e%3cdefs%3e%3cfilter%20id='filter0_n_93_129'%20x='0'%20y='0'%20width='195.7'%20height='274.221'%20filterUnits='userSpaceOnUse'%20color-interpolation-filters='sRGB'%3e%3cfeFlood%20flood-opacity='0'%20result='BackgroundImageFix'/%3e%3cfeBlend%20mode='normal'%20in='SourceGraphic'%20in2='BackgroundImageFix'%20result='shape'/%3e%3cfeTurbulence%20type='fractalNoise'%20baseFrequency='2%202'%20stitchTiles='stitch'%20numOctaves='3'%20result='noise'%20seed='900'%20/%3e%3cfeColorMatrix%20in='noise'%20type='luminanceToAlpha'%20result='alphaNoise'%20/%3e%3cfeComponentTransfer%20in='alphaNoise'%20result='coloredNoise1'%3e%3cfeFuncA%20type='discrete'%20tableValues='0%200%200%200%200%200%200%200%200%200%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%201%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%20'/%3e%3c/feComponentTransfer%3e%3cfeComposite%20operator='in'%20in2='shape'%20in='coloredNoise1'%20result='noise1Clipped'%20/%3e%3cfeFlood%20flood-color='rgba(0,%200,%200,%200.25)'%20result='color1Flood'%20/%3e%3cfeComposite%20operator='in'%20in2='noise1Clipped'%20in='color1Flood'%20result='color1'%20/%3e%3cfeMerge%20result='effect1_noise_93_129'%3e%3cfeMergeNode%20in='shape'%20/%3e%3cfeMergeNode%20in='color1'%20/%3e%3c/feMerge%3e%3c/filter%3e%3c/defs%3e%3c/svg%3e)
+
+

+
+ Rick Sanchez
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/pages/CharactersPage.test.tsx b/src/pages/CharactersPage.test.tsx
new file mode 100644
index 0000000..e17a84c
--- /dev/null
+++ b/src/pages/CharactersPage.test.tsx
@@ -0,0 +1,299 @@
+import React from "react";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom/vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import CharactersPage from "./CharactersPage";
+
+// AI was used to create this test file
+const mockCharacters = [
+ {
+ id: 1,
+ name: "Rick Sanchez",
+ species: "Human",
+ status: "Alive",
+ gender: "Male",
+ origin: {
+ name: "Earth (C-137)",
+ url: "",
+ },
+ image: "rick.jpg",
+ episode: ["https://rickandmortyapi.com/api/episode/1"],
+ },
+ {
+ id: 2,
+ name: "Summer Smith",
+ species: "Human",
+ status: "Alive",
+ gender: "Female",
+ origin: {
+ name: "Earth",
+ url: "",
+ },
+ image: "summer.jpg",
+ episode: ["https://rickandmortyapi.com/api/episode/2"],
+ },
+];
+
+function renderWithQueryClient(ui: React.ReactNode) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ return render(
+ {ui},
+ );
+}
+
+function mockFetch() {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/character")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ results: mockCharacters,
+ }),
+ });
+ }
+
+ if (url.includes("/episode")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ id: 1,
+ name: "Pilot",
+ episode: "S01E01",
+ }),
+ });
+ }
+
+ return Promise.reject(new Error("Unknown URL"));
+ }),
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+
+ localStorage.clear();
+ sessionStorage.clear();
+});
+
+describe("CharactersPage", () => {
+ it("displays fetched characters", async () => {
+ mockFetch();
+
+ renderWithQueryClient();
+
+ expect(await screen.findByText("Rick Sanchez")).toBeInTheDocument();
+
+ expect(screen.getByText("Summer Smith")).toBeInTheDocument();
+ });
+
+ it("opens character details", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ const detailsButtons = screen.getAllByRole("button", {
+ name: "Details",
+ });
+
+ await user.click(detailsButtons[0]);
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Rick Sanchez",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("closes character details", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ const detailsButtons = screen.getAllByRole("button", {
+ name: "Details",
+ });
+
+ await user.click(detailsButtons[0]);
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Close character details",
+ }),
+ );
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("shows the next character", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ await user.click(
+ screen.getAllByRole("button", {
+ name: "Details",
+ })[0],
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Rick Sanchez",
+ }),
+ ).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Next character",
+ }),
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Summer Smith",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the previous character", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ await user.click(
+ screen.getAllByRole("button", {
+ name: "Details",
+ })[1],
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Summer Smith",
+ }),
+ ).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Previous character",
+ }),
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Rick Sanchez",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("filters characters based on search", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ const searchInput = screen.getByRole("searchbox");
+
+ await user.type(searchInput, "Rick");
+
+ expect(screen.getByText("Rick Sanchez")).toBeInTheDocument();
+
+ expect(screen.queryByText("Summer Smith")).not.toBeInTheDocument();
+ });
+
+ it("filters characters by gender", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ await user.click(
+ screen.getByRole("checkbox", {
+ name: "Female",
+ }),
+ );
+
+ expect(screen.getByText("Summer Smith")).toBeInTheDocument();
+
+ expect(screen.queryByText("Rick Sanchez")).not.toBeInTheDocument();
+ });
+
+ it("adds a character to favorites", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ const favoriteButton = screen.getByRole("button", {
+ name: "Add Rick Sanchez to favorites",
+ });
+
+ await user.click(favoriteButton);
+
+ expect(
+ screen.getByRole("button", {
+ name: "Remove Rick Sanchez from favorites",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("stores favorites in localStorage", async () => {
+ const user = userEvent.setup();
+
+ mockFetch();
+
+ renderWithQueryClient();
+
+ await screen.findByText("Rick Sanchez");
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Add Rick Sanchez to favorites",
+ }),
+ );
+
+ expect(
+ JSON.parse(localStorage.getItem("favoriteCharacterIds") ?? "[]"),
+ ).toContain(1);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..9f6250a
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ },
+});