diff --git a/package.json b/package.json
index dd4b08a..fae6155 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,14 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --turbopack",
"build": "next build --turbopack",
- "start": "next start",
+ "dev": "next dev --turbopack",
"lint": "eslint",
"lint:fix": "eslint . --fix",
- "prepare": "husky"
+ "prepare": "husky",
+ "start": "next start",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
@@ -27,17 +29,23 @@
"@antfu/eslint-config": "^5.2.2",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.8.0",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.35.0",
"eslint-config-next": "15.5.2",
"eslint-plugin-format": "^1.0.1",
"husky": "^9.1.7",
+ "jsdom": "^26.1.0",
"lint-staged": "^16.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^3.2.4"
},
"lint-staged": {
"*": "pnpm lint"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8298b61..7132d61 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -51,6 +51,15 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.13
+ '@testing-library/jest-dom':
+ specifier: ^6.8.0
+ version: 6.8.0
+ '@testing-library/react':
+ specifier: ^16.3.0
+ version: 16.3.0
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1
'@types/node':
specifier: ^20
version: 20.19.13
@@ -60,6 +69,9 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.1.9(@types/react@19.1.12)
+ '@vitest/coverage-v8':
+ specifier: ^3.2.4
+ version: 3.2.4
eslint:
specifier: ^9.35.0
version: 9.35.0(jiti@2.5.1)
@@ -72,6 +84,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
+ jsdom:
+ specifier: ^26.1.0
+ version: 26.1.0
lint-staged:
specifier: ^16.1.6
version: 16.1.6
@@ -84,6 +99,9 @@ importers:
typescript:
specifier: ^5
version: 5.9.2
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.4
packages:
diff --git a/tests/api/pictures-route.test.ts b/tests/api/pictures-route.test.ts
new file mode 100644
index 0000000..09bdb5a
--- /dev/null
+++ b/tests/api/pictures-route.test.ts
@@ -0,0 +1,41 @@
+// @vitest-environment node
+import { NextRequest } from "next/server";
+import { describe, expect, it } from "vitest";
+
+import { GET } from "../../app/api/pictures/route";
+import { MOCK_PICTURES } from "../../lib/mock-pics";
+
+describe("get /api/pictures", () => {
+ it("returns the first page when cursor is omitted", async () => {
+ const request = new NextRequest("http://localhost/api/pictures");
+
+ const response = await GET(request);
+ const body = await response.json();
+
+ expect(body.pictures).toHaveLength(9);
+ expect(body.pictures[0]).toEqual(MOCK_PICTURES[0]);
+ expect(body.nextCursor).toBe(9);
+ });
+
+ it("returns the first page with a next cursor", async () => {
+ const request = new NextRequest("http://localhost/api/pictures?cursor=0");
+
+ const response = await GET(request);
+ const body = await response.json();
+
+ expect(body.pictures).toHaveLength(9);
+ expect(body.pictures[0]).toEqual(MOCK_PICTURES[0]);
+ expect(body.nextCursor).toBe(9);
+ });
+
+ it("returns a final page without a next cursor", async () => {
+ const lastCursor = Math.max(MOCK_PICTURES.length - 5, 0);
+ const request = new NextRequest(`http://localhost/api/pictures?cursor=${lastCursor}`);
+
+ const response = await GET(request);
+ const body = await response.json();
+
+ expect(body.pictures).toHaveLength(MOCK_PICTURES.length - lastCursor);
+ expect(body.nextCursor).toBeNull();
+ });
+});
diff --git a/tests/components/shared/modal-pic.test.tsx b/tests/components/shared/modal-pic.test.tsx
new file mode 100644
index 0000000..2ee55ea
--- /dev/null
+++ b/tests/components/shared/modal-pic.test.tsx
@@ -0,0 +1,37 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import ModalPic from "../../../components/shared/modal-pic";
+
+describe("modal pic", () => {
+ const picture = {
+ id: "pic-1",
+ url: "https://picsum.photos/id/1/800/1200",
+ alt: "Sample picture",
+ };
+
+ it("renders the picture and text content", () => {
+ render();
+
+ expect(screen.getByRole("img", { name: /sample picture/i })).toBeInTheDocument();
+ expect(screen.getByText(/lorem ipsum/i)).toBeInTheDocument();
+ });
+
+ it("invokes onClose when escape is pressed", () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("invokes onClose when the overlay is clicked", () => {
+ const onClose = vi.fn();
+ const { container } = render();
+
+ fireEvent.click(container.firstChild as HTMLElement);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/setup.tsx b/tests/setup.tsx
new file mode 100644
index 0000000..6e2a3d8
--- /dev/null
+++ b/tests/setup.tsx
@@ -0,0 +1,11 @@
+import "@testing-library/jest-dom/vitest";
+import React from "react";
+import { vi } from "vitest";
+
+vi.mock("next/image", () => ({
+ __esModule: true,
+ default: (props: React.ComponentProps<"img">) => {
+ const { src, alt, ...rest } = props;
+ return
;
+ },
+}));
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..295b953
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,17 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./tests/setup.tsx"],
+ coverage: {
+ reporter: ["text", "json", "html"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "."),
+ },
+ },
+});