Automate Django Tests in GitLab CI with Docker

Blog / Server Management · July 2, 2020 · Updated June 10, 2026 · 7 min read
Automate Django Tests in GitLab CI with Docker

To automate Django tests in a self-hosted GitLab CI setup, define a .gitlab-ci.yml that runs your suite inside a python:3.13 Docker image, attaches a postgres:16 service for an ephemeral test database, caches dependencies on your lockfile, and runs pytest -n auto (or python manage.py test --parallel) with coverage. A coverage: regex plus a Cobertura coverage_report artifact then surface line coverage on every merge request. Everything runs on a GitLab Runner using the docker executor, so each pipeline gets a clean, isolated environment.

This guide focuses on the testing pipeline. For building and pushing Docker images or deploying, see our companion GitLab CI/CD with Docker guide.

Key takeaways

  • Attach a services: postgres:16 container for a throwaway test database — never test against a shared database.
  • Pin image: python:3.13-slim and cache pip/uv on the lockfile hash so installs stay fast.
  • Run tests with pytest -n auto (pytest-xdist) or python manage.py test --parallel to use every runner core.
  • Gate merge requests with a coverage: regex and a Cobertura coverage_report for inline MR annotations.
  • Use rules:/workflow: (not the legacy only/except) and needs: to build a fast, fail-fast DAG.
  • Run the docker executor on a runner host separate from your GitLab instance for isolation and security.

What you need

  • A self-hosted GitLab instance (Community or Enterprise Edition) with at least one project.
  • A GitLab Runner registered with the docker executor (covered below).
  • A Django project with a test suite and a dependency lockfile (requirements.txt, uv.lock, or poetry.lock).
  • Docker installed on the runner host.

You do not need Docker-in-Docker (DinD) just to run tests — DinD is only required when you build container images. If your pipeline also builds images, see setting up the GitLab Container Registry on your own domain.

Configure Django and pytest for CI

Point Django at the Postgres service via a DATABASE_URL, and keep a dedicated, fast settings module for tests. Reading the database URL from the environment keeps the same settings working locally and in CI:

# myproject/settings/test.py
from .base import *  # noqa: F401,F403
import dj_database_url

# Connect to the Postgres service container started by GitLab CI.
DATABASES = {
    "default": dj_database_url.config(
        default="postgresql://app:app@postgres:5432/app_test",
        conn_max_age=600,
    )
}

# Make tests fast and deterministic.
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
DEBUG = False

Centralise the pytest and coverage configuration in pyproject.toml so both local runs and CI behave identically. --reuse-db speeds up local reruns; CI builds a fresh database each pipeline anyway:

# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"
python_files = ["tests.py", "test_*.py", "*_tests.py"]
addopts = "--reuse-db --strict-markers --cov=myproject --cov-report=term-missing --cov-report=xml --junitxml=report.xml"

[tool.coverage.run]
source = ["myproject"]
branch = true
omit = ["*/migrations/*", "*/tests/*", "manage.py"]

[tool.coverage.report]
show_missing = true
fail_under = 85

The .gitlab-ci.yml for Django tests

The pipeline below uses python:3.13-slim, attaches a postgres:16 service, caches dependencies on the lockfile, and runs the suite with coverage. The workflow: block prevents duplicate pipelines, and rules: (not the deprecated only/except) decide when jobs run. lint runs first; test starts the moment it passes thanks to needs:.

stages:
  - lint
  - test

# Build pipelines for merge requests and the default branch only.
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: never

default:
  image: python:3.13-slim
  tags:
    - docker            # must match a tag on your self-hosted runner

variables:
  POSTGRES_DB: app_test
  POSTGRES_USER: app
  POSTGRES_PASSWORD: app
  POSTGRES_HOST_AUTH_METHOD: trust
  DATABASE_URL: "postgresql://app:app@postgres:5432/app_test"
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

# Cache wheels and the virtualenv, keyed on the lockfile.
cache:
  key:
    files:
      - requirements.txt
  paths:
    - .cache/pip
    - .venv/

.python:
  before_script:
    - python -m venv .venv
    - source .venv/bin/activate
    - pip install -r requirements.txt

lint:
  stage: lint
  extends: .python
  script:
    - ruff check .
    - ruff format --check .

test:
  stage: test
  extends: .python
  needs: ["lint"]          # DAG: run as soon as lint passes
  services:
    - name: postgres:16
      alias: postgres
  script:
    - python manage.py migrate --noinput
    - pytest -n auto        # coverage flags live in pyproject.toml
  coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
  artifacts:
    when: always
    expire_in: 1 week
    paths:
      - coverage.xml
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

How the Postgres service works

GitLab starts each entry under services: as a sibling container on the same network as your job. The service is reachable by its image name (or alias) as a hostname — here postgres:5432. The POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD variables initialise the database, and Django connects through DATABASE_URL. Because the container is destroyed when the job ends, every pipeline gets a fresh, ephemeral database with no leftover state.

If your tests occasionally race the database on startup, add a readiness check to before_script: until pg_isready -h postgres; do sleep 1; done.

Coverage reports and gates

Two mechanisms work together to surface coverage on merge requests:

Mechanism What it does Where it shows
coverage: regex Captures one total percentage from the log Pipeline & MR widget, badges
coverage_report (Cobertura) Per-line data from coverage.xml Inline annotations in the MR diff

Enforce a minimum so a drop fails the job. With pytest-cov, the fail_under = 85 in pyproject.toml (or --cov-fail-under=85) blocks the merge; with plain coverage, run coverage report --fail-under=85. Mirror coverage to an external dashboard with Coveralls for your Django project.

Speed it up: parallel tests and a version matrix

Run tests across CPU cores and across Python/Django versions:

  • Within a job: pytest -n auto (via pytest-xdist) or python manage.py test --parallel. Each worker gets its own database (app_test_gw0, app_test_gw1, …) against the same Postgres service, so they don't collide.
  • Across jobs: use parallel: matrix: to fan out a test matrix — GitLab spawns one job per combination and runs them concurrently.
test:
  stage: test
  image: python:$PYTHON-slim
  services:
    - name: postgres:16
      alias: postgres
  parallel:
    matrix:
      - PYTHON: ["3.12", "3.13"]
        DJANGO: ["4.2", "5.2"]
  before_script:
    - pip install -r requirements.txt
    - pip install "Django~=$DJANGO.0"   # override the pinned version per matrix cell
  script:
    - pytest -n auto

Self-hosted GitLab Runner setup

Register a runner with the docker executor so each job runs in a clean container. The --tag-list here matches the tags: [docker] in the pipeline. Since GitLab 16.0 the legacy --registration-token flow is deprecated — create the runner in the UI and use the generated authentication token (glrt-…). Add --docker-privileged only if a job needs Docker-in-Docker to build images.

# Install GitLab Runner on the runner host (Debian/Ubuntu)
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install -y gitlab-runner

# Register with the docker executor (GitLab 16.0+ uses an authentication token)
sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.example.com/" \
  --token "$RUNNER_AUTHENTICATION_TOKEN" \
  --executor "docker" \
  --docker-image "python:3.13-slim" \
  --tag-list "docker"

unittest (Django test runner) vs pytest-django

Aspect manage.py test (unittest) pytest-django
Setup Built in, zero extra deps Add pytest, pytest-django
Syntax TestCase classes Plain functions + fixtures
Parallelism --parallel -n auto (pytest-xdist)
Fixtures setUp / fixtures @pytest.fixture, factory_boy
Local DB reuse --keepdb --reuse-db
Coverage coverage run pytest-cov (--cov)
Plugin ecosystem Limited Large

Both run fine in the pipeline above — just swap the script: line. pytest-django is the more common choice in 2026 for its fixtures and plugins.

Testing best practices for CI

  • Ephemeral DB per pipeline. Use the services: Postgres so no run touches shared data.
  • Keep tests fast. Cheap password hashers, locmem email, and --reuse-db/--keepdb locally — CI builds a fresh database each time.
  • Use factories, not fixture dumps. factory_boy keeps test data readable and isolated. See Django unit test cases with forms and views.
  • Fail fast. Put lint before test with needs: so obvious mistakes stop the pipeline early.
  • Gate coverage. fail_under (or --cov-fail-under) blocks merges that lower coverage.
  • Isolate the runner. Keep the docker-executor runner on a separate host from your GitLab instance.

Need help wiring this across many repositories? Our software testing services team sets up CI test pipelines, coverage gates, and flaky-test triage.

Frequently Asked Questions

Do I need Docker-in-Docker to run Django tests in GitLab CI?

No. Running tests only needs the docker executor, which launches your job inside a python:3.13 container with a Postgres service alongside it. Docker-in-Docker (DinD) is required only when the pipeline builds container images — see our GitLab CI/CD with Docker guide for that.

How do I connect Django to the Postgres service container?

Reference the service by its image name or alias as the hostname. With postgres:16 aliased to postgres, set DATABASE_URL=postgresql://app:app@postgres:5432/app_test. GitLab puts the service on the same network as the job, so the hostname resolves automatically.

How do I run Django tests in parallel?

Use pytest -n auto (pytest-xdist) or python manage.py test --parallel. Each worker creates its own database (app_test_gw0, app_test_gw1, …) against the same Postgres service, so they don't collide. Use parallel: matrix: to also fan tests out across Python and Django versions.

How do I show test coverage on merge requests?

Add a coverage: regex to capture the total percentage for the MR widget, and upload a Cobertura coverage_report artifact built from coverage.xml for inline diff annotations. Enforce a floor with fail_under, --cov-fail-under, or coverage report --fail-under.

Should I use the Django test runner or pytest-django?

Both work in the same pipeline. The built-in runner needs no extra dependencies and supports --parallel. pytest-django adds fixtures, factory_boy integration, -n auto parallelism, and a large plugin ecosystem — it is the more popular choice in 2026.

Why register the runner on a separate host from GitLab?

Test jobs run arbitrary project code. Keeping the docker-executor runner off your GitLab instance limits the blast radius if a job is compromised, and stops heavy test load from affecting Git operations, CI scheduling, and the web UI.

Share this article