GDPR Compliance Testing: Automated Tests for Data Privacy Requirements

GDPR Compliance Testing: Automated Tests for Data Privacy Requirements

GDPR is not a checkbox. It is a set of behavioral requirements that your software must continuously satisfy — and "continuously" is the key word. A manual audit once a year does not protect you. A compliance review before a major release does not protect you. What protects you is automated tests that run on every pull request and fail loudly when something regresses.

This post is about how to write those tests. Not abstract principles — actual test code covering the most common GDPR obligations.

What GDPR Actually Requires From Your Software

Before writing tests, you need to know what you are testing. GDPR imposes a concrete set of obligations on any system that processes personal data of EU residents:

  • Right of access (Article 15): Users can request all data you hold about them.
  • Right to erasure (Article 17): Users can demand deletion of their data.
  • Right to portability (Article 20): Users can export their data in a machine-readable format.
  • Consent (Article 7): Processing must be based on a lawful basis; consent must be freely given, specific, and withdrawable.
  • Data minimization (Article 5): You may only collect what is necessary.
  • Breach notification (Article 33): Breaches must be reported to authorities within 72 hours.

Each of these has a testable surface. Let us go through them.

Testing the Right to Erasure

The deletion endpoint is usually the highest-risk area. Developers add a "delete account" button, but they forget the audit log table, the analytics events table, the backup replica, or the email marketing integration.

# tests/gdpr/test_right_to_erasure.py
import pytest
import requests
from datetime import datetime

BASE_URL = "https://api.example.com"

class TestRightToErasure:
    def setup_method(self):
        # Create a test user with known personal data
        resp = requests.post(f"{BASE_URL}/users", json={
            "email": "gdpr-test@example.com",
            "name": "GDPR Test User",
            "phone": "+1234567890"
        })
        assert resp.status_code == 201
        self.user = resp.json()
        self.user_id = self.user["id"]
        self.token = self._get_token("gdpr-test@example.com")

    def test_deletion_removes_pii_from_primary_database(self):
        """After deletion, GET /users/:id must return 404 or anonymized data."""
        delete_resp = requests.delete(
            f"{BASE_URL}/users/{self.user_id}",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        assert delete_resp.status_code in (200, 204)

        # Direct lookup must fail
        get_resp = requests.get(
            f"{BASE_URL}/users/{self.user_id}",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        assert get_resp.status_code == 404

    def test_deletion_removes_pii_from_audit_logs(self):
        """Audit logs may retain an event record but must not contain raw PII."""
        # Create some activity to generate audit entries
        requests.get(
            f"{BASE_URL}/users/{self.user_id}/profile",
            headers={"Authorization": f"Bearer {self.token}"}
        )

        # Delete the user
        requests.delete(
            f"{BASE_URL}/users/{self.user_id}",
            headers={"Authorization": f"Bearer {self.token}"}
        )

        # Check audit log — this requires an admin endpoint
        admin_token = self._get_admin_token()
        audit_resp = requests.get(
            f"{BASE_URL}/admin/audit-log?user_id={self.user_id}",
            headers={"Authorization": f"Bearer {admin_token}"}
        )
        assert audit_resp.status_code == 200
        events = audit_resp.json()["events"]

        for event in events:
            # Event can exist, but must not contain raw email or name
            assert "gdpr-test@example.com" not in str(event)
            assert "GDPR Test User" not in str(event)
            assert "+1234567890" not in str(event)

    def test_deletion_is_completed_within_30_days(self):
        """GDPR requires erasure without undue delay — test that a deletion
        request is acknowledged and a job is scheduled."""
        delete_resp = requests.delete(
            f"{BASE_URL}/users/{self.user_id}",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        body = delete_resp.json()

        # At minimum, the response must confirm the request was received
        assert "deletion_scheduled" in body or "deleted_at" in body
        if "deletion_scheduled" in body:
            assert body["deletion_scheduled"] is True

    def test_deletion_propagates_to_email_provider(self):
        """Third-party marketing lists must also be cleared."""
        admin_token = self._get_admin_token()
        requests.delete(
            f"{BASE_URL}/users/{self.user_id}",
            headers={"Authorization": f"Bearer {self.token}"}
        )

        # Your app should have an endpoint that reports third-party sync status
        sync_resp = requests.get(
            f"{BASE_URL}/admin/gdpr/deletion-status/{self.user_id}",
            headers={"Authorization": f"Bearer {admin_token}"}
        )
        assert sync_resp.status_code == 200
        status = sync_resp.json()
        assert status.get("email_provider_deleted") is True

Testing the Right of Access (Subject Access Request)

class TestSubjectAccessRequest:
    def test_sar_returns_all_data_categories(self):
        """SAR response must cover every table that stores personal data."""
        sar_resp = requests.post(
            f"{BASE_URL}/users/{self.user_id}/data-export",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        assert sar_resp.status_code == 200
        export = sar_resp.json()

        # Every category your DPA lists must appear
        required_categories = [
            "profile",
            "activity_log",
            "payment_history",
            "consent_records",
            "communications"
        ]
        for category in required_categories:
            assert category in export, f"SAR missing category: {category}"

    def test_sar_response_is_machine_readable(self):
        """Article 20 requires a structured, commonly used, machine-readable format."""
        sar_resp = requests.post(
            f"{BASE_URL}/users/{self.user_id}/data-export",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        # Must be JSON or CSV — not a PDF scan
        content_type = sar_resp.headers.get("Content-Type", "")
        assert "application/json" in content_type or "text/csv" in content_type

    def test_sar_is_delivered_within_30_days(self):
        """Test that the job is queued and has a reasonable deadline."""
        sar_resp = requests.post(
            f"{BASE_URL}/users/{self.user_id}/data-export",
            headers={"Authorization": f"Bearer {self.token}"}
        )
        body = sar_resp.json()
        assert "request_id" in body
        assert "expected_completion" in body

        from datetime import datetime, timedelta
        completion = datetime.fromisoformat(body["expected_completion"])
        assert completion <= datetime.utcnow() + timedelta(days=30)

Consent flows are frequently broken by A/B tests, marketing replatforming, or simple copy-paste errors. Automated checks catch these regressions.

# tests/gdpr/test_consent.py
import pytest
from playwright.sync_api import sync_playwright

class TestConsentFlow:
    def test_consent_banner_appears_for_new_users(self):
        """First visit must show consent dialog before any tracking."""
        with sync_playwright() as p:
            browser = p.chromium.launch()
            context = browser.new_context()
            page = context.new_page()

            # Intercept analytics calls
            analytics_calls = []
            page.on("request", lambda req: analytics_calls.append(req.url)
                    if "analytics" in req.url or "segment" in req.url else None)

            page.goto("https://app.example.com")

            # Consent banner must be visible
            banner = page.locator("[data-testid='consent-banner']")
            assert banner.is_visible()

            # No analytics must fire before consent is given
            page.wait_for_timeout(2000)
            assert len(analytics_calls) == 0, \
                f"Analytics fired before consent: {analytics_calls}"

            browser.close()

    def test_rejecting_consent_disables_non_essential_cookies(self):
        """Reject all must result in only strictly necessary cookies."""
        with sync_playwright() as p:
            browser = p.chromium.launch()
            context = browser.new_context()
            page = context.new_page()
            page.goto("https://app.example.com")

            # Reject all non-essential cookies
            page.click("[data-testid='reject-all-cookies']")
            page.wait_for_timeout(1000)

            cookies = context.cookies()
            for cookie in cookies:
                # Only session and CSRF cookies are allowed without consent
                assert cookie["name"] in ("session", "csrf_token", "_gh_sess"), \
                    f"Non-essential cookie set after rejection: {cookie['name']}"

            browser.close()

    def test_consent_withdrawal_stops_tracking(self):
        """User must be able to withdraw previously given consent."""
        with sync_playwright() as p:
            browser = p.chromium.launch()
            context = browser.new_context()
            page = context.new_page()
            page.goto("https://app.example.com")

            # Accept all
            page.click("[data-testid='accept-all-cookies']")
            page.wait_for_timeout(500)

            # Now withdraw
            page.goto("https://app.example.com/privacy/consent")
            page.click("[data-testid='withdraw-all-consent']")
            page.click("[data-testid='save-preferences']")

            # Navigate and verify no analytics fires
            analytics_calls = []
            page.on("request", lambda req: analytics_calls.append(req.url)
                    if "analytics" in req.url else None)
            page.goto("https://app.example.com/dashboard")
            page.wait_for_timeout(2000)

            assert len(analytics_calls) == 0, \
                "Analytics still firing after consent withdrawal"

            browser.close()

Testing Data Minimization

Data minimization is harder to test automatically but you can write schema-level assertions that enforce it:

# tests/gdpr/test_data_minimization.py
import requests

SENSITIVE_FIELDS_REQUIRING_JUSTIFICATION = [
    "date_of_birth",
    "gender",
    "nationality",
    "ip_address",
    "device_fingerprint",
    "precise_location",
]

class TestDataMinimization:
    def test_registration_does_not_collect_unnecessary_fields(self):
        """Registration response must not return fields that were not asked for."""
        resp = requests.post(f"{BASE_URL}/users", json={
            "email": "min-test@example.com",
            "password": "securepassword123"
        })
        assert resp.status_code == 201
        user_data = resp.json()

        # These fields should not appear unless the product explicitly needs them
        unexpected = [f for f in SENSITIVE_FIELDS_REQUIRING_JUSTIFICATION
                      if f in user_data]
        assert not unexpected, \
            f"Registration response contains unnecessary fields: {unexpected}"

    def test_api_does_not_leak_pii_in_error_messages(self):
        """Error responses must not echo back personal data."""
        # Try to register with an existing email
        requests.post(f"{BASE_URL}/users", json={
            "email": "existing@example.com",
            "password": "password"
        })
        resp = requests.post(f"{BASE_URL}/users", json={
            "email": "existing@example.com",
            "password": "password"
        })
        assert resp.status_code == 409
        error_body = resp.text
        # Error must not echo the email back in a way that confirms existence
        # (user enumeration also a GDPR-relevant concern)
        assert "existing@example.com" not in error_body

Integrating Into CI

Wire these tests into your pipeline with a dedicated GDPR stage:

# .github/workflows/gdpr-compliance.yml
name: GDPR Compliance Tests

on:
  pull_request:
    paths:
      - "src/users/**"
      - "src/consent/**"
      - "src/data-export/**"
      - "migrations/**"

jobs:
  gdpr-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install dependencies
        run: pip install pytest requests playwright pytest-html
      - name: Install Playwright browsers
        run: playwright install chromium
      - name: Run GDPR compliance tests
        run: pytest tests/gdpr/ -v --html=reports/gdpr-report.html
      - name: Upload compliance report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: gdpr-compliance-report
          path: reports/gdpr-report.html

Trigger this suite specifically on changes to any code that touches user data, consent, or migrations. A failing GDPR test is a merge blocker — configure your branch protection rules accordingly.

What Automated Tests Cannot Cover

Some GDPR obligations are procedural, not software:

  • DPA agreements with sub-processors — contractual, not testable in code
  • Data retention schedules — verify these exist in a policy document, but automate the enforcement with scheduled jobs and tests that verify those jobs run
  • 72-hour breach notification — requires an incident response runbook, not a test

The distinction matters. Do not skip automated tests because "GDPR is about process." The process depends on your software doing the right thing, and your software will regress without tests.

Building a Compliance Test Suite

Start with the highest-risk areas: deletion, export, and consent. Add a test every time you find a gap — either through manual audit or a real user complaint. Within a few sprints you will have a suite that covers your entire GDPR surface and runs in under five minutes.

The goal is not 100% coverage of every article. The goal is that no engineer can accidentally break a GDPR obligation and have it reach production unnoticed. Automated tests are the only way to make that guarantee.

Read more

Start now free