From 9074fe9fa138dee72b1af1ddf527491a73181a71 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:23:15 -0300 Subject: [PATCH] feat: add backend and frontend test coverage with main-branch CI - add pytest setup and backend tests for analyzer helpers and API routes - add vitest setup and frontend tests for page fetch, clipboard copy, and CSV export - add GitHub Actions workflow for backend tests, frontend tests, and frontend build on main - update README with local test commands for backend and frontend --- .github/workflows/ci.yml | 69 ++++++++++ README.md | 22 ++++ backend/pyproject.toml | 10 ++ backend/tests/test_analyzer.py | 25 ++++ backend/tests/test_routes.py | 124 ++++++++++++++++++ frontend/app/page.test.tsx | 69 ++++++++++ .../analyzer/results-section.test.tsx | 63 +++++++++ frontend/package.json | 11 +- frontend/test/setup.ts | 37 ++++++ frontend/vitest.config.ts | 15 +++ 10 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/tests/test_analyzer.py create mode 100644 backend/tests/test_routes.py create mode 100644 frontend/app/page.test.tsx create mode 100644 frontend/components/analyzer/results-section.test.tsx create mode 100644 frontend/test/setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1ed0f49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend Tests + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Install backend dependencies + run: uv sync --group dev + + - name: Run backend tests + run: uv run pytest + + frontend: + name: Frontend Tests + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm install + + - name: Run frontend tests + run: npm run test + + - name: Run production build + run: npm run build diff --git a/README.md b/README.md index 4fbd77f..ba2b89d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,28 @@ The backend and frontend need to run at the same time. --- +## Running Tests + +Backend: + +```bash +cd backend +uv sync --group dev +uv run pytest +``` + +Frontend: + +```bash +cd frontend +npm install +npm run test +``` + +GitHub Actions runs these checks automatically on pushes to `main` and on pull requests that target `main`. + +--- + ## Why Developers Use This - Discover high-signal posts faster diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5ba829f..677e006 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,3 +14,13 @@ dependencies = [ "requests>=2.34.2", "uvicorn>=0.48.0", ] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=8.4.1", +] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/backend/tests/test_analyzer.py b/backend/tests/test_analyzer.py new file mode 100644 index 0000000..e07ebf6 --- /dev/null +++ b/backend/tests/test_analyzer.py @@ -0,0 +1,25 @@ +from src.services.analyzer import PostAnalyzer + + +def test_normalize_tech_stack_deduplicates_and_cleans_tokens() -> None: + raw = [" FastAPI ", "Python", "python", " ", "- Docker "] + + result = PostAnalyzer._normalize_tech_stack(raw) + + assert result == "FastAPI, Python, Docker" + + +def test_normalize_tech_stack_returns_na_for_empty_values() -> None: + assert PostAnalyzer._normalize_tech_stack(None) == "N/A" + assert PostAnalyzer._normalize_tech_stack("null") == "N/A" + + +def test_extract_github_url_from_text_returns_first_repo_url() -> None: + text = ( + "See https://example.com first and then " + "https://github.com/openai/openai-python/issues/1)." + ) + + result = PostAnalyzer._extract_github_url_from_text(text) + + assert result == "https://github.com/openai/openai-python/issues/1" diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py new file mode 100644 index 0000000..5e143be --- /dev/null +++ b/backend/tests/test_routes.py @@ -0,0 +1,124 @@ +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient + +from src.main import app +from src.api import routes + + +@pytest.fixture +def client() -> Generator[TestClient, None, None]: + with TestClient(app) as test_client: + yield test_client + + +def test_read_root_returns_health_message(client: TestClient) -> None: + response = client.get("/") + + assert response.status_code == 200 + assert response.json() == { + "message": "Dev.to Analyzer API is running. Go to /gradio for the UI." + } + + +def test_analyze_endpoint_returns_empty_list_when_scanner_finds_no_posts( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + class ScannerStub: + def __init__(self, tag: str) -> None: + self.tag = tag + + def fetch_posts(self, pages: int, progress): # noqa: ANN001 + assert self.tag == "python" + assert pages == 2 + return [] + + class AnalyzerStub: + def analyze_batch(self, posts): # noqa: ANN001 + raise AssertionError("Analyzer should not be called when there are no posts") + + async def fake_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(routes, "DevToScanner", ScannerStub) + monkeypatch.setattr(routes, "PostAnalyzer", AnalyzerStub) + monkeypatch.setattr(routes.asyncio, "sleep", fake_sleep) + + response = client.get("/api/analyze", params={"tag": "python", "pages": 2}) + + assert response.status_code == 200 + assert response.json() == [] + + +def test_analyze_endpoint_transforms_scanner_and_analyzer_output( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + raw_posts = [ + { + "id": 101, + "title": "Async API Patterns", + "published_at": "2026-07-08T12:00:00Z", + "public_reactions_count": 12, + "comments_count": 3, + "url": "https://dev.to/example/async-api-patterns", + "user": { + "name": "Ada Lovelace", + "profile_image_90": "https://images.dev/avatar.png", + }, + } + ] + + class ScannerStub: + def __init__(self, tag: str) -> None: + self.tag = tag + + def fetch_posts(self, pages: int, progress): # noqa: ANN001 + assert self.tag == "fastapi" + assert pages == 1 + progress(1.0, desc="stub") + return [post.copy() for post in raw_posts] + + def get_post_details(self, post_id: int) -> str: + assert post_id == 101 + return "Body markdown with https://github.com/acme/project" + + class AnalyzerStub: + def analyze_batch(self, posts): # noqa: ANN001 + assert posts[0]["body_markdown"] == "Body markdown with https://github.com/acme/project" + return [ + { + "id": 101, + "tech_stack": "FastAPI, Python", + "pain_point": "Blocking requests slowed the API.", + "solution": "Move IO to async endpoints.", + "github_url": "https://github.com/acme/project", + } + ] + + async def fake_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr(routes, "DevToScanner", ScannerStub) + monkeypatch.setattr(routes, "PostAnalyzer", AnalyzerStub) + monkeypatch.setattr(routes.asyncio, "sleep", fake_sleep) + + response = client.get("/api/analyze", params={"tag": "fastapi", "pages": 1}) + + assert response.status_code == 200 + assert response.json() == [ + { + "id": 101, + "author": "Ada Lovelace", + "avatar": "https://images.dev/avatar.png", + "date": "2026-07-08", + "topic": "Async API Patterns", + "tech_stack": "FastAPI, Python", + "problem": "Blocking requests slowed the API.", + "solution": "Move IO to async endpoints.", + "github_url": "https://github.com/acme/project", + "reacts": 12, + "comments": 3, + "url": "https://dev.to/example/async-api-patterns", + } + ] diff --git a/frontend/app/page.test.tsx b/frontend/app/page.test.tsx new file mode 100644 index 0000000..6b5944a --- /dev/null +++ b/frontend/app/page.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import DevToAnalyzer from "./page" + + +const apiResult = [ + { + id: 1, + author: "Grace Hopper", + avatar: "https://images.dev/grace.png", + date: "2026-07-08", + topic: "Ship Better Python APIs", + tech_stack: "FastAPI, Python", + problem: "Slow sync handlers.", + solution: "Adopt async IO.", + github_url: "https://github.com/acme/api", + reacts: 14, + comments: 2, + url: "https://dev.to/example/post", + }, +] + +describe("DevToAnalyzer page", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("fetches analysis results with the selected filters", async () => { + const user = userEvent.setup() + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => apiResult, + }) + vi.stubGlobal("fetch", fetchMock) + + render() + + await user.clear(screen.getByPlaceholderText("e.g. webdev, python, react")) + await user.type(screen.getByPlaceholderText("e.g. webdev, python, react"), "fastapi") + await user.clear(screen.getByRole("spinbutton")) + await user.type(screen.getByRole("spinbutton"), "4") + await user.click(screen.getByRole("button", { name: "Analyze Posts" })) + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:8000/api/analyze?tag=fastapi&pages=4" + ) + ) + expect((await screen.findAllByText("Grace Hopper")).length).toBeGreaterThan(0) + expect(screen.getByRole("link", { name: "Repo" })).toHaveAttribute( + "href", + "https://github.com/acme/api" + ) + }) + + it("shows an error message when the API request fails", async () => { + const user = userEvent.setup() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false })) + + render() + + await user.click(screen.getByRole("button", { name: "Analyze Posts" })) + + expect(await screen.findByText("Failed to fetch data from API")).toBeInTheDocument() + expect(consoleErrorSpy).toHaveBeenCalled() + }) +}) diff --git a/frontend/components/analyzer/results-section.test.tsx b/frontend/components/analyzer/results-section.test.tsx new file mode 100644 index 0000000..328269c --- /dev/null +++ b/frontend/components/analyzer/results-section.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ResultsSection, type PostData } from "./results-section" + + +const data: PostData[] = [ + { + id: 1, + author: "Grace Hopper", + avatar: "https://images.dev/grace.png", + date: "2026-07-08", + topic: "Ship Better Python APIs", + tech_stack: "FastAPI, Python", + problem: "Slow sync handlers.", + solution: "Adopt async IO.", + github_url: "https://github.com/acme/api", + reacts: 14, + comments: 2, + url: "https://dev.to/example/post", + }, +] + +describe("ResultsSection", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it("copies a readable text summary to the clipboard", async () => { + const user = userEvent.setup() + const writeTextMock = vi.mocked(navigator.clipboard.writeText) + + render() + + await user.click(screen.getByRole("button", { name: /copy data/i })) + + expect(writeTextMock).toHaveBeenCalledWith( + expect.stringContaining("Grace Hopper - Ship Better Python APIs") + ) + expect(writeTextMock).toHaveBeenCalledWith( + expect.stringContaining("GitHub URL: https://github.com/acme/api") + ) + }) + + it("exports the current results as csv", async () => { + const user = userEvent.setup() + const createObjectURLMock = vi.mocked(URL.createObjectURL) + const revokeObjectURLMock = vi.mocked(URL.revokeObjectURL) + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}) + + render() + + await user.click(screen.getByRole("button", { name: /export csv/i })) + + expect(createObjectURLMock).toHaveBeenCalledTimes(1) + const blob = createObjectURLMock.mock.calls[0][0] as Blob + await expect(blob.text()).resolves.toContain('"Grace Hopper"') + expect(clickSpy).toHaveBeenCalledTimes(1) + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock-url") + }) +}) diff --git a/frontend/package.json b/frontend/package.json index 248114d..2f4819a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -60,13 +62,18 @@ "zod": "^3.24.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^22", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "jsdom": "^26.1.0", "postcss": "^8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "1.3.3", - "typescript": "5.7.3" + "typescript": "5.7.3", + "vitest": "^3.2.4" } } diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts new file mode 100644 index 0000000..dc60118 --- /dev/null +++ b/frontend/test/setup.ts @@ -0,0 +1,37 @@ +import "@testing-library/jest-dom/vitest" + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +Object.defineProperty(navigator, "clipboard", { + writable: true, + value: { + writeText: vi.fn(), + }, +}) + +Object.defineProperty(URL, "createObjectURL", { + writable: true, + value: vi.fn(() => "blob:mock-url"), +}) + +Object.defineProperty(URL, "revokeObjectURL", { + writable: true, + value: vi.fn(), +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..cb3a6f1 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname), + }, + }, +})