Generating GDPR-Safe Customer Fixtures

The safest production data is the production data you never copied. This page builds a complete customer dataset from the schema alone — using identifier ranges reserved for exactly this purpose — that is realistic enough to develop and test against and contains nothing belonging to a real person.

Context: reserved ranges exist for this

Standards bodies have set aside identifiers that can never belong to anyone, precisely so that test data does not have to guess. Using them turns “these values are probably not real” into “these values cannot be real”.

The commonly needed ones are worth memorising: .invalid and .test top-level domains never resolve; example.com, example.net and example.org are real registered domains and should be avoided for anything that might send; UK numbers in 07700 900000900999 and North American numbers with the 555-01xx exchange are reserved for fiction; the payment networks publish test card numbers that pass a Luhn check and are declined by every real processor.

The second half of the job is realism. A dataset where every name is eight characters and every address has two lines exercises one layout and misses every edge the real world contains.

Reserved ranges and the lookalikes to avoid Four rows of identifier types. Email should use the reserved invalid or test top-level domains rather than example.com, which is a real registered domain. Phone numbers should use the UK 07700 900xxx or North American 555-01xx reserved ranges rather than a plausible-looking arbitrary number. Payment cards should use published network test numbers. National identifiers should be omitted entirely rather than generated in a valid format. Field Use Never use email [email protected] anything at a resolvable domain phone +44 7700 900418 · 555-0142 a plausible arbitrary number payment card a published network test number any Luhn-valid generated digits national identifier omit the field entirely a correctly formatted fake A correctly formatted fake national identifier will eventually collide with a real one; there is no reserved range to draw from.

Solution

1. Draw contact details from reserved ranges only

// src/fixtures/safe.ts
import { faker } from './faker';

/** Reserved TLDs — .invalid can never resolve, so nothing can be delivered. */
const SAFE_DOMAINS = ['example.invalid', 'example.test'] as const;

export function safeEmail(handle?: string): string {
  const local = handle ?? faker.internet.username().toLowerCase().replace(/[^a-z0-9._-]/g, '');
  return `${local}@${faker.helpers.arrayElement(SAFE_DOMAINS)}`;
}

/** UK 07700 900000–900999 is reserved for drama and documentation. */
export function safeUkMobile(): string {
  return `+44 7700 900${faker.string.numeric(3)}`;
}

/** North American 555-0100–555-0199 is the equivalent reserved block. */
export function safeUsPhone(): string {
  return `+1 ${faker.string.numeric({ length: 3, allowLeadingZeros: false })} 555 01${faker.string.numeric(2)}`;
}

/** Published network test numbers — Luhn-valid, universally declined. */
const TEST_CARDS = [
  '4242424242424242', // Visa
  '5555555555554444', // Mastercard
  '378282246310005',  // Amex
] as const;

export const safeCard = () => faker.helpers.arrayElement(TEST_CARDS);

2. Keep the distributions plausible

Realism comes from the spread, not from any single value. Weighting the generator to match the real shape is what makes the fixtures exercise the same code:

// src/fixtures/customers.ts
import { faker } from './faker';
import { safeEmail, safeUkMobile } from './safe';

export interface Customer {
  id: string;
  fullName: string;
  email: string;
  phone: string | null;
  addressLines: string[];
  postcode: string;
  createdAt: string;
  marketingOptIn: boolean;
}

export function buildCustomer(id: string): Customer {
  const first = faker.person.firstName();
  const last = faker.person.lastName();

  return {
    id,
    fullName: `${first} ${last}`,
    email: safeEmail(`${first}.${last}`.toLowerCase()),
    // Roughly a fifth of real customers have no phone on file.
    phone: faker.number.float({ min: 0, max: 1 }) > 0.2 ? safeUkMobile() : null,
    addressLines: faker.helpers.weightedArrayElement([
      { weight: 6, value: 2 },   // most addresses are two lines
      { weight: 3, value: 3 },
      { weight: 1, value: 1 },   // some are one
    ]) === 1
      ? [faker.location.streetAddress()]
      : [faker.location.streetAddress(), faker.location.city()],
    postcode: faker.location.zipCode('??# #??'),
    createdAt: faker.date.between({ from: '2023-01-01', to: '2026-07-31' }).toISOString(),
    marketingOptIn: faker.datatype.boolean({ probability: 0.35 }),
  };
}

The phone: null branch is the one that earns its keep. Twenty per cent of records missing a phone means every list, every detail view and every export gets tested against the absent case, which a uniformly complete dataset never does.

3. Cover the awkward cases on purpose

Random generation clusters around the middle and never produces the edges. Add them explicitly:

// src/fixtures/customers.edge.ts
import type { Customer } from './customers';
import { safeEmail } from './safe';

/** The records production actually contains and a generator never produces. */
export const EDGE_CUSTOMERS: Customer[] = [
  {
    id: 'cus_edge_longname',
    fullName: 'Bartholomew Fitzwilliam-Harrington-Delacroix',   // 44 chars — overflows fixed cells
    email: safeEmail('b.fitzwilliam.harrington.delacroix'),
    phone: null,
    addressLines: ['Flat 12B, The Old Biscuit Factory, 100 Drummond Road'],
    postcode: 'SE16 4DG',
    createdAt: '2023-02-11T08:14:00.000Z',
    marketingOptIn: false,
  },
  {
    id: 'cus_edge_diacritics',
    fullName: 'Zoë Ærlandsdóttir-Nguyễn',                        // non-ASCII in every part
    email: safeEmail('zoe.aerlandsdottir'),
    phone: '+44 7700 900044',
    addressLines: ['12 Rue de l’Épée', 'Saint-Étienne'],
    postcode: 'W1A 0AX',
    createdAt: '2024-11-30T23:59:59.000Z',                       // month and year boundary
    marketingOptIn: true,
  },
  {
    id: 'cus_edge_minimal',
    fullName: 'Li Wu',                                           // shortest plausible name
    email: safeEmail('lw'),
    phone: null,
    addressLines: [],                                            // legitimately empty
    postcode: '',
    createdAt: '2026-07-31T09:00:00.000Z',                       // created "now"
    marketingOptIn: false,
  },
];

Three hand-written records catch more layout, encoding and empty-state bugs than a thousand generated ones. They are also the records worth reviewing in a pull request, because each encodes a specific thing that went wrong once.

Generated records cluster; bugs live at the edges A distribution of generated name lengths forms a dense hump between eight and eighteen characters. Two shaded zones at the extremes mark where the rendering bugs are: very short names that break truncation logic and very long names that overflow fixed-width cells. Almost no generated record lands in either zone, which is why the edge cases have to be written by hand. bugs here bugs here 2 chars generated name length 50 chars truncation and initials overflow, wrapping and column collapse Almost every generated record lands here, where nothing breaks.

Verification

# No resolvable email domain reached the fixtures
grep -oE '@[a-z0-9.-]+' src/fixtures/generated/customers.json | sort -u
# expect only @example.invalid and @example.test

# Phone numbers are inside the reserved blocks
grep -oE '\+44 7700 9[0-9]{5}' src/fixtures/generated/customers.json | wc -l
grep -cE '\+44 7[0-6]' src/fixtures/generated/customers.json   # expect 0

# The edge records survive into the built fixture set
jq -e '[.[] | select(.id | startswith("cus_edge_"))] | length == 3' \
  src/fixtures/generated/customers.json

Gotchas and edge cases

  • Faker’s internet.email() uses real domains by default. It draws from a list including gmail.com and yahoo.com, so a fixture generated with it will happily produce an address that could belong to someone. Always route through your own safeEmail, and add a lint rule banning the bare call.

  • A generated postcode can be a real address. UK postcode formats are dense enough that a random one usually exists. Combined with a name it becomes a quasi-identifier, so prefer postcode districts (the outward code alone) unless the full code is genuinely needed for a format test.

  • Edge fixtures rot if nothing asserts on them. A record added to catch an overflow bug is only doing work while a test renders it. Reference each edge record from at least one spec by id, so deleting the spec makes the unused fixture visible instead of leaving it as decoration.


The edge records worth keeping Three record shapes worth writing by hand and keeping forever: a maximum-length name, a record with non-ASCII in every text field, and a minimal record with every optional field absent. Between them they cover most layout, encoding and empty-state failures. Maximum length everywhere names, addresses, company catches overflow and column collapse Non-ASCII in every field diacritics, combining marks, right-to-left catches encoding and width assumptions Minimal, everything optional absent the shortest legal record catches missing empty-state handling Three records, referenced by id from at least one spec each, so deleting the spec makes the fixture visibly unused.

Sizing a customer dataset

More records is not better, and the instinct to generate thousands usually makes the fixture set less useful rather than more.

What volume buys. Enough records to paginate, to exercise a virtualised list, to make a search return more than one page, and to notice a rendering cost that only appears at scale. Roughly a hundred covers all of that for most interfaces; a thousand covers the rest.

What volume costs. Slower fixture generation, slower test setup, a diff nobody reads when the set is regenerated, and — most importantly — no memorable records. A set with a thousand indistinguishable customers has no member anyone can refer to.

What variety buys. Every branch in the interface exercised at least once: the customer with no orders, the one with three hundred, the one with no phone number, the one whose name is too long for the column, the one in a right-to-left locale. Fifteen carefully chosen records cover more branches than a thousand random ones.

The arrangement that works is a small named set plus generated bulk behind it. The named records are referenced by identifier from specifications and are what people talk about; the bulk exists so that lists have something to paginate. Because the bulk is generated deterministically, it costs nothing to regenerate and nothing to review.

One more sizing consideration specific to privacy: a smaller set is easier to inspect. A human can read fifteen records and confirm that nothing in them looks real. Nobody reads a thousand, which means the detector is the only thing standing between a mistake and the repository — and a detector is a pattern matcher, not a judgement.

← Back to Mock Data Privacy & Anonymisation