GDPR Compliance Testing: How to Test Your App's Data Privacy Controls
GDPR has been in force since May 2018, yet data protection authorities across Europe continue to hand out record fines. In 2023 alone, Meta received a €1.2 billion penalty. The gap between "we think we're compliant" and "we can prove we're compliant" is almost always a testing gap. Engineers build privacy features, but those features rarely get the same systematic test coverage as a login form or a checkout flow.
This guide walks through GDPR's technical requirements from a testing perspective: what needs to be verified, how to automate it, and how to build a repeatable test suite that gives your legal and compliance teams real evidence rather than hand-waving.
What GDPR Actually Requires From Your Application
GDPR is not purely a legal document — it contains specific technical obligations that map directly to testable application behaviors:
- Lawful basis for processing (Article 6): Your app must only process personal data when a valid legal basis exists. Consent is the most common for consumer apps.
- Consent management (Article 7): Consent must be freely given, specific, informed, and unambiguous. Withdrawing consent must be as easy as giving it.
- Right of access (Article 15): Data subjects can request all personal data you hold about them.
- Right to erasure (Article 17): Data subjects can request deletion of their personal data.
- Data minimization (Article 5(1)(c)): You should only collect data that is necessary for the stated purpose.
- Data portability (Article 20): Users can request their data in a machine-readable format.
- Breach notification (Article 33): You must detect and report breaches within 72 hours.
Each of these is a testable behavior. Let's go through them systematically.
Testing Consent Flows
Consent flows are the most visible part of GDPR compliance and the most frequently broken. A consent banner that pre-ticks marketing cookies or makes the "Reject All" button harder to find than "Accept All" violates GDPR.
What to test
- Cookie banner appears on first visit for users without existing consent
- No non-essential cookies are set before consent is given
- Accepting only necessary cookies does not set analytics or marketing cookies
- Rejecting all cookies does not set any non-essential cookies
- Consent preferences are persisted across sessions
- Withdrawing consent stops further data processing
- The UI treats accept and reject with equal prominence
Automated testing with Playwright
import { test, expect, BrowserContext } from '@playwright/test';
test.describe('GDPR Consent Flow', () => {
test('no non-essential cookies set before consent', async ({ page }) => {
// Clear all cookies and storage before visiting
await page.goto('https://yourapp.com');
// Check cookies immediately on load — before any interaction
const cookies = await page.context().cookies();
const nonEssential = cookies.filter(c =>
!['session', 'csrf_token'].includes(c.name)
);
expect(nonEssential).toHaveLength(0);
});
test('consent banner has equal-prominence accept and reject buttons', async ({ page }) => {
await page.goto('https://yourapp.com');
const acceptBtn = page.locator('[data-testid="consent-accept"]');
const rejectBtn = page.locator('[data-testid="consent-reject"]');
await expect(acceptBtn).toBeVisible();
await expect(rejectBtn).toBeVisible();
// Both buttons should be visible without scrolling
const acceptBox = await acceptBtn.boundingBox();
const rejectBox = await rejectBtn.boundingBox();
expect(acceptBox).not.toBeNull();
expect(rejectBox).not.toBeNull();
});
test('rejecting all consent sets only essential cookies', async ({ page }) => {
await page.goto('https://yourapp.com');
await page.click('[data-testid="consent-reject"]');
const cookies = await page.context().cookies();
const cookieNames = cookies.map(c => c.name);
// Only consent preference and session cookies should exist
expect(cookieNames).not.toContain('_ga');
expect(cookieNames).not.toContain('_fbp');
expect(cookieNames).not.toContain('_gid');
});
test('accepting analytics consent sets GA cookies', async ({ page }) => {
await page.goto('https://yourapp.com');
await page.click('[data-testid="consent-preferences"]');
await page.check('[data-testid="analytics-consent"]');
await page.click('[data-testid="consent-save"]');
const cookies = await page.context().cookies();
const cookieNames = cookies.map(c => c.name);
expect(cookieNames).toContain('_ga');
});
test('withdrawing consent removes analytics cookies', async ({ page, context }) => {
// First, give consent
await page.goto('https://yourapp.com');
await page.click('[data-testid="consent-accept"]');
// Now withdraw consent
await page.click('[data-testid="privacy-settings"]');
await page.uncheck('[data-testid="analytics-consent"]');
await page.click('[data-testid="consent-save"]');
// Verify cookies are cleared
const cookies = await context.cookies();
const gaExists = cookies.some(c => c.name === '_ga');
expect(gaExists).toBe(false);
});
});Right to Erasure Testing
The right to erasure (Article 17) requires that when a user requests deletion, their personal data is actually deleted across all systems — not just marked as inactive. This is surprisingly hard to test correctly because data often lives in multiple places: primary database, search indices, analytics warehouses, backups, audit logs.
Test scenarios
import pytest
import requests
import time
BASE_URL = "https://api.yourapp.com"
class TestRightToErasure:
def setup_method(self):
# Create a test user with known personal data
resp = requests.post(f"{BASE_URL}/users", json={
"email": "erasure-test@example.com",
"name": "Test User GDPR",
"phone": "+1234567890"
})
self.user = resp.json()
self.user_id = self.user["id"]
self.auth_token = self.user["token"]
def test_erasure_request_accepted(self):
resp = requests.post(
f"{BASE_URL}/users/{self.user_id}/erasure-request",
headers={"Authorization": f"Bearer {self.auth_token}"}
)
assert resp.status_code == 202 # Accepted for processing
def test_user_data_deleted_from_primary_db(self):
requests.post(
f"{BASE_URL}/users/{self.user_id}/erasure-request",
headers={"Authorization": f"Bearer {self.auth_token}"}
)
# Allow time for async processing
time.sleep(2)
# Direct DB query through internal admin endpoint (test environment only)
resp = requests.get(
f"{BASE_URL}/admin/users/{self.user_id}",
headers={"X-Admin-Key": "test-admin-key"}
)
assert resp.status_code == 404
def test_email_no_longer_findable(self):
requests.post(
f"{BASE_URL}/users/{self.user_id}/erasure-request",
headers={"Authorization": f"Bearer {self.auth_token}"}
)
time.sleep(2)
# Attempting to find user by email should return nothing
resp = requests.get(
f"{BASE_URL}/admin/users?email=erasure-test@example.com",
headers={"X-Admin-Key": "test-admin-key"}
)
data = resp.json()
assert data["total"] == 0
def test_login_impossible_after_erasure(self):
requests.post(
f"{BASE_URL}/users/{self.user_id}/erasure-request",
headers={"Authorization": f"Bearer {self.auth_token}"}
)
time.sleep(2)
resp = requests.post(f"{BASE_URL}/auth/login", json={
"email": "erasure-test@example.com",
"password": "TestPassword123"
})
assert resp.status_code == 401
def test_erasure_completion_email_sent(self):
# Check that a confirmation email was sent within 30 days (Article 12(3))
resp = requests.post(
f"{BASE_URL}/users/{self.user_id}/erasure-request",
headers={"Authorization": f"Bearer {self.auth_token}"}
)
request_id = resp.json()["request_id"]
time.sleep(2)
resp = requests.get(
f"{BASE_URL}/admin/erasure-requests/{request_id}",
headers={"X-Admin-Key": "test-admin-key"}
)
data = resp.json()
assert data["confirmation_email_sent"] is TrueData Minimization Testing
Data minimization means your application should not collect data that isn't necessary for its stated purpose. Testing this involves auditing API request and response payloads, form fields, and analytics events.
API payload audit
// Jest test for checking signup form does not collect unnecessary fields
describe('Signup Form Data Minimization', () => {
test('signup payload contains only necessary fields', async () => {
const allowedFields = ['email', 'password', 'name'];
// Intercept the actual API call during signup
const payload = await captureSignupPayload();
const actualFields = Object.keys(payload);
const unnecessaryFields = actualFields.filter(
f => !allowedFields.includes(f)
);
expect(unnecessaryFields).toEqual([]);
});
test('user profile response does not expose sensitive derived data', async () => {
const profile = await getMyProfile(testUserToken);
// These fields should never appear in a profile response
expect(profile).not.toHaveProperty('ip_address');
expect(profile).not.toHaveProperty('device_fingerprint');
expect(profile).not.toHaveProperty('inferred_age');
});
});DSAR Workflow Testing
A Data Subject Access Request (DSAR) workflow must return all personal data held about a user in a structured, machine-readable format within 30 days (Article 12(3)). Testing the DSAR workflow end-to-end is essential.
describe('DSAR Workflow', () => {
test('DSAR request returns complete data export within SLA', async () => {
const response = await api.post(`/users/${userId}/data-export-request`);
expect(response.status).toBe(202);
const requestId = response.data.request_id;
// Poll for completion (in production, this would be delivered by email)
let exportData;
for (let i = 0; i < 10; i++) {
await sleep(1000);
const status = await api.get(`/data-export-requests/${requestId}`);
if (status.data.status === 'complete') {
exportData = status.data.export;
break;
}
}
expect(exportData).toBeDefined();
expect(exportData.format).toBe('application/json'); // Machine-readable
expect(exportData.data).toHaveProperty('profile');
expect(exportData.data).toHaveProperty('activity');
expect(exportData.data).toHaveProperty('preferences');
});
});Building a Repeatable Compliance Test Suite
Individual tests are useful, but GDPR compliance needs to be verified continuously, not just at audit time. Integrate compliance tests into your CI/CD pipeline:
# .github/workflows/gdpr-tests.yml
name: GDPR Compliance Tests
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly on Mondays
jobs:
gdpr-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run consent flow tests
run: npx playwright test tests/gdpr/consent.spec.ts
- name: Run erasure workflow tests
run: pytest tests/gdpr/erasure_test.py
- name: Run data minimization audit
run: npm run test:data-minimization
- name: Generate compliance report
run: npm run gdpr:report
- name: Upload evidence
uses: actions/upload-artifact@v3
with:
name: gdpr-compliance-evidence
path: reports/gdpr/What Good Evidence Looks Like
When a data protection authority asks for evidence of compliance, "we have tests" is not enough. You need:
- Test run logs with timestamps — showing tests passed on a specific date
- Coverage mapping — which GDPR articles are covered by which tests
- Failure history — demonstrating you detected and remediated issues
- Retention policy enforcement tests — showing data is actually deleted after the retention period
Build a mapping document that ties each GDPR article to specific test IDs. Your DPO will thank you when the audit comes.
Common Pitfalls to Avoid
Testing the banner, not the cookies. Many teams verify the banner UI looks right but never check whether cookies are actually blocked before consent. Use page.context().cookies() to verify at the network level.
Not testing withdrawal. Accepting consent is tested far more often than withdrawing it. Withdrawal is equally important under GDPR.
Forgetting downstream systems. Erasure tests often only check the primary database. Personal data in search indices (Elasticsearch, Algolia), data warehouses, or third-party analytics tools needs to be verified separately.
Testing only happy paths. What happens if an erasure request fails mid-way? Does the system retry? Does the user get notified? These failure scenarios need tests too.
GDPR compliance is a living obligation, not a one-time project. A test suite that runs continuously gives your team early warning when a new feature accidentally breaks privacy controls — before a regulator finds out.