The data seeder is one of the three parts of Apex Forge Platform, the Apex I keep in a public repository at github.com/WTWatson/apex-forge-platform on GitHub. It is one class, ForgeDataSeeder, with three private inner classes that implement ForgeDataBuilder.ValueProvider. Its header says what it is for (ForgeDataSeeder.cls, lines 4 to 7). It is a seed-value helper for tests: UK-flavoured names, addresses, phone numbers, postcodes and companies, lorem text and range-bounded primitives, plus ValueProvider implementations for per-record variation under count(n). This page documents what the code does at commit f9bf157, with the class and line for each claim. The class carries the project’s copyright header (lines 1 to 11); its test class, ForgeDataSeederTest, has none. The providers plug into ForgeDataBuilder, whose page comes before this one. How to get the code into a project is on the overview page.
What does each helper return?
A String built from fixed lists and random numbers. Ten private static final lists sit at the top of the class (lines 14 to 76). Every pick from one of them goes through pickString(), which returns null for a null or empty list and otherwise the element at an index from integerBetween(0, size - 1) (lines 195 to 199). The helpers follow in file order, each with the lists it draws on.
- Names.
firstName()picks from 42 first names, Agatha to Winifred (lines 14 to 21 and 78).lastName()picks from 36 last names, Abbott to Worthington (lines 23 to 30 and 80).fullName()joins one of each with a space (line 82). - Email.
email(first, last)lower-cases both names and joins them with a dot, then adds a plus sign, a number from 1000 to 9999 and@example.co.uk(lines 86 to 90). Soemail('Alex', 'Smith')has the shape[email protected].email()with no arguments calls it with a random first and last name (line 84). - Company.
company()joins one of 19 prefixes, Albion to Yorkshire, and one of 10 suffixes with a space (lines 32 to 41 and 92 to 95). The suffixes are Ltd, PLC, Holdings, Group, Partners, & Sons, & Co, Brothers, Trading and Enterprises. - Phone.
phone()returns07, a number from 100 to 999, a space and a number from 100000 to 999999 (lines 97 to 101). Every value has the shape07NNN NNNNNNand is twelve characters long. - Street address.
streetAddress()returns a number from 1 to 9999, a space, a street name, a space and a suffix (lines 103 to 107). The 19 street names run from Albion to Westminster, most of them London street and district names such as Baker, Downing, Piccadilly and Strand, and the 15 suffixes from St and Rd to Hill and Walk (lines 50 to 60). - City.
city()picks from 25 place names, Bath to York (lines 43 to 48 and 109). All of them are towns and cities in England. - Postcode.
postalCode()returns an area code, a number from 1 to 20, a space, a number from 1 to 9 and two letters (lines 111 to 115). The 27 area codes are two letters each, SW to YO (lines 62 to 65), and the letters come from a 24-letter alphabet that leaves out I and O (lines 67 to 70). The result is seven or eight characters long,SW1 1AAorYO20 9ZZin shape. - Lorem.
word()picks from 24 lorem words, lorem to ullamco (lines 72 to 76 and 117).sentence(words)joins that many words with spaces, upper-cases the first character and appends a full stop; a null or a count below 1 becomes 1 (lines 119 to 126).paragraph(sentences)joins that many sentences with spaces, each 5 to 12 words long, with the same floor of 1 (lines 128 to 136).
What do the range helpers do?
They return a value inside the bounds you pass, swapping the two when you pass them the wrong way round, and none of them checks a bound for null.
integerBetween(lo, hi)swapsloandhiwhenlois the larger (line 140) and returnslo + (Integer) (Math.random() * (hi - lo + 1))(line 141). The span ishi - lo + 1, one wider than the difference between the bounds. Three helpers callMath.random()themselves:decimalBetween(),datetimeBetween()andcoinFlip()(lines 147, 163 and 167). Every other random draw in the class goes through this one: the email and phone numbers, the house number, the postcode parts, the paragraph’s word counts (line 133), the day offset indateBetween()(line 155),pickFrom()andpickString().decimalBetween(lo, hi, scale)swaps reversed bounds the same way (line 146), computeslo + (hi - lo) * Decimal.valueOf(Math.random())(line 147) and returns it withsetScale(scale)applied (line 148). It passes no rounding mode, so what happens to the dropped places is the one-argumentsetScale()rule on the Apex Reference Guide’s Decimal Class page, not a choice the seeder makes.dateBetween(lo, hi)askslo.daysBetween(hi)for the day count (line 153). When that is negative the bounds are swapped and the count negated (line 154), and the result islo.addDays(integerBetween(0, days))(line 155): the earlier date moved forward by a whole number of days inside the span. The Date Class page documentsdaysBetween(secondDate)andaddDays(additionalDays).datetimeBetween(lo, hi)works in milliseconds. It takesgetTime()of each bound (lines 160 to 161), swaps them when reversed (line 162), takes(Long) ((hiMs - loMs) * Math.random())as the offset (line 163) and returnsDatetime.newInstance(loMs + offset)(line 164). Unlike line 141 there is no+ 1here. The Datetime Class page documentsgetTime()as the number of milliseconds since January 1, 1970, 00:00:00 GMT, andnewInstance(milliseconds)as a Datetime built from that count.coinFlip()returnsMath.random() < 0.5(line 167).pickFrom(choices)returns null for a null or empty list and otherwise one element at a random index (lines 169 to 173). It takes and returnsObject, so you cast the result.
A null bound reaches the comparison on line 140 or 146, the daysBetween() call on line 153 or the getTime() call on line 160 or 161, and nothing in the class handles that case. The tests never pass one.
How do I vary values across a count(n) list?
With one of the four providers, which ForgeDataBuilder asks for a value once per record index.
ForgeDataBuilder.ValueProvider is an interface with one method, Object get(Integer index) (ForgeDataBuilder.cls, line 25). When a value passed to with() implements it, buildList() calls get(i) with the zero-based record index and puts the result on the record (lines 101 to 104). A plain value is put on every record as it is, so with(Contact.Phone, ForgeDataSeeder.phone()) draws one number when the test calls phone() and gives it to every record in the list. The seeder’s four factories return providers (ForgeDataSeeder.cls, lines 175 to 193), each a private inner class.
sequence(prefix)returns aSequenceProvider, whoseget(i)returns the prefix, a space andi + 1(lines 175 to 178 and 201 to 205). Sosequence('Acct')yieldsAcct 1,Acct 2and so on, a different value for every index by construction.firstNames()andlastNames()return aCycleProviderover the first-name and last-name lists (lines 180 to 188). Itsget(i)returns the entry atMath.mod(i, source.size())(lines 207 to 213), so values repeat once the count passes the length of the list. The 43rd first name is the first again, and the 37th last name likewise. Neither class promises a unique name per record. Index 0 is always Agatha and Abbott, since the index does the choosing and nothing random is involved.emails()returns anEmailProvider, whoseget(i)returnsuser,i + 1, a plus sign, a number from 1000 to 9999 and@example.co.uk(lines 190 to 193 and 215 to 219). The first record gets[email protected]. The index is in every address, soemails()is distinct for every index, assequence()is; the number after the plus is random and changes from run to run.
So sequence() and emails() are distinct for every index, and firstNames() and lastNames() cycle their lists of 42 first names and 36 last names, so values repeat past that count. The builder does not check what a provider returns (ForgeDataBuilder.cls, line 104), and a test can write its own provider with the same one method.
How random is it?
Every random value in the class comes from Math.random(), and nothing seeds it.
Math.random() is called on four lines: 141 in integerBetween(), 147 in decimalBetween(), 163 in datetimeBetween() and 167 in coinFlip(). Everything else draws through integerBetween(): the list picks (lines 172 and 198), the numbers in emails, phones, addresses and postcodes, the paragraph’s word counts (line 133) and the day offset in dateBetween() (line 155). The Apex Reference Guide’s Math Class page documents random() as returning a Double less than 1.0, and it takes no argument. ForgeDataSeeder seeds nothing and keeps no state of its own beyond the ten final lists; a provider holds only its prefix or its list (lines 202 and 208).
For a test, that means no drawn value can be asserted on. A test asserts on the shape instead: that a postcode has a space, that an email ends in @example.co.uk, that an integer lies inside its bounds, that a picked value is a member of its list. That is how ForgeDataSeederTest works, method by method below. The exceptions are the three things the class computes without Math.random(): sequence(), firstNames() and lastNames(). Their values depend on the index alone, so sequence('X').get(9) is always X 10 (ForgeDataSeederTest.cls, line 125) and firstNames().get(0) is always Agatha (ForgeDataSeeder.cls, lines 15 and 211). If a test needs one random value in two places, draw it once into a variable, which is what happens to a plain value passed to with().
A worked example
A test that builds five Contacts without DML, with providers for the names and emails and one shared phone number.
// SeededContactsTest.cls, written against API version 66.0
@IsTest
private class SeededContactsTest {
@IsTest
static void fiveContactsGetSeededNamesAndEmails() {
List<SObject> contacts = ForgeDataBuilder.of(Contact.SObjectType)
.with(Contact.FirstName, ForgeDataSeeder.firstNames())
.with(Contact.LastName, ForgeDataSeeder.lastNames())
.with(Contact.Email, ForgeDataSeeder.emails())
.with(Contact.Phone, ForgeDataSeeder.phone())
.count(5)
.buildList();
Assert.areEqual(5, contacts.size());
Set<String> seen = new Set<String>();
for (SObject s : contacts) {
Contact c = (Contact) s;
Assert.isTrue(c.Email.endsWith('@example.co.uk'));
Assert.isTrue(c.Phone.startsWith('07'));
seen.add(c.Email);
}
Assert.areEqual(5, seen.size());
Assert.areEqual(((Contact) contacts[0]).Phone, ((Contact) contacts[4]).Phone);
}
}
The three providers are asked for a value once per index (ForgeDataBuilder.cls, lines 101 to 103). So the five first names are the first five of the list in order, the five last names likewise, and the five emails carry user1 to user5 and are all different. phone() is a plain String by the time with() sees it, drawn once when the test calls it, so every record carries the same number and the last assertion holds (line 104). No assertion is on a drawn value; each is on a shape, a count or a comparison the code guarantees. Auto-fill is on and fills whatever else Contact requires that it can; LastName is already set. This example was written against the source at f9bf157 and API version 66.0, and uses nothing the source does not provide. It has not been run in an org.
What do the tests prove?
ForgeDataSeederTest.cls is one of the two classes in the ForgeData suite, with ForgeDataBuilderTest (ForgeData.testSuite-meta.xml); the overview page shows the sf apex run test command, which repeats --suite-names for each suite. The twenty methods assert on shape, never on a drawn value, which is the only kind of assertion the randomness allows. In file order, with what each asserts.
scalarsAreNonEmpty(lines 4 to 18):firstName()andlastName()are non-null and non-empty,fullName()contains a space,email()contains@, andcompany(),phone(),city(),streetAddress()andword()are non-empty. It does not check that any value is a member of its list.postcodeLooksUkShaped(lines 20 to 28): the postcode is 6 to 8 characters long and contains a space. The code produces 7 or 8, so the lower bound is slack, and neither the area code nor the letters are checked.emailWithNamesIsWellFormed(lines 30 to 37):email('Alex', 'Smith')startsalex.smith+and ends@example.co.uk. The number between is not checked.sentenceStartsCapitalAndEndsPeriod(lines 39 to 47):sentence(4)starts with an upper-case character and ends with a full stop. The word count is not checked.paragraphHasOneSentencePerRequest(lines 49 to 56):paragraph(3)contains exactly three full stops. That holds because no lorem word contains one.integerBetweenRespectsBounds(lines 58 to 65): fifty draws ofintegerBetween(10, 20)all lie within 10 and 20. It does not check that either bound is ever hit.integerBetweenHandlesSwappedBounds(lines 67 to 74): one draw ofintegerBetween(20, 10)lies within 10 and 20.decimalBetweenRespectsScale(lines 76 to 83):decimalBetween(0, 1, 2)has a scale of 2 and lies within 0 and 1. Swapped bounds are not tried for decimals.dateBetweenRespectsBounds(lines 85 to 94) anddateBetweenHandlesSwappedBounds(lines 96 to 105): one draw across 2026 lies within the bounds, passed either way round.datetimeBetweenRespectsBounds(lines 107 to 116): one draw across 2026 lies within the bounds. There is no swapped-bounds test for datetimes.sequenceProviderEmitsIndexedValues(lines 118 to 126):sequence('X')givesX 1,X 2andX 10for indices 0, 1 and 9.firstNamesProviderYieldsDifferentConsecutiveValues(lines 128 to 140) andlastNamesProviderYieldsDifferentConsecutiveValues(lines 142 to 151): indices 0 and 1 give different values, and for first names both are non-null. Neither test reaches the wrap at the end of the list, so the cycling is read from line 211, not from a passing test.emailsProviderYieldsUniqueAddresses(lines 153 to 165): indices 0 to 4 give five different addresses. The format is not checked.pickFromReturnsMember(lines 167 to 175): the pick from a three-element list is one of the three.pickFromEmptyReturnsNull(lines 177 to 182): an empty list and null both return null.coinFlipReturnsBoolean(lines 184 to 188): the result is not null. Line 167 returns the result of a comparison, so the test asserts little.sentenceHandlesZeroOrNegative(lines 190 to 195):sentence(0)andsentence(null)end with a full stop. Despite the name, no negative count is tried.paragraphHandlesZeroOrNegative(lines 197 to 201):paragraph(0)ends with a full stop. No negative and no null is tried.
Nothing in the suite checks the phone shape, the 07 or the space, nor whether any value drawn from the seeder’s own lists is a member of that list, nor the postcode area or the sentence word count. Nor does it reach the wrap past the end of either name list, the number range in email(), or decimalBetween() and datetimeBetween() with swapped bounds. Those parts of this page are read from the code, not from a passing test.
What does the code not do?
- It draws from its own lists only. Names, companies, cities, streets and area codes come from eight fixed lists (lines 14 to 65). Those hold 42, 36, 19, 10, 25, 19, 15 and 27 entries. The letters come from a 24-letter alphabet and the lorem text from 24 words (lines 67 to 76). Nothing adds to a list or swaps one in;
pickFrom()is the one helper that takes a list of yours. - It knows one country’s shapes. Phone numbers start
07, postcodes follow the area, number, space, digit and letters shape, emails end@example.co.uk, the company suffixes include Ltd and PLC, and the cities are English. There is no locale argument and no second format. - It guarantees no uniqueness beyond the index.
sequence()andemails()differ for every index because the index is in the value (lines 204 and 217). Everything else is a random draw or a cycle: two calls tofirstName()can return the same name,email()can repeat, andfirstNames()repeats from the 43rd record. - It has no seed. Four lines call
Math.random()and nothing sets its state, so a drawn value cannot be reproduced from one run to the next. - It does not check its bounds. The four range helpers swap reversed bounds (lines 140, 146, 154 and 162) but test nothing for null. Only
sentence(),paragraph(),pickFrom()andpickString()guard a null argument (lines 121, 130, 171 and 197). - It never throws its own exception and never touches the database. There is no exception class, no
throw, no DML and no SOQL in the class; every method on the outer class is static and returns a value. - It does not format or round by choice. Dates and datetimes come back as
DateandDatetimevalues (lines 155 and 164), anddecimalBetween()scales with the one-argumentsetScale()and names no rounding mode (line 148).
References
- Apex Forge Platform, github.com/WTWatson/apex-forge-platform at commit f9bf157 (21 April 2026):
ForgeDataSeeder.clsandForgeDataSeederTest.clswith their meta files underforce-app/main/default/classes,ForgeData.testSuite-meta.xmlunderforce-app/main/default/testSuites, andForgeDataBuilder.clsfor theValueProviderinterface and thebuildList()loop. - Apex Reference Guide, Math Class:
random()andmod(). - Apex Reference Guide, Decimal Class:
setScale(scale),valueOf(doubleToDecimal)andscale(). - Apex Reference Guide, Date Class:
daysBetween(secondDate)andaddDays(additionalDays). - Apex Reference Guide, Datetime Class:
getTime()andnewInstance(milliseconds).