How Random Address Generators Work for Software Testing & QA

Published: August 1, 2026 | Author: David Vance (Senior QA Automation Engineer) | Category: Software Engineering

Discover how synthetic data engines generate USPS-compliant street addresses, ZIP+4 codes, and realistic geographic coordinates without using PII.


A generator that draws a city, a state and a ZIP independently produces Miami, WY 90210 roughly every time. The whole difficulty is dependency: pick one real place, then derive everything else from it rather than drawing it.

What naive random generation produces

Here is the version almost everyone writes first:

// Broken. Three independent draws from three lists.
const city  = pick(CITIES);   // 'Miami'
const state = pick(STATES);   // 'WY'
const zip   = pick(ZIPS);     // '90210'

Sample output, and what is wrong with each:

generatedproblem
Miami, WY 90210city is in Florida, ZIP is in California, state is Wyoming — nothing agrees
Chicago, IL 99501995xx is Alaska; the prefix contradicts the state
Springfield, MA 01103plausible, by luck — and there are Springfields in 34 states, so the city name alone never determined the state
Boston, MA 0210102101 is a retired ZIP; it parses and will not validate
New York, NY 10001-00000000 is not an issued add-on; the four digits look real and point nowhere

These fixtures fail in a specific and expensive way: they get rejected by your own validation layer. The test then exercises the rejection path, not the behaviour under test, and when a developer investigates they find the fixture is wrong rather than the code. Worse, a passing test proves nothing, because the validator short-circuited before reaching the logic.

The pipeline that works

Four stages, each deriving from the last rather than drawing independently.

1. Start from a registry of real places. You need a table whose rows are internally consistent: a city name, the state it is in, the county it sits in, and the ZIP prefixes that serve it. Every downstream value comes from the chosen row. Nothing is drawn twice.

// One draw. Everything else is a lookup on the chosen row.
const place = pickWeighted(PLACES);
// { city: 'Austin', state: 'TX', county: 'Travis', prefixes: ['787','733'] }

2. Weight the selection by population. Uniform selection over a city list makes a town of 400 as likely as Houston, so your fixtures look nothing like your traffic and you never generate the dense-city cases where duplicate street names and high-rise unit numbers appear. Weighting by population gives a distribution that resembles real address input.

function pickWeighted(rows, rand) {
  const total = rows.reduce((n, r) => n + r.population, 0);
  let t = rand() * total;
  for (const r of rows) if ((t -= r.population) <= 0) return r;
  return rows[rows.length - 1];
}

3. Derive the ZIP from the place, not the other way round. Take a prefix the chosen city is served by, then fill the last two digits. Those two digits are the compromise: a real five-digit ZIP would have to come from a complete, current ZIP list, so a generator either ships one or generates within a prefix and is honest that the result is structurally valid rather than confirmed assigned.

const prefix = pick(place.prefixes, rand);            // '787'
const zip5   = prefix + String(Math.floor(rand() * 100)).padStart(2, '0');

4. Derive the rest from the place. County comes from the row. Timezone comes from the state or the county. The area code comes from the set assigned to that state. The street number and street name are invented — that is the synthetic part — and the unit designator follows Publication 28.

What you must not do at this stage is invent a precise coordinate and present it as the location of the ZIP. No public source gives per-ZIP coordinates at that precision; a lat/long derived by scattering points around a city centre is a plausible-looking number with nothing behind it.

Seeding, so failures reproduce

An unseeded generator makes a test that fails once in fifty runs, which teams learn to re-run rather than fix.

function mulberry32(seed) {
  return function () {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const seed = Number(process.env.TEST_SEED ?? 1337);
const rand = mulberry32(seed);
console.log(`TEST_SEED=${seed}`);     // print it, always

const addresses = Array.from({ length: 100 }, () => generateAddress(rand));

Two rules: pass the PRNG in rather than reaching for a module-level Math.random, or your generator cannot be seeded at all; and seed per test file, so one test's draw count does not shift another test's data.

How to validate what you generated

Do not trust the generator — assert on its output. These are the checks that catch real bugs, written as a test:

import assert from 'node:assert/strict';

for (const a of addresses) {
  // 1. The ZIP prefix must belong to the state. This is the single most
  //    valuable assertion: it catches every independent-draw bug at once.
  assert.equal(prefixToState(a.zip.slice(0, 3)), a.state,
    `${a.city}, ${a.state} ${a.zip}: prefix ${a.zip.slice(0, 3)} is not in ${a.state}`);

  // 2. The city must be a real city in that state, and the county must be
  //    the one the registry records for it.
  const row = lookupPlace(a.city, a.state);
  assert.ok(row, `${a.city}, ${a.state} is not a place on record`);
  assert.equal(a.county, row.county);

  // 3. ZIP is five digits as a string, leading zero intact.
  assert.match(a.zip, /^\d{5}$/);

  // 4. The area code must be assigned to the state, and the line number
  //    must sit in the reserved fictional block so nothing can ring.
  assert.ok(areaCodesFor(a.state).includes(a.phone.slice(0, 3)));
  assert.match(a.phone, /555-01\d{2}$/);

  // 5. Suffix and unit designator are Publication 28 forms.
  assert.match(a.street, /\b(ST|AVE|BLVD|RD|DR|LN|CT|PL|WAY|PKWY|TER|CIR)\b/);
}

Three more worth adding once the basics pass:

  • Distribution. Generate 10,000 and assert the top five cities account for a plausible share. A uniform distribution means the weighting broke.
  • Determinism. Generate twice from the same seed and assert deep equality. Catches accidental Date.now() or Math.random() in a helper.
  • Known-bad fixtures. Keep a handful of addresses your generator should never produce — Miami, WY 90210, a ZIP of 00000, a phone in 555-8820 — and assert they do not appear. A regression that reintroduces independent draws fails loudly.

The street-name corpus is where realism actually comes from

The city registry fixes the geography. The street name is invented, and a careless list makes every generated address obviously fake in ways that break tests.

Three properties to get right:

Suffix distribution. Real US addresses are not uniformly distributed across suffixes. Street, Avenue, Road, Drive and Lane dominate; Esplanade and Causeway are rare. A generator that picks uniformly from the 200-odd Publication 28 suffixes produces an implausible density of TRCE and CSWY, and your UI never sees the 2-character ST that most real input uses. Weight the suffix list.

Name collisions across cities. The same street name recurring in different cities is not a bug to avoid — it is the single most useful property for testing. MAIN ST exists in thousands of places, which is exactly why "look up the customer by street name" is a bug and why your tests should produce it.

Length and character coverage. A corpus of short single-word names never exercises the 64-character delivery line limit, the apostrophe in O'FARRELL ST, the hyphen in JEAN-BAPTISTE WAY, or the numeric name in 42ND ST — which also needs the right ordinal suffix, and 2ND/3RD/21ST are where naive pluralisation code fails. Include all four shapes deliberately.

Run the invariants in CI, not by hand

The assertions above are only worth writing if they run on every change. Generated data has a particular failure mode: a refactor changes a lookup and the output stays *plausible* while becoming wrong, which no reviewer spots by eye.

import test from 'node:test';
import assert from 'node:assert/strict';

// A fixed seed makes the suite deterministic; a second unseeded pass over a larger
// sample catches anything that only breaks for particular draws.
test('every generated address is internally consistent', () => {
  const rand = mulberry32(20261104);
  for (let i = 0; i < 5_000; i += 1) {
    const a = generateAddress(rand);
    assert.equal(prefixToState(a.zip.slice(0, 3)), a.state, JSON.stringify(a));
    assert.equal(a.county, lookupPlace(a.city, a.state)?.county, JSON.stringify(a));
  }
});

test('the same seed produces the same output', () => {
  const one = Array.from({ length: 50 }, (_, i) => generateAddress(mulberry32(7 + i * 0)));
  const two = Array.from({ length: 50 }, (_, i) => generateAddress(mulberry32(7 + i * 0)));
  assert.deepEqual(one, two);
});

test('the city distribution is weighted, not uniform', () => {
  const rand = mulberry32(99);
  const counts = new Map();
  for (let i = 0; i < 20_000; i += 1) {
    const { city } = generateAddress(rand);
    counts.set(city, (counts.get(city) ?? 0) + 1);
  }
  const top = [...counts.values()].sort((a, b) => b - a).slice(0, 5).reduce((n, v) => n + v, 0);
  // Uniform selection over a few thousand cities would put this near zero.
  assert.ok(top / 20_000 > 0.05, `top five cities are only ${(top / 200).toFixed(1)}% of output`);
});

The third test is the one that pays for itself. A uniform-selection regression leaves every individual record valid, so the per-record assertions all pass; only the aggregate shows it.

Batch generation changes two of the guarantees

Generating one record and generating ten thousand are different problems, and two things that hold for a single record stop holding in bulk.

Uniqueness is not free. Draw 10,000 addresses from a weighted city list and the same city appears hundreds of times — correct, and realistic. But the full record can repeat too, because the street number and name are drawn from small pools. If your fixture loads into a table with a unique constraint on the address, the insert fails on a duplicate somewhere around the first few thousand rows. Either de-duplicate before insert, or widen the pools, or drop the constraint for test data. Deciding which is a schema question, not a generator question.

Per-record validity does not imply per-batch validity. A batch destined for a bulk insert needs consistency the single record never exercised: every row the same shape, no null where the schema says not-null, string lengths inside the column widths. A VARCHAR(50) street column and a generated 1244 NORTHWEST MAPLE GROVE EXTENSION BOULEVARD APT 1408 is a truncation error in production and a silent truncation in MySQL's non-strict mode.

So assert on the batch as well as the row:

const batch = Array.from({ length: 10_000 }, () => generateAddress(rand));

// Shape: no row missing a field the schema requires.
for (const a of batch) {
  for (const k of ['street', 'city', 'state', 'zip', 'county', 'phone']) {
    assert.ok(a[k], `row missing ${k}: ${JSON.stringify(a)}`);
  }
  assert.ok(a.street.length <= 50, `street exceeds VARCHAR(50): ${a.street}`);
}

// Duplicates, counted rather than assumed away.
const seen = new Set(batch.map((a) => `${a.street}|${a.city}|${a.state}|${a.zip}`));
console.log(`${batch.length - seen.size} duplicate records in ${batch.length}`);

Run that once against the pool sizes you actually have and you will know whether a unique index on the address is viable for your fixtures before CI tells you at an awkward moment.

What a generator cannot tell you

Worth being clear about the boundary, because tooling in this space routinely overstates it:

  • Deliverability. Whether mail arrives at an address requires matching against the USPS address database. A generator has no such match, so a generated street address is not a delivery point and should never be presented as one.
  • ZIP+4. The add-on is derived from the USPS database, not computed. A generated add-on is four syntactically valid digits pointing nowhere.
  • Current assignment. ZIPs are added and retired; a generated code inside a real prefix may not be issued today.
  • Occupancy. No generator knows whether anybody lives at a number it made up — and the point is that nobody does.

A generator's honest claim is narrower and still useful: the city, county, state, ZIP prefix and area code hold together, so your validation layer accepts the record and your test exercises the code you meant to test.

To see this running, the address generator produces records built this way, with the county and ZIP prefix derived from the chosen city.