GDPR Compliance Testing: Automating Data Privacy Verification
The General Data Protection Regulation (GDPR) has been in force since May 2018, and enforcement has accelerated sharply. In 2023, GDPR fines totaled over €2.1 billion. Meta received a record €1.2 billion fine in a single case. These aren't abstract legal risks — they result from identifiable, testable technical failures: consent not recorded, deletion requests not fulfilled, data transferred without adequate safeguards.
The good news for engineers is that most GDPR technical requirements can be verified with automated tests. This guide covers the major areas where testing catches compliance failures before regulators do.
GDPR Rights That Require Technical Implementation
GDPR grants individuals eight rights. Each right is an engineering problem:
| Right | Article | What Must Work |
|---|---|---|
| Right to be informed | 13, 14 | Privacy notice shown at collection time |
| Right of access | 15 | Export all data about a subject within 30 days |
| Right to rectification | 16 | Users can correct inaccurate data |
| Right to erasure ("right to be forgotten") | 17 | Delete all data on request within 30 days |
| Right to restriction | 18 | Pause processing without deleting data |
| Right to data portability | 20 | Export in machine-readable format (JSON, CSV) |
| Right to object | 21 | Opt out of processing for direct marketing |
| Rights re: automated decisions | 22 | Human review available for algorithmic decisions |
Each of these needs at least one test. Many need several.
Testing Consent Flows
Consent under GDPR must be freely given, specific, informed, and unambiguous. Pre-ticked boxes are illegal. Consent must be as easy to withdraw as to give. These are all testable.
// tests/gdpr/consent.spec.ts
import { test, expect } from '@playwright/test';
test.describe('GDPR Consent', () => {
test('cookie consent banner is shown on first visit', async ({ browser }) => {
// Use a fresh browser context (no cookies) to simulate first visit
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
const consentBanner = page.locator('[data-testid="cookie-consent-banner"]');
await expect(consentBanner).toBeVisible();
await context.close();
});
test('analytics cookies are not set before consent', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
// Before consent, only strictly necessary cookies should exist
const cookies = await context.cookies();
const analyticsCookies = cookies.filter(c =>
['_ga', '_gid', '_fbp', 'mp_', 'ajs_'].some(prefix => c.name.startsWith(prefix))
);
expect(analyticsCookies).toHaveLength(0);
await context.close();
});
test('consent is not pre-ticked', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
// All optional consent checkboxes should be unchecked by default
const analyticsCheckbox = page.locator('[data-testid="consent-analytics"]');
const marketingCheckbox = page.locator('[data-testid="consent-marketing"]');
await expect(analyticsCheckbox).not.toBeChecked();
await expect(marketingCheckbox).not.toBeChecked();
await context.close();
});
test('analytics cookies are set only after explicit consent', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
await page.locator('[data-testid="consent-analytics"]').check();
await page.locator('[data-testid="consent-accept-selected"]').click();
const cookies = await context.cookies();
const analyticsCookies = cookies.filter(c => c.name.startsWith('_ga'));
expect(analyticsCookies.length).toBeGreaterThan(0);
await context.close();
});
test('withdrawing consent clears analytics cookies', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
// Grant consent first
await page.goto('/');
await page.locator('[data-testid="consent-accept-all"]').click();
// Verify cookies are set
let cookies = await context.cookies();
const hadAnalytics = cookies.some(c => c.name.startsWith('_ga'));
expect(hadAnalytics).toBe(true);
// Withdraw consent
await page.locator('[data-testid="manage-cookies"]').click();
await page.locator('[data-testid="consent-analytics"]').uncheck();
await page.locator('[data-testid="consent-save"]').click();
// Analytics cookies should be cleared
cookies = await context.cookies();
const analyticsAfterWithdraw = cookies.filter(c => c.name.startsWith('_ga'));
expect(analyticsAfterWithdraw).toHaveLength(0);
await context.close();
});
test('consent banner is not shown to returning users who consented', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
// First visit — give consent
await page.goto('/');
await page.locator('[data-testid="consent-accept-all"]').click();
await expect(page.locator('[data-testid="cookie-consent-banner"]')).not.toBeVisible();
// Second visit — banner should not reappear
await page.goto('/');
await expect(page.locator('[data-testid="cookie-consent-banner"]')).not.toBeVisible();
await context.close();
});
});Testing the Right to Erasure
Article 17 requires deletion of personal data within one month of request (with extension possible). Deletion must be complete — the data must not persist in backups that are regularly restored, caches, logs, or third-party systems.
// tests/gdpr/erasure.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { db } from '../../src/db';
import { redisClient } from '../../src/cache';
import { searchIndex } from '../../src/search';
describe('GDPR Right to Erasure', () => {
let userId: string;
let authToken: string;
beforeEach(async () => {
// Create a test user with data spread across systems
const user = await db('users').insert({
email: 'delete-me@example.com',
name: 'Test Erasure User',
phone: '555-0100',
}).returning('id');
userId = user[0].id;
authToken = generateTestToken(userId);
// Create associated data
await db('orders').insert({ user_id: userId, amount: 99.99 });
await db('comments').insert({ user_id: userId, content: 'Test comment' });
await db('analytics_events').insert({ user_id: userId, event: 'page_view' });
// Cache their profile
await redisClient.set(`user:${userId}`, JSON.stringify({ name: 'Test Erasure User' }));
// Index in search
await searchIndex.index({ id: userId, name: 'Test Erasure User', email: 'delete-me@example.com' });
});
it('should delete user from primary database', async () => {
await request(app)
.delete('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const user = await db('users').where({ id: userId }).first();
expect(user).toBeUndefined();
});
it('should delete user data from all associated tables', async () => {
await request(app)
.delete('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const orders = await db('orders').where({ user_id: userId });
expect(orders).toHaveLength(0);
const comments = await db('comments').where({ user_id: userId });
expect(comments).toHaveLength(0);
// Analytics may be retained in aggregate/anonymized form
const events = await db('analytics_events').where({ user_id: userId });
expect(events).toHaveLength(0);
});
it('should clear user data from cache', async () => {
await request(app)
.delete('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const cached = await redisClient.get(`user:${userId}`);
expect(cached).toBeNull();
});
it('should remove user from search index', async () => {
await request(app)
.delete('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const searchResult = await searchIndex.get(userId);
expect(searchResult).toBeNull();
});
it('should revoke all active sessions on erasure', async () => {
await request(app)
.delete('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Original token should be invalidated
await request(app)
.get('/api/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(401);
});
it('deletion request should be processed within 30 days', async () => {
// Test the queuing/scheduling mechanism for erasure
const res = await request(app)
.post('/api/data-requests/erasure')
.set('Authorization', `Bearer ${authToken}`)
.expect(202); // Accepted for processing
expect(res.body.scheduled_for).toBeDefined();
const scheduledDate = new Date(res.body.scheduled_for);
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
expect(scheduledDate.getTime()).toBeLessThanOrEqual(thirtyDaysFromNow.getTime());
});
});Testing the Right of Access (Data Subject Access Request)
Article 15 requires providing a copy of all personal data within one month. Test that the export is complete and in a portable format.
// tests/gdpr/data-access.test.ts
import request from 'supertest';
import { app } from '../../src/app';
describe('GDPR Data Subject Access Request', () => {
const authToken = 'valid-user-token';
it('should return all personal data categories', async () => {
const res = await request(app)
.get('/api/me/data-export')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Export must cover all data categories
expect(res.body).toHaveProperty('profile');
expect(res.body).toHaveProperty('orders');
expect(res.body).toHaveProperty('communications');
expect(res.body).toHaveProperty('activity_log');
expect(res.body).toHaveProperty('preferences');
expect(res.body).toHaveProperty('consents');
});
it('consent records should include timestamp and purpose', async () => {
const res = await request(app)
.get('/api/me/data-export')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const consents = res.body.consents;
expect(Array.isArray(consents)).toBe(true);
consents.forEach((consent: Record<string, unknown>) => {
expect(consent).toHaveProperty('purpose');
expect(consent).toHaveProperty('granted_at');
expect(consent).toHaveProperty('granted'); // boolean
});
});
it('export should be in machine-readable format', async () => {
const res = await request(app)
.get('/api/me/data-export')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
// Should be valid JSON (implicit since we parsed it)
expect(typeof res.body).toBe('object');
});
it('data export should not include other users data', async () => {
const res = await request(app)
.get('/api/me/data-export')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Profile email should match the authenticated user only
expect(res.body.profile.email).toBe('testuser@example.com');
expect(res.body.profile.email).not.toContain('otheruser');
});
});Data Mapping Verification
GDPR requires knowing what personal data you hold and where. Automated tests can verify that your data map matches reality.
# tests/gdpr/test_data_mapping.py
import pytest
from sqlalchemy import inspect
from myapp.database import engine
# Fields that should be flagged as personal data in your data map
PERSONAL_DATA_COLUMNS = {
'users': ['email', 'name', 'phone', 'date_of_birth', 'ip_address'],
'orders': ['billing_address', 'shipping_address'],
'payments': ['card_last_four', 'billing_name'],
'support_tickets': ['description'], # may contain PII by nature
}
def test_personal_data_columns_exist_in_database():
"""Verify data map is accurate — columns in data map exist in DB."""
inspector = inspect(engine)
for table_name, expected_columns in PERSONAL_DATA_COLUMNS.items():
actual_columns = [col['name'] for col in inspector.get_columns(table_name)]
for col in expected_columns:
assert col in actual_columns, (
f"Data map references {table_name}.{col} but column does not exist. "
f"Update the data map or add the column."
)
def test_no_undeclared_email_columns():
"""Detect email-like columns not declared in data map."""
inspector = inspect(engine)
declared_email_columns = set()
for table, cols in PERSONAL_DATA_COLUMNS.items():
for col in cols:
if 'email' in col:
declared_email_columns.add(f"{table}.{col}")
for table_name in inspector.get_table_names():
for col in inspector.get_columns(table_name):
col_name = col['name'].lower()
if 'email' in col_name or 'mail' in col_name:
qualified = f"{table_name}.{col['name']}"
assert qualified in declared_email_columns, (
f"Undeclared email column found: {qualified}. "
f"Add to data map and assess GDPR obligations."
)Cookie Consent Testing with Playwright and CDP
For precise cookie testing, the Chrome DevTools Protocol lets you inspect cookies set by third-party scripts:
// tests/gdpr/cookie-audit.spec.ts
import { test, expect, CDPSession } from '@playwright/test';
test('third-party cookies should not be set without consent', async ({ page, context }) => {
// Track all cookies set during page load
const cookiesSet: string[] = [];
const client = await context.newCDPSession(page);
await client.send('Network.enable');
client.on('Network.responseReceivedExtraInfo', (event) => {
const setCookieHeader = event.headers['set-cookie'];
if (setCookieHeader) {
cookiesSet.push(...setCookieHeader.split('\n').map(c => c.split('=')[0].trim()));
}
});
// Visit without giving consent
await page.goto('/');
await page.waitForTimeout(3000); // Allow scripts to run
// Known tracking/analytics cookies
const trackingCookies = ['_ga', '_gid', '_fbp', 'fr', '__utma', 'IDE', 'NID'];
const violations = cookiesSet.filter(name =>
trackingCookies.some(tracker => name.startsWith(tracker))
);
expect(violations).toHaveLength(0);
});CI Integration for GDPR Tests
# .github/workflows/gdpr-compliance.yml
name: GDPR Compliance Tests
on:
pull_request:
schedule:
- cron: '0 6 * * 1' # Weekly
jobs:
gdpr-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run database migrations
run: npm run migrate
env:
DATABASE_URL: postgresql://postgres:testpass@localhost/testdb
- name: Start application
run: npm start &
env:
DATABASE_URL: postgresql://postgres:testpass@localhost/testdb
REDIS_URL: redis://localhost:6379
- run: npx wait-on http://localhost:3000
- name: Run GDPR unit tests
run: npx jest tests/gdpr/ --coverage
- name: Run GDPR Playwright tests
run: npx playwright test tests/gdpr/
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: gdpr-test-results
path: |
playwright-report/
coverage/Handling Legitimate Interests and Data Retention
Not all data processing requires consent. Legitimate interests, legal obligations, and contractual necessity are also lawful bases. But each basis has its own testable requirements:
// tests/gdpr/retention.test.ts
import { db } from '../../src/db';
describe('GDPR Data Retention', () => {
it('deleted accounts should be purged after retention period', async () => {
// Accounts deleted more than 90 days ago should be fully purged
const oldDeletedAccounts = await db('users')
.where('deleted_at', '<', db.raw("NOW() - INTERVAL '90 days'"))
.whereNotNull('deleted_at');
expect(oldDeletedAccounts).toHaveLength(0);
});
it('financial records must be retained for 7 years (legal obligation)', async () => {
// Even after account deletion, financial records must be retained
// This tests that they are NOT deleted (legal hold)
const recentOrder = await db('orders')
.where('created_at', '>', db.raw("NOW() - INTERVAL '6 years'"))
.first();
if (recentOrder) {
// Verify financial records survive user deletion
await db('users').where({ id: recentOrder.user_id }).update({ deleted_at: new Date() });
const orderAfterDeletion = await db('orders').where({ id: recentOrder.id }).first();
expect(orderAfterDeletion).toBeDefined();
}
});
it('marketing emails should stop immediately after opt-out', async () => {
// Simulate opt-out
await db('users')
.where({ id: 'test-user-id' })
.update({ marketing_opt_in: false, marketing_opt_out_at: new Date() });
// Verify no pending marketing emails in queue for this user
const pendingEmails = await db('email_queue')
.where({ user_id: 'test-user-id', type: 'marketing' });
expect(pendingEmails).toHaveLength(0);
});
});Building a Repeatable GDPR Verification Program
GDPR compliance degrades over time. New features add new data flows; team turnover means policies aren't followed. A sustainable testing program runs on cadence:
| Cadence | Test Type | Coverage |
|---|---|---|
| Every commit | Unit tests | Erasure logic, consent recording, data access scoping |
| Every PR | Integration tests | Consent flows, deletion cascades, data export completeness |
| Weekly | E2E Playwright | Cookie consent UI, withdrawal UX, third-party cookie audit |
| Monthly | Data mapping check | New columns vs. declared personal data fields |
| Quarterly | Manual review | Data transfers, DPA list, breach notification procedures |
| Annually | Full compliance audit | All rights, all articles, DPO review |
GDPR is ultimately about trust — users trusting that your organization treats their data with respect. Automated tests are how engineers operationalize that trust into verifiable, repeatable guarantees.