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 {alt}; + }, +})); 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, "."), + }, + }, +});