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 900000–900999 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.
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.
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 includinggmail.comandyahoo.com, so a fixture generated with it will happily produce an address that could belong to someone. Always route through your ownsafeEmail, 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.
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.
Related
- Anonymising Production Payloads for Local Use — for the cases where a real payload genuinely is needed
- Mock Data Privacy & Anonymisation — the classification and CI detector this dataset is checked against
- Building a Reusable Fixture Factory — the factory these safe generators plug into
← Back to Mock Data Privacy & Anonymisation