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),
+ },
+ },
+})