Backend Testing Guide
=====================
This comprehensive guide covers testing practices, patterns, and best practices for the ibutsu-server backend. Tests use real database operations with SQLite in-memory databases to ensure they validate actual application behavior while remaining fast and isolated.
.. contents:: Table of Contents
:local:
:depth: 2
Philosophy
----------
Integration Testing Approach
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Our testing philosophy prioritizes **integration tests over heavy mocking** because:
* **Reliability**: Real database operations catch actual issues
* **Maintainability**: Less mock code means less to maintain
* **Confidence**: Tests validate real application behavior
* **Simplicity**: Easier to understand and write
When to Mock vs When to Use Real Data
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Use Real Database Operations For:**
* Database queries and filters
* SQLAlchemy model operations
* Controller logic
* Widget data aggregation
* User authentication and authorization
**Mock Only External Services:**
* Celery tasks (background jobs)
* Redis/cache operations
* External HTTP requests
* Email sending
* Time-sensitive operations (use ``freezegun``)
Running Tests
-------------
Commands
~~~~~~~~
.. code-block:: bash
# Run all tests
cd backend
hatch run test
# Run with coverage
hatch run test-cov
# Run specific test file
hatch run test tests/widgets/test_importance_component.py
# Run specific test
hatch run test tests/widgets/test_importance_component.py::test_get_importance_component_with_valid_project
# Run tests in parallel (default)
hatch run test -n auto
# Run tests verbosely
hatch run test -v
Running Tests by Marker
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: bash
# Run only integration tests
hatch run test -m integration
# Run only validation tests
hatch run test -m validation
# Run all except slow tests
hatch run test -m "not slow"
# Run integration and validation tests
hatch run test -m "integration or validation"
Test File Organization
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: text
backend/tests/
├── conftest.py # Shared fixtures
├── fixtures/ # Fixture modules
│ ├── __init__.py
│ ├── database.py # Flask app and builder fixtures
│ ├── auth.py # Authentication fixtures
│ ├── utilities.py # Utility fixtures
│ └── constants.py # Test constants
├── helpers/ # Test helper functions
│ ├── __init__.py
│ ├── db_builders.py # Database builder functions
│ ├── assertions.py # Common assertions
│ └── factories.py # Data factories
├── controllers/ # Controller integration tests
│ └── test_*_controller.py
├── widgets/ # Widget integration tests
│ └── test_*.py
└── tasks/ # Celery task tests
└── test_*.py
Test Markers
------------
Pytest markers help categorize and filter tests. The following markers are defined in ``pyproject.toml``:
Available Markers
~~~~~~~~~~~~~~~~~
``@pytest.mark.integration``
Marks tests as integration tests that use real database operations. These tests verify actual application behavior with a SQLite in-memory database.
**When to use:** Tests that interact with the database, API endpoints, or multiple components working together.
``@pytest.mark.validation``
Marks tests that validate input/request parameters, error handling, and edge cases.
**When to use:** Tests that verify validation logic, error responses, or parameter checking.
``@pytest.mark.unit``
Marks tests as unit tests that mock external dependencies.
**When to use:** Tests that focus on a single function or class in isolation.
``@pytest.mark.slow``
Marks tests that take a long time to run.
**When to use:** Tests that involve heavy data processing or complex operations.
Using Markers
~~~~~~~~~~~~~
.. code-block:: python
import pytest
@pytest.mark.integration
def test_create_project(flask_app, make_project):
"""Integration test with real database"""
project = make_project(name='test-project')
assert project.id is not None
@pytest.mark.validation
@pytest.mark.parametrize('project_id,expected_status', [
('not-a-uuid', 400),
('00000000-0000-0000-0000-000000000000', 404),
])
def test_project_validation(flask_app, project_id, expected_status, auth_headers):
"""Validation test for error cases"""
client, jwt_token = flask_app
headers = auth_headers(jwt_token)
response = client.get(f'/api/project/{project_id}', headers=headers)
assert response.status_code == expected_status
Available Fixtures
------------------
Core Application Fixtures
~~~~~~~~~~~~~~~~~~~~~~~~~~
``flask_app``
^^^^^^^^^^^^^
Creates a Flask application with SQLite in-memory database and test user.
.. code-block:: python
def test_my_endpoint(flask_app, auth_headers):
client, jwt_token = flask_app
response = client.get(
'/api/projects',
headers=auth_headers(jwt_token)
)
assert response.status_code == 200
**Provides:**
* Test client for making requests
* JWT token for authenticated requests
* SQLite in-memory database
* Test superadmin user
``db_session``
^^^^^^^^^^^^^^
Direct access to the database session within an application context.
.. code-block:: python
def test_database_query(db_session):
from ibutsu_server.db import db
from ibutsu_server.db.models import Project
project = Project(name='test', title='Test Project')
db_session.add(project)
db_session.commit()
# SQLAlchemy 2.0 pattern
found = db.session.execute(
db.select(Project).filter_by(name='test')
).scalar_one_or_none()
assert found is not None
``app_context``
^^^^^^^^^^^^^^^
Application context for operations that need it.
.. code-block:: python
def test_with_context(flask_app, app_context):
from ibutsu_server.db import db
from ibutsu_server.db.models import Project
# Can now query without explicit context manager
projects = db.session.execute(db.select(Project)).scalars().all()
``auth_headers``
^^^^^^^^^^^^^^^^
Factory for creating authenticated request headers.
.. code-block:: python
def test_authenticated_request(flask_app, auth_headers):
client, jwt_token = flask_app
headers = auth_headers(jwt_token)
response = client.get('/api/project', headers=headers)
assert response.status_code == 200
Database Builder Fixtures
~~~~~~~~~~~~~~~~~~~~~~~~~~
These fixtures create database objects with sensible defaults.
``make_project``
^^^^^^^^^^^^^^^^
Factory to create test projects.
.. code-block:: python
def test_with_project(make_project):
project = make_project(name='my-project', title='My Project')
assert project.id is not None
assert project.name == 'my-project'
``make_run``
^^^^^^^^^^^^
Factory to create test runs.
.. code-block:: python
def test_with_run(make_project, make_run):
project = make_project()
run = make_run(
project_id=project.id,
metadata={'build_number': 100, 'env': 'prod'}
)
assert run.id is not None
``make_result``
^^^^^^^^^^^^^^^
Factory to create test results.
.. code-block:: python
def test_with_result(make_project, make_run, make_result):
project = make_project()
run = make_run(project_id=project.id)
result = make_result(
run_id=run.id,
project_id=project.id,
test_id='test.example',
result='passed',
metadata={'component': 'frontend'}
)
assert result.id is not None
``make_user``
^^^^^^^^^^^^^
Factory to create test users.
.. code-block:: python
def test_with_user(make_user):
user = make_user(
email='test@example.com',
name='Test User',
is_superadmin=True
)
assert user.id is not None
``make_artifact``
^^^^^^^^^^^^^^^^^
Factory to create test artifacts.
.. code-block:: python
def test_with_artifact(make_result, make_artifact):
result = make_result()
artifact = make_artifact(
result_id=result.id,
filename='test.log',
content=b'test content'
)
assert artifact.id is not None
``make_import``
^^^^^^^^^^^^^^^
Factory to create import records.
.. code-block:: python
def test_with_import(make_import):
import_record = make_import(
filename='test.xml',
format='junit',
status='done'
)
assert import_record.id is not None
``make_widget_config``
^^^^^^^^^^^^^^^^^^^^^^
Factory to create widget configurations.
.. code-block:: python
def test_with_widget(make_project, make_widget_config):
project = make_project()
widget = make_widget_config(
project_id=project.id,
widget='run-aggregator',
params={'weeks': 4}
)
assert widget.id is not None
``make_group``
^^^^^^^^^^^^^^
Factory to create test groups.
.. code-block:: python
def test_with_group(make_group):
group = make_group(name='test-group')
assert group.id is not None
``make_dashboard``
^^^^^^^^^^^^^^^^^^
Factory to create test dashboards.
.. code-block:: python
def test_with_dashboard(make_project, make_dashboard):
project = make_project()
dashboard = make_dashboard(
project_id=project.id,
title='Test Dashboard'
)
assert dashboard.id is not None
Composite Fixtures
~~~~~~~~~~~~~~~~~~
Composite fixtures create common data hierarchies to reduce boilerplate in tests.
``artifact_test_hierarchy``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Creates project → run → result hierarchy for artifact tests.
.. code-block:: python
def test_delete_artifact(flask_app, artifact_test_hierarchy, auth_headers):
client, jwt_token = flask_app
hierarchy = artifact_test_hierarchy
result = hierarchy["result"]
# result, run, and project are all available
...
``result_test_hierarchy``
^^^^^^^^^^^^^^^^^^^^^^^^^^
Creates project → run → result hierarchy for result controller tests.
.. code-block:: python
def test_update_result(flask_app, result_test_hierarchy, auth_headers):
client, jwt_token = flask_app
hierarchy = result_test_hierarchy
result = hierarchy["result"]
...
``widget_test_hierarchy``
^^^^^^^^^^^^^^^^^^^^^^^^^^
Creates project → widget_config hierarchy for widget tests.
.. code-block:: python
def test_widget_config(flask_app, widget_test_hierarchy, auth_headers):
client, jwt_token = flask_app
hierarchy = widget_test_hierarchy
widget = hierarchy["widget_config"]
project = hierarchy["project"]
...
Widget Test Fixtures
~~~~~~~~~~~~~~~~~~~~
``bulk_run_creator``
^^^^^^^^^^^^^^^^^^^^
Create multiple runs with sequential attributes.
.. code-block:: python
def test_multiple_runs(make_project, bulk_run_creator):
project = make_project()
runs = bulk_run_creator(
count=5,
project_id=project.id,
metadata_pattern=lambda i: {"build": str(i)}
)
assert len(runs) == 5
``bulk_result_creator``
^^^^^^^^^^^^^^^^^^^^^^^
Create multiple results with sequential attributes.
.. code-block:: python
def test_multiple_results(make_run, bulk_result_creator):
run = make_run()
results = bulk_result_creator(
count=10,
run_id=run.id,
project_id=run.project_id,
component="frontend"
)
assert len(results) == 10
``jenkins_run_factory``
^^^^^^^^^^^^^^^^^^^^^^^
Factory for creating Jenkins-style runs with standardized metadata.
.. code-block:: python
def test_jenkins_widget(make_project, jenkins_run_factory):
project = make_project()
run = jenkins_run_factory(
job_name="my-job",
build_number="100",
project_id=project.id
)
assert run.metadata["jenkins"]["job_name"] == "my-job"
Test Patterns
-------------
Parametrization Patterns
~~~~~~~~~~~~~~~~~~~~~~~~
Parametrization reduces code duplication by running the same test logic with different inputs.
**Pattern 1: Validation Test Parametrization**
.. code-block:: python
@pytest.mark.validation
@pytest.mark.parametrize(
("input_id", "expected_status", "description"),
[
("not-a-uuid", 400, "Invalid UUID format triggers validation error"),
("00000000-0000-0000-0000-000000000000", 404, "Valid UUID but not found"),
],
)
def test_endpoint_validation_errors(flask_app, input_id, expected_status, description, auth_headers):
"""Test validation errors for endpoint - parametrized"""
client, jwt_token = flask_app
headers = auth_headers(jwt_token)
response = client.get(f'/api/resource/{input_id}', headers=headers)
assert response.status_code == expected_status, description
**Pattern 2: Pagination Test Parametrization**
.. code-block:: python
@pytest.mark.integration
@pytest.mark.parametrize(
("page", "page_size"),
[
(1, 25),
(2, 10),
(1, 56),
],
)
def test_list_pagination(flask_app, make_project, page, page_size, auth_headers):
"""Test list endpoint with different pagination parameters"""
client, jwt_token = flask_app
# Create test data
for i in range(30):
make_project(name=f"project-{i}")
query_string = [("page", page), ("pageSize", page_size)]
headers = auth_headers(jwt_token)
response = client.get("/api/project", headers=headers, params=query_string)
assert response.status_code == 200
response_data = response.json()
assert response_data["pagination"]["page"] == page
assert response_data["pagination"]["pageSize"] == page_size
**Pattern 3: Lambda Builder Pattern**
When parametrized tests need different setup logic:
.. code-block:: python
@pytest.mark.validation
@pytest.mark.parametrize(
("data_builder", "needs_hierarchy", "expected_status", "expected_error"),
[
(lambda h: {"resultId": str(h["result"].id)}, True, 400, "no file uploaded"),
(lambda _: {"resultId": "not-a-uuid"}, False, 400, "uuid format"),
],
)
def test_upload_validation(
flask_app, artifact_test_hierarchy, data_builder,
needs_hierarchy, expected_status, expected_error, auth_headers
):
"""Test upload validation with conditional setup"""
client, jwt_token = flask_app
hierarchy = artifact_test_hierarchy if needs_hierarchy else {}
data = data_builder(hierarchy)
headers = auth_headers(jwt_token)
response = client.post("/api/artifact/upload", data=data, headers=headers)
assert response.status_code == expected_status
assert expected_error in response.text.lower()
Arrange-Act-Assert Pattern
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Organize tests into three clear sections:
.. code-block:: python
def test_create_project(flask_app, make_group, auth_headers):
"""Test project creation"""
client, jwt_token = flask_app
# Arrange - Set up test data
group = make_group(name="test-group")
project_data = {
"name": "my-project",
"title": "My Project",
"group_id": str(group.id),
}
# Act - Execute the operation
headers = auth_headers(jwt_token)
response = client.post("/api/project", headers=headers, json=project_data)
# Assert - Verify the results
assert response.status_code == 201
assert response.json()["name"] == "my-project"
# Additional verification in database
with client.application.app_context():
from ibutsu_server.db.models import Project
project = Project.query.filter_by(name="my-project").first()
assert project is not None
SQLAlchemy 2.0 Patterns
-----------------------
This project uses SQLAlchemy 2.0 patterns. Here are the key differences from legacy patterns:
Querying Records
~~~~~~~~~~~~~~~~
**Legacy Pattern (Deprecated):**
.. code-block:: python
# Don't use Model.query.get() - deprecated in SQLAlchemy 2.0
user = User.query.get(user_id)
project = Project.query.filter_by(name='test').first()
**SQLAlchemy 2.0 Pattern:**
.. code-block:: python
from ibutsu_server.db import db
# Get by primary key
user = db.session.get(User, user_id)
# Query with filter
project = db.session.execute(
db.select(Project).filter_by(name='test')
).scalar_one_or_none()
# Get all records
results = db.session.execute(db.select(Result)).scalars().all()
Counting Records
~~~~~~~~~~~~~~~~
**SQLAlchemy 2.0 Pattern:**
.. code-block:: python
# Use .subquery() to convert query to subquery
query = db.select(Project)
total = db.session.execute(
db.select(db.func.count()).select_from(query.subquery())
).scalar()
Best Practices
--------------
Do's
~~~~
* **Use fixture builders for test data** - ``make_project``, ``make_run``, etc.
* **Test real database state** - Verify objects actually exist in the database
* **Use descriptive test names** - ``test_get_importance_component_with_empty_results``
* **Keep tests focused and atomic** - One assertion per logical behavior
* **Use parametrization** - Reduce code duplication for similar test cases
* **Add appropriate markers** - ``@pytest.mark.integration``, ``@pytest.mark.validation``
Don'ts
~~~~~~
* **Don't mock database operations** - Use real SQLite in-memory database
* **Don't create complex mock chains** - Use builder fixtures instead
* **Don't skip tests due to mocking difficulty** - Use integration approach
* **Don't test implementation details** - Test behavior, not internal calls
* **Don't over-use composite fixtures** - Only create for 3+ tests
Coverage Requirements
---------------------
* **Target:** 80% line coverage for all modules
* **Run:** ``hatch run test-cov`` to verify coverage
* **Report:** Coverage reports generated in ``htmlcov/`` and ``coverage.xml``
.. code-block:: bash
# Run with coverage for specific module
hatch run test-cov -- --cov-report=term-missing --cov=ibutsu_server.widgets.importance_component
When to Use Each Pattern
------------------------
+----------------------------+----------------------------------------+
| Pattern | Use When |
+============================+========================================+
| Parametrization | Testing same logic with different |
| | inputs (validation, pagination) |
+----------------------------+----------------------------------------+
| Composite Fixtures | Multiple tests need same data |
| | hierarchy (3+ tests) |
+----------------------------+----------------------------------------+
| Lambda Builders | Parametrized tests need conditional |
| | setup |
+----------------------------+----------------------------------------+
| Integration Marker | Tests interact with database or |
| | multiple components |
+----------------------------+----------------------------------------+
| Validation Marker | Tests verify input validation or |
| | error handling |
+----------------------------+----------------------------------------+
| Unit Marker | Tests focus on single function with |
| | mocked dependencies |
+----------------------------+----------------------------------------+
Checklist for New Tests
-----------------------
Before writing a new test:
1. Can I parametrize an existing test instead?
2. Is there a composite fixture I can use?
3. Should I create a new composite fixture? (used 3+ times?)
4. Have I added appropriate pytest markers?
5. Does my test follow the Arrange-Act-Assert pattern?
6. Am I using builder fixtures instead of API calls for setup?
7. Have I verified results in the database where appropriate?
Troubleshooting
---------------
"No application context"
~~~~~~~~~~~~~~~~~~~~~~~~
**Problem:** Trying to access database outside of application context.
**Solution:** Use ``app_context`` fixture or wrap in context manager:
.. code-block:: python
def test_something(flask_app):
client, _ = flask_app
with client.application.app_context():
# Database operations here
...
"Foreign key constraint failed"
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem:** Creating objects without required relationships.
**Solution:** Ensure parent objects exist first:
.. code-block:: python
# Good
project = make_project()
run = make_run(project_id=project.id)
result = make_result(run_id=run.id, project_id=project.id)
"Test data leaking between tests"
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem:** Data from one test appearing in another.
**Solution:** Each test gets a fresh database with ``flask_app`` fixture.
Tests are slow
~~~~~~~~~~~~~~
**Solution:**
1. Use builder fixtures instead of API calls for setup
2. Run tests in parallel: ``hatch run test -n auto``
3. Create minimal test data - only what's needed
References
----------
* `Flask Testing Documentation `_
* `pytest Documentation `_
* `pytest parametrize documentation `_
* `pytest fixtures documentation `_
* `SQLAlchemy Testing `_
* Backend ``AGENTS.md`` - Testing guidelines for AI agents