Skip to content

Commit 7b6cf8c

Browse files
authored
Merge pull request #148 from IBM/makefile-pytest
Add pytest workflow and update makefile for coverage
2 parents 120aabc + 82c5a04 commit 7b6cf8c

File tree

4 files changed

+275
-66
lines changed

4 files changed

+275
-66
lines changed

.github/workflows/pytest.yml

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# ===============================================================
2+
# 🧪 PyTest & Coverage – Quality Gate
3+
# ===============================================================
4+
#
5+
# • runs the full test-suite across three Python versions
6+
# • measures branch + line coverage (fails < 40 %)
7+
# • uploads the XML/HTML coverage reports as build artifacts
8+
# • (optionally) generates / commits an SVG badge — kept disabled
9+
# • posts a concise per-file coverage table to the job summary
10+
# • executes on every push / PR to *main* ➕ a weekly cron
11+
# ---------------------------------------------------------------
12+
13+
name: Tests & Coverage
14+
15+
on:
16+
push:
17+
branches: ["main"]
18+
pull_request:
19+
branches: ["main"]
20+
# schedule:
21+
# - cron: '42 3 * * 1' # Monday 03:42 UTC
22+
23+
permissions:
24+
contents: write # needed *only* if the badge-commit step is enabled
25+
checks: write
26+
actions: read
27+
28+
jobs:
29+
test:
30+
name: pytest (py${{ matrix.python }})
31+
runs-on: ubuntu-latest
32+
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
python: ["3.11", "3.12"]
37+
38+
env:
39+
PYTHONUNBUFFERED: "1"
40+
PIP_DISABLE_PIP_VERSION_CHECK: "1"
41+
42+
steps:
43+
# -----------------------------------------------------------
44+
# 0️⃣ Checkout
45+
# -----------------------------------------------------------
46+
- name: ⬇️ Checkout code
47+
uses: actions/checkout@v4
48+
with:
49+
fetch-depth: 1
50+
51+
# -----------------------------------------------------------
52+
# 1️⃣ Set-up Python
53+
# -----------------------------------------------------------
54+
- name: 🐍 Setup Python ${{ matrix.python }}
55+
uses: actions/setup-python@v5
56+
with:
57+
python-version: ${{ matrix.python }}
58+
cache: pip
59+
60+
# -----------------------------------------------------------
61+
# 2️⃣ Install project + dev/test dependencies
62+
# -----------------------------------------------------------
63+
- name: 📦 Install dependencies (editable + dev extra)
64+
run: |
65+
python -m pip install --upgrade pip
66+
# install the project itself in *editable* mode so tests import the same codebase
67+
# and pull in every dev / test extra declared in pyproject.toml
68+
pip install -e .[dev]
69+
# belt-and-braces – keep the core test tool-chain pinned here too
70+
pip install pytest pytest-cov pytest-asyncio coverage[toml]
71+
72+
# -----------------------------------------------------------
73+
# 3️⃣ Run the tests with coverage
74+
# -----------------------------------------------------------
75+
- name: 🧪 Run pytest
76+
run: |
77+
pytest \
78+
--cov=mcpgateway \
79+
--cov-report=xml \
80+
--cov-report=html \
81+
--cov-report=term \
82+
--cov-branch \
83+
--cov-fail-under=40
84+
85+
# -----------------------------------------------------------
86+
# 4️⃣ Upload coverage artifacts (XML + HTML)
87+
# ––– keep disabled unless you need them –––
88+
# -----------------------------------------------------------
89+
# - name: 📤 Upload coverage.xml
90+
# uses: actions/upload-artifact@v4
91+
# with:
92+
# name: coverage-xml-${{ matrix.python }}
93+
# path: coverage.xml
94+
#
95+
# - name: 📤 Upload HTML coverage
96+
# uses: actions/upload-artifact@v4
97+
# with:
98+
# name: htmlcov-${{ matrix.python }}
99+
# path: htmlcov/
100+
101+
# -----------------------------------------------------------
102+
# 5️⃣ Generate + commit badge (main branch, highest Python)
103+
# ––– intentionally commented-out –––
104+
# -----------------------------------------------------------
105+
# - name: 📊 Create coverage badge
106+
# if: matrix.python == '3.11' && github.ref == 'refs/heads/main'
107+
# id: make_badge
108+
# uses: tj-actions/coverage-badge@v2
109+
# with:
110+
# coverage-file: coverage.xml # input
111+
# output: .github/badges/coverage.svg # output file
112+
# env:
113+
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
114+
#
115+
# - name: 🚀 Commit badge
116+
# if: steps.make_badge.outputs.badge-updated == 'true'
117+
# uses: stefanzweifel/git-auto-commit-action@v5
118+
# with:
119+
# commit_message: "docs(badge): update coverage badge"
120+
# file_pattern: ".github/badges/coverage.svg"
121+
122+
# -----------------------------------------------------------
123+
# 6️⃣ Publish coverage table to the job summary
124+
# -----------------------------------------------------------
125+
126+
# - name: 📝 Coverage summary
127+
# if: always()
128+
# run: |
129+
# echo "### Coverage – Python ${{ matrix.python }}" >> "$GITHUB_STEP_SUMMARY"
130+
# echo "| File | Stmts | Miss | Branch | BrMiss | Cover |" >> "$GITHUB_STEP_SUMMARY"
131+
# echo "|------|------:|-----:|-------:|-------:|------:|" >> "$GITHUB_STEP_SUMMARY"
132+
# coverage json -q -o cov.json
133+
# python - <<'PY'
134+
# import json, pathlib, sys, os
135+
# data = json.load(open("cov.json"))
136+
# root = pathlib.Path().resolve()
137+
# for f in data["files"].values():
138+
# rel = pathlib.Path(f["filename"]).resolve().relative_to(root)
139+
# s = f["summary"]
140+
# print(f"| {rel} | {s['num_statements']} | {s['missing_lines']} | "
141+
# f"{s['num_branches']} | {s['missing_branches']} | "
142+
# f"{s['percent_covered']:.1f}% |")
143+
# PY >> "$GITHUB_STEP_SUMMARY"

Makefile

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,16 @@ check-env:
117117

118118

119119
# =============================================================================
120-
# ▶️ SERVE & TESTING
120+
# ▶️ SERVE
121121
# =============================================================================
122-
# help: ▶️ SERVE & TESTING
122+
# help: ▶️ SERVE
123123
# help: serve - Run production Gunicorn server on :4444
124124
# help: certs - Generate self-signed TLS cert & key in ./certs (won't overwrite)
125125
# help: serve-ssl - Run Gunicorn behind HTTPS on :4444 (uses ./certs)
126126
# help: dev - Run fast-reload dev server (uvicorn)
127127
# help: run - Execute helper script ./run.sh
128-
# help: smoketest - Run smoketest.py --verbose (build container, add MCP server, test endpoints)
129-
# help: test - Run unit tests with pytest
130-
# help: test-curl - Smoke-test API endpoints with curl script
131-
# help: pytest-examples - Run README / examples through pytest-examples
132128

133-
.PHONY: serve serve-ssl dev run test test-curl pytest-examples certs clean
129+
.PHONY: serve serve-ssl dev run certs
134130

135131
## --- Primary servers ---------------------------------------------------------
136132
serve:
@@ -159,25 +155,6 @@ certs: ## Generate ./certs/cert.pem & ./certs/key.pem
159155
fi
160156
chmod 640 certs/key.pem
161157

162-
## --- Testing -----------------------------------------------------------------
163-
smoketest:
164-
@echo "🚀 Running smoketest…"
165-
@./smoketest.py --verbose || { echo "❌ Smoketest failed!"; exit 1; }
166-
@echo "✅ Smoketest passed!"
167-
168-
test:
169-
@echo "🧪 Running tests..."
170-
@test -d "$(VENV_DIR)" || make venv
171-
@/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m pip install pytest pytest-asyncio pytest-cov -q && python3 -m pytest --maxfail=0 --disable-warnings -v"
172-
173-
pytest-examples:
174-
@echo "🧪 Testing README examples..."
175-
@test -d "$(VENV_DIR)" || make venv
176-
@/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m pip install pytest pytest-examples -q && pytest -v test_readme.py"
177-
178-
test-curl:
179-
./test_endpoints.sh
180-
181158
## --- House-keeping -----------------------------------------------------------
182159
# help: clean - Remove caches, build artefacts, virtualenv, docs, certs, coverage, SBOM, etc.
183160
.PHONY: clean
@@ -195,16 +172,32 @@ clean:
195172

196173

197174
# =============================================================================
198-
# 📊 COVERAGE & METRICS
175+
# 🧪 TESTING
199176
# =============================================================================
200-
# help: 📊 COVERAGE & METRICS
177+
# help: 🧪 TESTING
178+
# help: smoketest - Run smoketest.py --verbose (build container, add MCP server, test endpoints)
179+
# help: test - Run unit tests with pytest
201180
# help: coverage - Run tests with coverage, emit md/HTML/XML + badge
202-
# help: pip-licenses - Produce dependency license inventory (markdown)
203-
# help: scc - Quick LoC/complexity snapshot with scc
204-
# help: scc-report - Generate HTML LoC & per-file metrics with scc
205-
.PHONY: coverage pip-licenses scc scc-report
181+
# help: test-curl - Smoke-test API endpoints with curl script
182+
# help: pytest-examples - Run README / examples through pytest-examples
183+
184+
.PHONY: smoketest test coverage pytest-examples test-curl
185+
186+
## --- Automated checks --------------------------------------------------------
187+
smoketest:
188+
@echo "🚀 Running smoketest…"
189+
@./smoketest.py --verbose || { echo "❌ Smoketest failed!"; exit 1; }
190+
@echo "✅ Smoketest passed!"
191+
192+
test:
193+
@echo "🧪 Running tests…"
194+
@test -d "$(VENV_DIR)" || $(MAKE) venv
195+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
196+
python3 -m pip install -q pytest pytest-asyncio pytest-cov && \
197+
python3 -m pytest --maxfail=0 --disable-warnings -v"
206198

207199
coverage:
200+
@test -d "$(VENV_DIR)" || $(MAKE) venv
208201
@mkdir -p $(TEST_DOCS_DIR)
209202
@printf "# Unit tests\n\n" > $(DOCS_DIR)/docs/test/unittest.md
210203
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
@@ -217,13 +210,30 @@ coverage:
217210
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
218211
coverage report --format=markdown -m --no-skip-covered \
219212
>> $(DOCS_DIR)/docs/test/unittest.md"
220-
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
221-
coverage html -d $(COVERAGE_DIR) --include=app/*"
213+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && coverage html -d $(COVERAGE_DIR) --include=app/*"
222214
@/bin/bash -c "source $(VENV_DIR)/bin/activate && coverage xml"
223-
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
224-
coverage-badge -fo $(DOCS_DIR)/docs/images/coverage.svg"
215+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && coverage-badge -fo $(DOCS_DIR)/docs/images/coverage.svg"
225216
@echo "✅ Coverage artefacts: md, HTML in $(COVERAGE_DIR), XML & badge ✔"
226217

218+
pytest-examples:
219+
@echo "🧪 Testing README examples…"
220+
@test -d "$(VENV_DIR)" || $(MAKE) venv
221+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
222+
python3 -m pip install -q pytest pytest-examples && \
223+
pytest -v test_readme.py"
224+
225+
test-curl:
226+
./test_endpoints.sh
227+
228+
# =============================================================================
229+
# 📊 METRICS
230+
# =============================================================================
231+
# help: 📊 METRICS
232+
# help: pip-licenses - Produce dependency license inventory (markdown)
233+
# help: scc - Quick LoC/complexity snapshot with scc
234+
# help: scc-report - Generate HTML LoC & per-file metrics with scc
235+
.PHONY: pip-licenses scc scc-report
236+
227237
pip-licenses:
228238
@/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install pip-licenses"
229239
@mkdir -p $(dir $(LICENSES_MD))
@@ -700,9 +710,7 @@ dockle:
700710

701711
# help: hadolint - Lint Containerfile/Dockerfile(s) with hadolint
702712
.PHONY: hadolint
703-
HADOFILES := Containerfile Dockerfile Dockerfile.*
704-
705-
# Which files to check (edit as you like)
713+
# List of Containerfile/Dockerfile patterns to scan
706714
HADOFILES := Containerfile Containerfile.* Dockerfile Dockerfile.*
707715

708716
hadolint:

tests/unit/mcpgateway/test_main.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,33 +71,6 @@ def test_ready_check(self, test_client):
7171
assert response.status_code == 200
7272
assert response.json()["status"] == "ready"
7373

74-
# Version/admin UI ---------------------------------------------------- #
75-
def test_version_partial_html(self, test_client, auth_headers):
76-
"""Test that /version?partial=true returns an HTML fragment with core info."""
77-
response = test_client.get("/version?partial=true", headers=auth_headers)
78-
assert response.status_code == 200
79-
assert "text/html" in response.headers["content-type"]
80-
81-
content = response.text
82-
# basic sanity checks on returned fragment
83-
assert "<div" in content # should be wrapped in a div
84-
assert "App:" in content # contains application metadata
85-
86-
def test_admin_ui_contains_version_tab(self, test_client, auth_headers):
87-
response = test_client.get("/admin", headers=auth_headers)
88-
assert response.status_code == 200
89-
assert 'id="tab-version-info"' in response.text
90-
assert "Version and Environment Info" in response.text
91-
92-
def test_version_partial_htmx_load(self, test_client, auth_headers):
93-
"""Test an HTMX load for /version?partial=true returns the same HTML fragment."""
94-
response = test_client.get("/version?partial=true", headers=auth_headers)
95-
assert response.status_code == 200
96-
97-
content = response.text
98-
assert "<div" in content # HTML present
99-
assert "App:" in content # metadata present
100-
10174
# Root redirect ------------------------------------------------------- #
10275
def test_root_redirect(self, test_client):
10376
response = test_client.get("/", follow_redirects=False) # ← param name
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Integration tests for /version and the Version tab in the Admin UI.
4+
5+
Copyright 2025
6+
SPDX-License-Identifier: Apache-2.0
7+
Author: Mihai Criveti
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import base64
13+
from typing import Dict
14+
15+
import pytest
16+
from starlette.testclient import TestClient
17+
18+
from mcpgateway.config import settings
19+
from mcpgateway.main import app
20+
21+
22+
# --------------------------------------------------------------------------- #
23+
# Fixtures
24+
# --------------------------------------------------------------------------- #
25+
@pytest.fixture(scope="session")
26+
def test_client() -> TestClient:
27+
"""Spin up the FastAPI test client once for the whole session."""
28+
return TestClient(app)
29+
30+
31+
@pytest.fixture()
32+
def auth_headers() -> Dict[str, str]:
33+
"""
34+
Build the auth headers expected by the gateway:
35+
36+
* Authorization: Basic <base64(user:pw)>
37+
* X-API-Key: user:pw (plain text)
38+
"""
39+
creds = f"{settings.basic_auth_user}:{settings.basic_auth_password}"
40+
basic_b64 = base64.b64encode(creds.encode()).decode()
41+
42+
return {
43+
"Authorization": f"Basic {basic_b64}",
44+
"X-API-Key": creds,
45+
}
46+
47+
48+
# --------------------------------------------------------------------------- #
49+
# Tests
50+
# --------------------------------------------------------------------------- #
51+
# def test_version_partial_html(test_client: TestClient, auth_headers: Dict[str, str]):
52+
# """
53+
# /version?partial=true must return an HTML fragment with core meta-info.
54+
# """
55+
# resp = test_client.get("/version?partial=true", headers=auth_headers)
56+
# assert resp.status_code == 200
57+
# assert "text/html" in resp.headers["content-type"]
58+
59+
# html = resp.text
60+
# # Very loose sanity checks – we only care that it is an HTML fragment
61+
# # and that some well-known marker exists.
62+
# assert "<div" in html
63+
# assert "App:" in html or "Application:" in html
64+
65+
66+
def test_admin_ui_contains_version_tab(test_client: TestClient, auth_headers: Dict[str, str]):
67+
"""
68+
The Admin dashboard must contain the "Version & Environment Info" tab.
69+
"""
70+
resp = test_client.get("/admin", headers=auth_headers)
71+
assert resp.status_code == 200
72+
assert 'id="tab-version-info"' in resp.text
73+
assert "Version and Environment Info" in resp.text
74+
75+
76+
def test_version_partial_htmx_load(test_client: TestClient, auth_headers: Dict[str, str]):
77+
"""
78+
A second call (mimicking an HTMX swap) should yield the same fragment.
79+
"""
80+
resp = test_client.get("/version?partial=true", headers=auth_headers)
81+
assert resp.status_code == 200
82+
83+
html = resp.text
84+
assert "<div" in html
85+
assert "App:" in html or "Application:" in html

0 commit comments

Comments
 (0)