GDPR Compliance Testing: Automating Data Privacy Verification

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.

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."
                )

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.

Read more

Start now free