Skip to main content
QFieldCloud has a comprehensive test suite covering unit tests, integration tests, and API tests.

Test Framework

QFieldCloud uses Django’s built-in test framework with a custom test runner:
  • Test Runner: QfcTestSuiteRunner (see docker-app/qfieldcloud/testing.py)
  • Database: Separate test database created automatically
  • Isolation: Each test runs in a transaction that’s rolled back

Setup Test Environment

Configure for Testing

Add the test override file to your COMPOSE_FILE in .env:
export COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.test.yml

Rebuild Stack

Rebuild to install test dependencies from requirements_test.txt:
docker compose up -d --build
docker compose run app python manage.py migrate
docker compose run app python manage.py collectstatic --noinput

Running Tests

Run All Tests

docker compose run app python manage.py test --keepdb
The --keepdb flag preserves the test database between runs for faster execution.

Run Specific Test Module

Test a specific module (e.g., permissions):
docker compose run app python manage.py test --keepdb qfieldcloud.core.tests.test_permission

Run Specific Test Case

Test a specific test class:
docker compose run app python manage.py test --keepdb qfieldcloud.core.tests.test_permission.QfcTestCase

Run Specific Test Method

Test a single test method:
docker compose run app python manage.py test --keepdb qfieldcloud.core.tests.test_permission.QfcTestCase.test_collaborator_project_takeover

Test Structure

Tests are organized by Django app:
docker-app/qfieldcloud/
├── core/tests/
│   ├── test_api.py              # API endpoint tests
│   ├── test_permission.py       # Permission tests
│   ├── test_project.py          # Project model tests
│   ├── test_jobs.py             # Job processing tests
│   ├── test_delta.py            # Delta sync tests
│   └── ...
├── authentication/tests/
│   ├── test_authentication.py   # Auth backend tests
│   └── test_list_auth_providers.py
├── filestorage/tests/
│   ├── test_files_api.py        # File API tests
│   ├── test_storage_usage.py   # Storage quota tests
│   ├── test_webdav.py          # WebDAV backend tests
│   └── ...
├── subscription/tests/
│   ├── test_subscription.py     # Subscription tests
│   ├── test_package.py         # Package tests
│   └── ...
└── notifs/tests/
    └── test_notifs.py           # Notification tests

Writing Tests

Basic Test Example

from django.test import TestCase
from qfieldcloud.core.models import User, Project

class ProjectTestCase(TestCase):
    def setUp(self):
        """Create test fixtures"""
        self.user = User.objects.create_user(
            username='testuser',
            email='[email protected]',
            password='testpass123'
        )
        self.project = Project.objects.create(
            name='Test Project',
            owner=self.user
        )
    
    def test_project_creation(self):
        """Test that a project can be created"""
        self.assertEqual(self.project.name, 'Test Project')
        self.assertEqual(self.project.owner, self.user)
    
    def test_project_str(self):
        """Test project string representation"""
        expected = f'{self.user.username}/{self.project.name}'
        self.assertEqual(str(self.project), expected)

API Test Example

from rest_framework.test import APITestCase
from rest_framework import status
from qfieldcloud.core.models import User

class ProjectAPITestCase(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser',
            password='testpass123'
        )
        self.client.force_authenticate(user=self.user)
    
    def test_create_project(self):
        """Test creating a project via API"""
        url = '/api/v1/projects/'
        data = {
            'name': 'My Test Project',
            'description': 'A test project'
        }
        response = self.client.post(url, data, format='json')
        
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data['name'], 'My Test Project')
    
    def test_list_projects_requires_auth(self):
        """Test that listing projects requires authentication"""
        self.client.force_authenticate(user=None)
        response = self.client.get('/api/v1/projects/')
        
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

Testing with Files

from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework.test import APITestCase

class FileUploadTestCase(APITestCase):
    def test_upload_qgis_file(self):
        # Create a mock QGIS project file
        qgs_content = b'<?xml version="1.0" encoding="utf-8"?>'
        qgs_file = SimpleUploadedFile(
            "project.qgs",
            qgs_content,
            content_type="application/x-qgis-project"
        )
        
        # Upload via API
        url = f'/api/v1/files/{self.project.id}/project.qgs'
        response = self.client.post(url, {'file': qgs_file})
        
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

Testing Permissions

from qfieldcloud.core.models import Project, ProjectCollaborator
from qfieldcloud.core.models import ProjectCollaborator as PC

class PermissionTestCase(APITestCase):
    def test_editor_cannot_delete_project(self):
        """Test that an editor cannot delete a project"""
        # Create owner and editor
        owner = User.objects.create_user(username='owner')
        editor = User.objects.create_user(username='editor')
        
        # Create project
        project = Project.objects.create(name='Test', owner=owner)
        
        # Add editor as collaborator
        ProjectCollaborator.objects.create(
            project=project,
            collaborator=editor,
            role=PC.Roles.EDITOR
        )
        
        # Try to delete as editor
        self.client.force_authenticate(user=editor)
        response = self.client.delete(f'/api/v1/projects/{project.id}/')
        
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

Test Coverage

Generate Coverage Report

Run tests with coverage:
docker compose exec app coverage run manage.py test --keepdb

View Coverage Report

Display coverage in terminal:
docker compose exec app coverage report
Example output:
Name                                      Stmts   Miss  Cover
-------------------------------------------------------------
qfieldcloud/core/models.py                 450     12    97%
qfieldcloud/core/views.py                  380     25    93%
qfieldcloud/authentication/backends.py      85      8    91%
-------------------------------------------------------------
TOTAL                                     3421    156    95%

Generate HTML Coverage Report

docker compose exec app coverage html
This creates an HTML report in htmlcov/. You can view it by opening htmlcov/index.html in a browser.

Coverage Configuration

Coverage settings are in docker-app/.coveragerc.

Parallel Test Instance

You can run a test instance in parallel with your development instance.

Create .env.test

ENVIRONMENT=test
QFIELDCLOUD_HOST=nginx
DJANGO_SETTINGS_MODULE=qfieldcloud.settings
STORAGE_ENDPOINT_URL=http://172.17.0.1:8109
MINIO_API_PORT=8109
MINIO_BROWSER_PORT=8110
WEB_HTTP_PORT=8101
WEB_HTTPS_PORT=8102
HOST_POSTGRES_PORT=8103
QFIELDCLOUD_DEFAULT_NETWORK=qfieldcloud_test_default
QFIELDCLOUD_SUBSCRIPTION_MODEL=subscription.Subscription
DJANGO_DEV_PORT=8111
SMTP4DEV_WEB_PORT=8112
SMTP4DEV_SMTP_PORT=8125
SMTP4DEV_IMAP_PORT=8143
COMPOSE_PROJECT_NAME=qfieldcloud_test
COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.test.yml
DEBUG_APP_DEBUGPY_PORT=5781
DEBUG_WORKER_WRAPPER_DEBUGPY_PORT=5780

Build Test Stack

docker compose --env-file .env --env-file .env.test up -d --build
docker compose --env-file .env --env-file .env.test run app python manage.py migrate
docker compose --env-file .env --env-file .env.test run app python manage.py collectstatic --noinput

Run Tests on Test Stack

docker compose --env-file .env --env-file .env.test run app python manage.py test --keepdb

Test Database

Test Database Configuration

The test database is automatically created with the name defined in POSTGRES_DB_TEST environment variable. In settings.py:218-221:
"TEST": {
    "NAME": os.environ.get("POSTGRES_DB_TEST"),
}

Access Test Database

Update your ~/.pg_service.conf:
[test.localhost.qfield.cloud]
host=localhost
dbname=test_qfieldcloud_db
user=qfieldcloud_db_admin
port=5433
password=3shJDd2r7Twwkehb
sslmode=disable
Connect:
psql 'service=test.localhost.qfield.cloud'

Best Practices

1. Use setUp() and tearDown()

Create fixtures in setUp(), clean up in tearDown():
def setUp(self):
    self.user = User.objects.create_user(username='test')
    
def tearDown(self):
    # Usually not needed - Django handles cleanup
    pass

2. Use --keepdb for Speed

Always use --keepdb to avoid recreating the test database:
python manage.py test --keepdb

3. Test One Thing at a Time

Each test method should test one specific behavior:
def test_user_can_create_project(self):
    # Test only project creation
    
def test_user_can_update_project_name(self):
    # Test only name update

4. Use Descriptive Test Names

Test names should describe what they test:
def test_editor_cannot_delete_files(self):  # Good
def test_permissions(self):  # Too vague

5. Use Assertions Effectively

self.assertEqual(actual, expected)
self.assertTrue(condition)
self.assertFalse(condition)
self.assertRaises(Exception, callable)
self.assertIn(item, container)
self.assertIsNone(value)
self.assertIsNotNone(value)

6. Mock External Services

Use unittest.mock for external dependencies:
from unittest.mock import patch, MagicMock

@patch('qfieldcloud.core.tasks.send_email')
def test_notification_sent(self, mock_send_email):
    # Test logic
    mock_send_email.assert_called_once()

Continuous Integration

QFieldCloud tests are run on every pull request. Ensure:
  1. All tests pass locally before pushing
  2. New features include tests
  3. Test coverage doesn’t decrease
  4. Tests run in reasonable time

Next Steps

Build docs developers (and LLMs) love