Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/skills/unit-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
name: unit-tests
description: 'Run, analyze, fix, and report on the Python unit tests in tests/. Use when: working on, running, or reasoning about unit tests; tests are failing; CI is red; debugging test errors; adding a missing argument to a test helper after a new parameter was introduced. DO NOT USE FOR: acceptance tests (atest/), Robot Framework test suites.'
argument-hint: 'Optional: specific test file or test name to focus on'
---

# Unit Tests

## How to run

**Windows:**
```bat
scripts\unittests.bat
```

**Linux / macOS:**
```bash
bash scripts/unittests.sh
```

Both scripts run `pytest` with coverage reporting on the `robotframework_dashboard` package.

**Targeted runs:**
```bash
python -m pytest tests/test_server.py --tb=short
python -m pytest tests/test_dashboard.py::test_generate_dashboard_creates_file --tb=short
```

**Coverage only (no script):**
```bash
python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage
```

## Test layout

All unit tests live flat in `tests/` — no subdirectories.

| File | What it covers |
|---|---|
| `tests/conftest.py` | Shared pytest fixtures (XML paths, `OutputProcessor`, `DatabaseProcessor`) |
| `tests/test_arguments.py` | `dotdict`, `_normalize_bool`, `_check_project_version_usage`, `_process_arguments` (all branches), `get_arguments` via mocked `sys.argv` |
| `tests/test_processors.py` | `OutputProcessor`: `get_run_start`, `get_output_data`, `calculate_keyword_averages`, `merge_run_and_suite_metadata`; legacy RF compat branches (`RunProcessor`, `SuiteProcessor`, `TestProcessor`, `KeywordProcessor`) |
| `tests/test_database.py` | `DatabaseProcessor`: table creation, schema migration, insert/get round-trip, all `remove_runs` strategies, `list_runs`, `vacuum_database`, `update_output_path`, static helpers |
| `tests/test_dashboard.py` | `DashboardGenerator`: `_compress_and_encode`, `_minify_text`, `generate_dashboard` (file creation, title, server mode, configs, subdirectory) |
| `tests/test_dependencies.py` | `DependencyProcessor`: JS/CSS block generation, CDN vs offline mode, admin-page variant, file gathering |
| `tests/test_robotdashboard.py` | `RobotDashboard`: `initialize_database`, `process_outputs`, `print_runs`, `remove_outputs`, `create_dashboard`, `get_runs`, `get_run_paths`, `update_output_path` |
| `tests/test_main.py` | `main()`: orchestration pipeline via mocked `ArgumentParser` and `RobotDashboard`; server branch |
| `tests/test_server.py` | `ApiServer`: all FastAPI endpoints via `TestClient` — auth, add/remove outputs, add/remove logs, file uploads (plain and gzip), catch-all resource route, autoupdate flag |

## Test data

Real `output.xml` files live in `testdata/outputs/`. These are the same 15 Robot Framework output files used by the acceptance tests — no synthetic mocks. Using real XMLs means `OutputProcessor` and `DatabaseProcessor` are exercised against genuine data, not fabricated inputs.

Inline data fixtures (plain Python tuples/dicts) are used only for edge cases that real XMLs cannot cover, such as malformed inputs and single-entry keyword lists.

## Testing the server

`tests/test_server.py` uses `fastapi.testclient.TestClient` (backed by `httpx`) to exercise every endpoint in `ApiServer` without starting a real network process. The `RobotDashboard` dependency is replaced with a `MagicMock`, making every test fast and deterministic. `httpx` is a required dev dependency — install it with `pip install httpx`.

Key patterns used:
- `_make_server()` helper creates an `ApiServer` with mock `RobotDashboard` attached.
- `monkeypatch.chdir(tmp_path)` is used whenever the server writes files to the current directory (e.g., `output_data`, file uploads).
- `client.request("DELETE", ...)` is used for the `DELETE` endpoints since `TestClient` has no `.delete()` method that accepts a JSON body.

## Why no pytest-mock

`pytest-mock` was considered and explicitly rejected for the pure-logic tests. The codebase has no need for it because:

- Pure functions are tested directly with inline data.
- `DatabaseProcessor` is tested against an in-memory SQLite (`:memory:`) — no patching needed.
- `OutputProcessor` is tested with real XML files — no patching of `robot.api` needed.
- `server.py` endpoints use `TestClient` + `MagicMock` — the standard library `unittest.mock` is sufficient.

## What is deliberately not unit-tested

| Module | Reason |
|---|---|
| `main.py` (fully wired) | Pure orchestration entry point; the two `test_main.py` tests cover the call graph using mocks, but the real subprocess path (file I/O) is covered by acceptance tests |
| `abstractdb.py` abstract methods | These are abstract — by definition untestable without a concrete subclass; the concrete `DatabaseProcessor` is fully tested |

## CI integration

Unit tests run as a separate `unit-tests` job in `.github/workflows/tests.yml` **before** the Robot acceptance tests. The `robot-tests` job declares `needs: unit-tests`, so acceptance tests are skipped entirely if unit tests fail. This keeps CI fast: a broken pure-Python function fails in seconds rather than after the full heavyweight Playwright container spins up.

## Schema migration test

`test_schema_migration_runs_table_from_10_to_14` in `test_database.py` creates a legacy 10-column SQLite database by hand and asserts that `DatabaseProcessor.__init__` automatically migrates all four tables to their current column counts (runs: 14, suites: 11, tests: 12, keywords: 12). This protects against regressions when future schema columns are added.

## Analyzing and fixing failures

### Categorize the failure

| Failure pattern | Likely cause | Action |
|---|---|---|
| `TypeError: missing required argument` | New parameter added to production code, test helper not updated | **Fix test** — add the new param with a sensible default |
| `AssertionError` on a value that changed | Business logic changed intentionally | **Verify intent** — check git diff; fix test if change is correct |
| `AssertionError` on a value that should NOT have changed | Regression in production code | **Fix code** — this is a real bug |
| `ImportError` / `ModuleNotFoundError` | Refactor moved or renamed something | Check both sides; fix whichever is wrong |
| `AttributeError` on production object | API surface changed | **Verify intent** — fix test if change was deliberate |
| Unexpected exception in production code under test | Real bug | **Fix code** |

### Fix appropriately

- **Only fix the tests** when production code changed intentionally and tests need to catch up.
- **Fix the code** when a test reveals a genuine regression or broken behaviour.
- **Never** silently skip or `xfail` a test to make CI green without investigating.

### Common fix: new required parameter

When a new required parameter is added to `generate_dashboard()`, `RobotDashboard.__init__()`, or `DashboardServer.__init__()`:
- Find the test helper function in the relevant test file and add the parameter with its default value (usually `False` for booleans).
- Also check for any direct call-sites in other test functions in the same file.

**Test helpers in this project:**
- `tests/test_dashboard.py` → `_call_generate(tmp_path, **kwargs)`
- `tests/test_robotdashboard.py` → `_make_rd(tmp_path, **kwargs)`
- `tests/test_server.py` → `_make_server(**kwargs)`

### Report format

After investigating, report:

| Test | Root Cause | Real Bug? | Fix Applied |
|------|-----------|-----------|-------------|
| `test_foo` | … | Yes / No | … |
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,35 @@ on:
pull_request:

jobs:
unit-tests:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Run unit tests
run: |
bash scripts/unittests.sh

- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml

robot-tests:
needs: unit-tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
results
logs
*.pyc
.coverage
__pycache__
dist
build
Expand Down
30 changes: 15 additions & 15 deletions atest/resources/keywords/general-keywords.resource
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,21 @@ Generate Dashboard
Release Lock name=dashboard_index
VAR ${DASHBOARD_INDEX} ${index} scope=test
${files} Catenate SEPARATOR=${SPACE}
... -o ${CURDIR}/../outputs/output-20250313-002134.xml:prod:project_1:version_1.0
... -o ${CURDIR}/../outputs/output-20250313-002151.xml:dev:project_1:version_1.0
... -o ${CURDIR}/../outputs/output-20250313-002222.xml:prod:project_2:version_1.0
... -o ${CURDIR}/../outputs/output-20250313-002257.xml:dev:project_2:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002338.xml:prod:project_1:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002400.xml:dev:project_1:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002431.xml:prod:project_2:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002457.xml:dev:project_2:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002528.xml:prod:project_1:version_1.1
... -o ${CURDIR}/../outputs/output-20250313-002549.xml:prod:project_1:version_1.2
... -o ${CURDIR}/../outputs/output-20250313-002636.xml:prod:project_2:version_1.2
... -o ${CURDIR}/../outputs/output-20250313-002703.xml:prod:project_2:version_1.2
... -o ${CURDIR}/../outputs/output-20250313-002739.xml:dev:project_1:version_1.2
... -o ${CURDIR}/../outputs/output-20250313-002915.xml:dev:project_1:version_1.2
... -o ${CURDIR}/../outputs/output-20250313-003006.xml:dev:project_2:amount:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002134.xml:prod:project_1:version_1.0
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002151.xml:dev:project_1:version_1.0
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002222.xml:prod:project_2:version_1.0
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002257.xml:dev:project_2:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002338.xml:prod:project_1:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002400.xml:dev:project_1:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002431.xml:prod:project_2:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002457.xml:dev:project_2:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002528.xml:prod:project_1:version_1.1
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002549.xml:prod:project_1:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002636.xml:prod:project_2:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002703.xml:prod:project_2:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002739.xml:dev:project_1:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002915.xml:dev:project_1:version_1.2
... -o ${CURDIR}/../../../testdata/outputs/output-20250313-003006.xml:dev:project_2:amount:version_1.2
${output} Run command=robotdashboard -d robotresults_${DASHBOARD_INDEX}.db ${files} -n robotdashboard_${DASHBOARD_INDEX}.html
Log ${output}

Expand Down
2 changes: 1 addition & 1 deletion atest/testsuites/00_cli.robot
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Suite Teardown Run Teardown Only Once keyword=Remove Database And Dashboar


*** Variables ***
${OUTPUTS_FOLDER} ${CURDIR}/../resources/outputs
${OUTPUTS_FOLDER} ${CURDIR}/../../testdata/outputs
${OS} ${None} # set on runtime


Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[tool.pytest.ini_options]
filterwarnings = [
"ignore::ResourceWarning",
]
10 changes: 9 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ setuptools
build
wheel
twine
mysql-connector-python
mysql-connector-python
fastapi
fastapi_offline
uvicorn
python-multipart
robotframework
pytest
pytest-cov
httpx
22 changes: 11 additions & 11 deletions robotframework_dashboard/abstractdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,59 +7,59 @@ def __init_subclass__(cls, **kwargs):
"""Function to validate that the custom dabataseclass is named 'DatabaseProcessor' correctly"""
super().__init_subclass__(**kwargs)
if cls.__name__ != "DatabaseProcessor":
raise TypeError(f"The custom databaseclass classname must be 'DatabaseProcessor', not '{cls.__name__}'")
raise TypeError(f"The custom databaseclass classname must be 'DatabaseProcessor', not '{cls.__name__}'") # pragma: no cover

@abstractmethod
def __init__(self, database_path: Path) -> None:
"""Mandatory: This function should handle the creation of the tables if required
The use of the database_path variable might not be required but you should still keep it as an argument!
"""
pass
pass # pragma: no cover

@abstractmethod
def open_database(self) -> None:
"""Mandatory: This function should handle the connection to the database and set it for other functions to use"""
pass
pass # pragma: no cover

@abstractmethod
def close_database(self) -> None:
"""Mandatory: This function is called to close the connection to the database"""
pass
pass # pragma: no cover

@abstractmethod
def run_start_exists(self, run_start: str) -> bool:
"""Mandatory: This function is called to check if the output is already present in the database, this is done to save time on needless reprocessing.
If you want a very simple implementation without complex logic you can simply "return False". This will work but will reprocess needlessly.
"""
pass
pass # pragma: no cover

@abstractmethod
def insert_output_data(
self, output_data: dict, tags: list, run_alias: str, path: Path, project_version: str, timezone: str = ""
) -> None:
"""Mandatory: This function inserts the data of an output file into the database"""
pass
pass # pragma: no cover

@abstractmethod
def get_data(self) -> dict:
"""Mandatory: This function gets all the data in the database"""
pass
pass # pragma: no cover

@abstractmethod
def list_runs(self) -> None:
"""Mandatory: This function gets all available runs and prints them to the console"""
pass
pass # pragma: no cover

@abstractmethod
def remove_runs(self, remove_runs: list) -> None:
"""Mandatory: This function removes all provided runs and all their corresponding data"""
pass
pass # pragma: no cover

def update_output_path(self, log_path: str) -> None:
def update_output_path(self, log_path: str) -> None: # pragma: no cover
"""Optional: Function to update the output_path using the log path that the server has used"""
raise NotImplementedError("update_output_path is not implemented in the custom databaseclass, but is only required when using the server!")

def _get_run_paths(self) -> dict:
def _get_run_paths(self) -> dict: # pragma: no cover
"""Optional: Returns a dict mapping run_start -> path for all runs.
Required by the server when automatically deleting log files after removing outputs.
If not implemented, log files will not be automatically deleted on output removal."""
Expand Down
6 changes: 6 additions & 0 deletions robotframework_dashboard/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from time import time
from datetime import datetime, timezone

# Explicit adapter for datetime -> ISO string, replacing the deprecated default
# behaviour removed in Python 3.12+. Compatible with Python 3.8+.
# See: https://docs.python.org/3/library/sqlite3.html#adapter-and-converter-recipes
sqlite3.register_adapter(datetime, lambda val: val.isoformat(sep=" "))


class DatabaseProcessor(AbstractDatabaseProcessor):
def __init__(self, database_path: Path):
Expand Down Expand Up @@ -125,6 +130,7 @@ def get_keywords_length():
def close_database(self):
"""This function is called to close the connection to the database"""
self.connection.close()
self.connection = None

def insert_output_data(
self,
Expand Down
2 changes: 1 addition & 1 deletion robotframework_dashboard/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def _inline_js_modules(self, js_files):
modules = {}
for rel_path in js_files:
abs_path = base / rel_path
if not abs_path.exists():
if not abs_path.exists(): # pragma: no cover
raise FileNotFoundError(f"JS module not found: {abs_path}")
modules[str(abs_path)] = abs_path.read_text(encoding="utf-8")

Expand Down
2 changes: 1 addition & 1 deletion robotframework_dashboard/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,5 @@ def main():
server.run()


if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
main()
12 changes: 7 additions & 5 deletions robotframework_dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@ def __init__(

def _get_admin_page(self):
admin_file = join(dirname(abspath(__file__)), "./templates", "admin.html")
admin_html = open(admin_file, "r").read()
with open(admin_file, "r", encoding="utf-8") as _f:
admin_html = _f.read()
admin_html = admin_html.replace(
"<!-- placeholder_refresh_card_visibility -->",
"" if self.no_autoupdate else "hidden",
Expand Down Expand Up @@ -333,8 +334,8 @@ def model_examples(model_cls: BaseModel):
return openapi_examples

def authenticate(credentials: HTTPBasicCredentials = Depends(self.security)):
if not self.server_user or not self.server_pass:
return "anonymous"
if not self.server_user or not self.server_pass: # pragma: no cover
return "anonymous" # pragma: no cover
correct_username = compare_digest(credentials.username, self.server_user)
correct_password = compare_digest(credentials.password, self.server_pass)
if not (correct_username and correct_password):
Expand Down Expand Up @@ -364,7 +365,8 @@ async def admin_page(username: str = Depends(authenticate)):
)
async def dashboard_page():
"""Serve robotdashboard HTML endpoint function"""
robot_dashboard_html = open("robot_dashboard.html", "r", encoding="utf-8").read()
with open("robot_dashboard.html", "r", encoding="utf-8") as _f:
robot_dashboard_html = _f.read()
return robot_dashboard_html

@self.app.post("/refresh-dashboard")
Expand Down Expand Up @@ -798,6 +800,6 @@ def set_robotdashboard(self, robotdashboard: RobotDashboard):
"""Function to initialize the RobotDashboard class"""
self.robotdashboard = robotdashboard

def run(self):
def run(self): # pragma: no cover
"""Function to start up the FastAPI server through uvicorn"""
run(self.app, host=self.server_host, port=self.server_port)
4 changes: 4 additions & 0 deletions scripts/unittests.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
set COVERAGE_FILE=results/.coverage
set PYTHONPATH=%~dp0..
if not exist results mkdir results
python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage --cov-report=xml:results/coverage.xml
6 changes: 6 additions & 0 deletions scripts/unittests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p results results/coverage
COVERAGE_FILE=results/.coverage PYTHONPATH="$SCRIPT_DIR/.." python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage --cov-report=xml:coverage.xml
Loading
Loading