GDPR Compliance Testing: How to Test Your App's Data Privacy Controls

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.

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

  1. Cookie banner appears on first visit for users without existing consent
  2. No non-essential cookies are set before consent is given
  3. Accepting only necessary cookies does not set analytics or marketing cookies
  4. Rejecting all cookies does not set any non-essential cookies
  5. Consent preferences are persisted across sessions
  6. Withdrawing consent stops further data processing
  7. 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 True

Data 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:

  1. Test run logs with timestamps — showing tests passed on a specific date
  2. Coverage mapping — which GDPR articles are covered by which tests
  3. Failure history — demonstrating you detected and remediated issues
  4. 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.

Read more