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 TrueTesting 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)Testing Consent Management
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_bodyIntegrating 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.htmlTrigger 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.