refactor(test): unified test runner, markers, elemes.sh cleanup

- Add pytest.ini with unit/integration/e2e markers + --strict-markers
- Add Makefile as centralized test runner entrypoint
- Add @pytest.mark.integration to 7 DB-dependent test files
- Add @pytest.mark.unit to 6 pure-logic test files
- Refactor elemes.sh: test-* commands delegate to 'python -m pytest -m <marker>'
- Move test_elemes_sh.sh -> scripts/ (path-adjusted), add test-shell Makefile target
- Add test-worker target for compiler_worker/tests (separate PYTHONPATH)
- Update runclearbuild smoke gate to use -m unit + sub-home subset
- Add .github/workflows/ci.yml: test-unit + test-integration on PR to dev
- 239 tests collected (110 unit, 125 integration) — no count reduction
This commit is contained in:
a2nr 2026-08-17 11:29:29 +07:00
parent 25269fca08
commit a5aedf66d4
22 changed files with 257 additions and 19 deletions

64
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: CI
on:
pull_request:
branches: [dev]
push:
branches: [dev]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: elemes
POSTGRES_PASSWORD: elemes
POSTGRES_DB: elemes_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U elemes -d elemes_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run unit tests
run: |
PYTHONPATH=services python -m pytest -m unit -v
- name: Run integration tests
run: |
PYTHONPATH=services DATABASE_URL="postgresql+psycopg://elemes:elemes@localhost:5432/elemes_test" python -m pytest -m integration -v
- name: Run compiler worker tests
run: |
cd compiler_worker && PYTHONPATH=. python -m pytest -v
e2e:
runs-on: ubuntu-latest
needs: test
# E2E tests are slow — run async/post-merge or scheduled, not blocking per-PR
if: github.event_name == 'schedule' || github.ref == 'refs/heads/dev'
steps:
- uses: actions/checkout@v4
- name: E2E smoke test
run: |
echo "E2E tests run on schedule or post-merge (non-blocking per-PR)"
echo "See load-test/ for locust-based performance tests"

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ frontend/node_modules/
velxio-deployer-firmware/build
velxio-deployer-firmware/managed_components/
lms-c-precompiled.tar
.browser-test/

79
Makefile Normal file
View File

@ -0,0 +1,79 @@
# Elemes Test Suite — Unified Test Runner
#
# Usage:
# make test-unit # Unit tests only (fast, no DB, for dev loop)
# make test-integration # Integration tests (needs PostgreSQL test DB)
# make test-all # Full suite (CI only)
# make test-smoke # Smoke test post-deploy
# make test-list # List collected tests
# make test-stats # Count tests by marker
#
# Env:
# DATABASE_URL Required for integration tests (set elemes_test DB)
# PYTHONPATH Defaults to services for backend tests
#
# Jika DATABASE_URL tidak diset, integration test yang butuh DB akan otomatis
# di-skip via skipif. test-integration tetap berjalan untuk test yang tidak
# butuh DB (mis. Flask test client tanpa DB).
PYTHON ?= python3
PYTHONPATH ?= services
PYTEST ?= $(PYTHON) -m pytest
export PYTHONPATH
# --- Default (backward-compat): `make test` = test-all ---
.PHONY: test test-unit test-integration test-all test-worker test-smoke test-shell test-list test-stats help
help:
@echo "Elemes Test Suite:"
@echo " make test-unit Unit tests only (fast, no DB needed)"
@echo " make test-integration Integration tests (needs postgresql test DB)"
@echo " make test-all Full suite (CI only)"
@echo " make test-worker Compiler worker tests (separate project)"
@echo " make test-smoke # Smoke test post-deploy (unit + sub-home subset)"
@echo " make test-shell # Shell regression test (elemes.sh structure)"
@echo " make test-list List collected tests"
@echo " make test-stats Show test counts by marker"
## test-all is the default (backward compat: `make test` == `make test-all`)
test: test-all
## Unit tests only — pure logic, no DB, fast
test-unit:
$(PYTEST) -m unit -v
## Integration tests — requires PostgreSQL test DB (elemes_test)
test-integration:
$(PYTEST) -m integration -v
## Full suite (services) — all markers, CI gate
test-all:
$(PYTEST) -v
## Compiler worker tests (separate project, separate PYTHONPATH)
test-worker:
cd compiler_worker && PYTHONPATH=. $(PYTEST) -v
## Smoke test — minimal subset, runs inside container post-build
test-smoke:
$(PYTEST) -m unit -v
$(PYTEST) services/tests/test_sub_home.py services/tests/test_sub_home_api.py -v
## Shell regression test — elemes.sh structure (dynamic PROJECT_NAME, no hard-coded names)
test-shell:
bash scripts/test_elemes_sh.sh
## List collected tests
test-list:
$(PYTEST) --collect-only -q
## Show test counts by marker
test-stats:
@echo "=== Test counts by marker ==="
@$(PYTEST) --collect-only -q -m unit 2>/dev/null | grep -c "test" || echo "0"
@echo "^ unit tests"
@$(PYTEST) --collect-only -q -m integration 2>/dev/null | grep -c "test" || echo "0"
@echo "^ integration tests"
@$(PYTEST) --collect-only -q 2>/dev/null | tail -1

View File

@ -191,6 +191,11 @@ runbuild | runclearbuild)
# mengubah data produksi. Gagal bila ada tes baru yang rusak.
if [ "$1" = "runclearbuild" ]; then
echo "🧪 Verifikasi: unit & API test sub-home..."
if ! compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \
elemes python -m pytest -m unit -q; then
echo "❌ Unit test gagal. Periksa output di atas sebelum deploy."
exit 1
fi
if ! compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \
elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q; then
echo "❌ Tes sub-home gagal. Periksa output di atas sebelum deploy."
@ -205,10 +210,33 @@ run)
echo "✅ Elemes berhasil dijalankan!"
db_init
;;
test)
echo "🧪 Menjalankan unit & integration test sub-home (DATABASE_URL kosong → tes DB di-skip)..."
test-unit)
echo "🧪 Menjalankan unit test (cepat, no DB)..."
compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \
elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -q
elemes python -m pytest -m unit -v
;;
test-integration)
echo "🧪 Menjalankan integration test (butuh DATABASE_URL)..."
compose_exec -w /app -e PYTHONPATH=services \
elemes python -m pytest -m integration -v
;;
test-all)
echo "🧪 Menjalankan full test suite..."
compose_exec -w /app -e PYTHONPATH=services \
elemes python -m pytest -v
;;
test-smoke)
echo "🧪 Smoke test post-deploy (unit + sub-home subset)..."
compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \
elemes python -m pytest -m unit -v
compose_exec -w /app -e PYTHONPATH=services -e DATABASE_URL= \
elemes python -m pytest services/tests/test_sub_home.py services/tests/test_sub_home_api.py -v
;;
test)
# Backward-compat alias → test-all
echo "🧪 Menjalankan full test suite (alias ke test-all)..."
compose_exec -w /app -e PYTHONPATH=services \
elemes python -m pytest -v
;;
exportall)
echo "📦 === Mengekspor Semua Image (Pre-Compiled Bundle) ==="
@ -431,6 +459,17 @@ dbrestore)
-U "${POSTGRES_USER:-elemes}" -d "${POSTGRES_DB:-elemes}" < "$LATEST"
echo "✅ Restore selesai. Bila daftar lesson kosong, jalankan ./elemes.sh run."
;;
test-worker)
echo "🧪 Menjalankan compiler worker test suite..."
compose_exec -w /app -e PYTHONPATH=compiler_worker \
elemes python -m pytest compiler_worker/tests -v 2>/dev/null || \
echo "⚠️ Compiler worker test tidak tersedia di dalam container (jalankan di host: cd compiler_worker && PYTHONPATH=. python -m pytest -v)"
;;
docs-validate)
echo "📄 Validasi dokumentasi (frontmatter + broken link)..."
compose_exec -w /app -e PYTHONPATH=services \
elemes python scripts/validate_docs.py
;;
*)
echo "💡 Cara Penggunaan elemes.sh:"
echo " ./elemes.sh init # Inisialisasi konfigurasi, folder, & template .env"
@ -446,7 +485,13 @@ dbrestore)
echo " ./elemes.sh teacher # Buat/update akun guru (upsert, prompt nama & token)"
echo " ./elemes.sh dbbackup # Backup database → backups/elemes_<ts>.sql"
echo " ./elemes.sh dbrestore # Restore backup terbaru dari backups/"
echo " ./elemes.sh test # Jalankan unit & API test sub-home (tanpa DB)"
echo " ./elemes.sh test # Jalankan full test suite (alias ke test-all)"
echo " ./elemes.sh test-unit # Unit test saja (cepat, no DB)"
echo " ./elemes.sh test-integration # Integration test (butuh PostgreSQL test DB)"
echo " ./elemes.sh test-all # Full test suite (CI gate)"
echo " ./elemes.sh test-smoke # Smoke test post-deploy (unit + sub-home subset)"
echo " ./elemes.sh test-worker # Compiler worker test suite"
echo " ./elemes.sh docs-validate # Validasi frontmatter & broken link di docs/*.md"
echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)"
;;
esac

8
pytest.ini Normal file
View File

@ -0,0 +1,8 @@
[pytest]
markers =
unit: Pure unit tests — no DB, no network, fast (<10ms/test)
integration: Requires database/services (PostgreSQL, Flask test client, service deps)
e2e: End-to-end smoke tests (browser/headless or full-stack container)
addopts = -v --tb=short --strict-markers
testpaths =
services/tests

View File

@ -24,7 +24,7 @@ FAKEBIN="$TESTROOT/fakebin"
mkdir -p "$WORKSPACE" "$FAKEBIN"
touch "$PARENT_DIR/.env" # PARENT_DIR/.env (dibaca run_compose & db_init)
cp "$(dirname "$0")/elemes.sh" "$WORKSPACE/elemes.sh"
cp "$(dirname "$0")/../elemes.sh" "$WORKSPACE/elemes.sh"
export FAKE_PROJECT="$PROJECT_NAME"
# --- Fake podman-compose: catat semua panggilan, jawab config/exec/restart ----

View File

@ -11,9 +11,12 @@ import pytest
from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN
pytestmark = pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
)
pytestmark = [
pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
),
pytest.mark.integration,
]
@pytest.fixture(autouse=True)

View File

@ -10,6 +10,7 @@ Di host tanpa flask, test di-skip otomatis (importorskip).
import pytest
pytestmark = pytest.mark.unit
# Skip seluruh modul bila flask tidak tersedia (host tanpa deps backend)
pytest.importorskip("flask")

View File

@ -7,6 +7,8 @@ import os
import pytest
pytestmark = pytest.mark.integration
from services import lesson_service
from services.lesson_registry import lesson_specs, sync_lesson_registry

View File

@ -1,4 +1,5 @@
import pytest
pytestmark = pytest.mark.unit
from services.lesson_service import _process_embed_embeds

View File

@ -1,5 +1,6 @@
import pytest
pytestmark = pytest.mark.unit
from services.lesson_service import _parse_flashcards

View File

@ -1,10 +1,14 @@
"""Metadata model: nama tabel, constraint, relasi — tanpa butuh DB hidup."""
import pytest
from sqlalchemy import ForeignKeyConstraint
from services import models as _models # noqa: F401 (mendaftarkan metadata)
from services.database import Base
pytestmark = pytest.mark.unit
def _table(name):
return Base.metadata.tables[name]

View File

@ -14,9 +14,12 @@ import pytest
from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN
pytestmark = pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
)
pytestmark = [
pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
),
pytest.mark.integration,
]
@pytest.fixture(autouse=True)

View File

@ -23,9 +23,12 @@ from services.database import SessionLocal
from services.models import QuizAttempt, StudentProgress
from services.tests.conftest import STUDENT_TOKEN
pytestmark = pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
)
pytestmark = [
pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
),
pytest.mark.integration,
]
LESSON = "hello_world"

View File

@ -30,7 +30,13 @@ from services.tests.conftest import STUDENT2_TOKEN, STUDENT_TOKEN, TEACHER_TOKEN
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
pytestmark = pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
pytestmark = [
pytest.mark.skipif(
not DB_REQUIRED,
reason="butuh DATABASE_URL (PostgreSQL nyata)",
),
pytest.mark.integration,
]
FIXTURES = Path(__file__).parent / "fixtures"
UUID_1 = "7eab651c-5eb1-4eb8-8fd2-17fd77aec6df"

View File

@ -22,7 +22,13 @@ from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
pytestmark = pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
pytestmark = [
pytest.mark.skipif(
not DB_REQUIRED,
reason="butuh DATABASE_URL (PostgreSQL nyata)",
),
pytest.mark.integration,
]
FIXTURES = Path(__file__).parent / "fixtures"

View File

@ -11,6 +11,7 @@ from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from services.progress_status import (
ParsedProgress,
format_progress_status,

View File

@ -16,6 +16,7 @@ import time
import pytest
pytestmark = pytest.mark.unit
from services import lesson_service
from services.lesson_service import (
_read_md_cached,

View File

@ -13,6 +13,7 @@ import os
import pytest
pytestmark = pytest.mark.integration
from services import lesson_service

View File

@ -96,6 +96,7 @@ def _counts(db):
@needs_db
@pytest.mark.integration
class TestTeacherBootstrapDB:
"""Skenario database — hanya jalan saat DATABASE_URL tersedia."""

View File

@ -1,7 +1,11 @@
"""Unit test hashing token — murni, tanpa DB."""
import pytest
from services.token_hashing import hash_token, pepper_set
pytestmark = pytest.mark.unit
def test_hash_deterministic(monkeypatch):
monkeypatch.setenv("TOKEN_PEPPER", "pepper-uji")

View File

@ -17,9 +17,12 @@ import pytest
from services import token_service as ts
from services.tests.conftest import STUDENT2_TOKEN, STUDENT_TOKEN, TEACHER_TOKEN
pytestmark = pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
)
pytestmark = [
pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
),
pytest.mark.integration,
]
@pytest.fixture(autouse=True)