I wrote Active Directory Test Data That Doesn’t Suck in August 2025. It offered a module you cloned from a folder in my scripts repo, imported, and ran:

1
2
3
Import-Module .\ADTestEnvironment.psd1
New-ADTestEnvironment
Remove-ADTestEnvironment -RemoveOUs -Force

Those three lines still work against a copy of the old module, which survives as a compatibility shim forwarding every name it used to export to an installed copy of TestEnvironment. The folder the old post points at holds only a README now, and Active Directory is one provider of six.

This is part two of the series. Part one covered the module as a whole and what makes seed data useful. This one is the Active Directory provider, written against the old post, because the CSV files came through the move almost untouched and nearly everything else did not.

What the numbers did

August 2025Now
Users290311
Security groups8790, carrying 5,969 memberships and 42 nestings
Devices688688, each with an address that resolves both ways
Service accounts2525, eight holding a principal name, one delegating
Organisational unitsnot counted33, or 43 with the edge case subtree
Password policiesnone3 fine-grained, over seeded groups, at three precedences
Group Policy objectsnone1, denying logon to the 25 service accounts, linked to the devices OU
DNSnone2 zones the seed owns, 1,376 records for the devices and 8 more around them
Edge casesnonea container of them, behind -IncludeEdgeCase

The seed data still lives in CSV files in the module’s Data folder and everything but the security group memberships was generated. ADUsers.csv, ADDevices.csv, ADSecurityGroups.csv and ADServiceAccounts.csv are the same files with the same columns, in Providers\AD\Data\, and the memberships are still written by hand because those relationships carry the meaning.

Batching survives and so does the claim about speed: a full seed is still a little over a minute of wall clock for the directory objects. Still PowerShell 5.1 compliant, still OU=TestData at the root of everything, although the container is prefixed now and reads OU=ZZ-TEST-TestData.

Organisational units are the one count the old post never gave, because nothing in it needed one. A default seed builds 33: the root, a container per object class, six category OUs under the groups, four type OUs under the devices, and eighteen department OUs under the users. A seeded person lands in OU=Engineering,OU=Users,OU=ZZ-TEST-TestData, so a script walking a department tree has something to walk. Passing -IncludeEdgeCase adds EdgeCases and nine children below it and takes the count to 43. The fine-grained password policies sit outside the tree entirely.

A re-run reuses whatever already exists and never duplicates it, in every provider. Seeding a domain that is already seeded skips its way through in a few seconds and reports what it skipped, so rebuilding one layer never means tearing down the rest first.

The command names picked up a Test infix along the way: New-ADTestUser, New-ADTestDevice, New-ADTestGroupPolicy. New-ADUser, New-ADGroup, New-ADComputer and Get-ADDomain are real RSAT cmdlets, and a function carrying one of those names would be found ahead of the cmdlet by everything else in the session. A contract test asserts that nothing in the module shadows a cmdlet.

Install, not clone

1
2
3
4
Install-PSResource -Name TestEnvironment

# or, on a host that still only has PowerShellGet v2
Install-Module -Name TestEnvironment -Scope CurrentUser

RequiredModules is empty and a contract test keeps it that way, so importing the module installs nothing. The AD provider needs RSAT’s ActiveDirectory and GroupPolicy modules and imports them at connect time, which is what lets somebody seed an Okta org from a Linux container without being asked for either. -InstallRsat on the connect will install the features if they are missing, opt-in only, since it is a machine-wide change.

Help is compiled now using PlatyPS, so Get-Help New-ADTestUser -Full reads real documentation and Get-Help about_TestEnvironment covers the concepts no single command owns.

Connecting became a step

The old module had nothing to connect to. Active Directory is reached with the caller’s own Windows identity, so there is no credential in the picture and for a long time that meant no connect command either.

1
Connect-TestEnvironment -Provider AD

It takes no credentials and it proves four things once: that the RSAT modules import, that the session is elevated, that a domain answers, and that the seed data is on disk. Without it, the first failure in a seeding run arrives somewhere in the middle, after some objects exist and some do not, and names a missing cmdlet.

It also picks the domain controller everything else will use. A domain can register more controllers than it is running, and automatic discovery resolves the _ldap._tcp.dc._msdcs records without caring which of them answers. Discovery is also cached and re-resolved, so a run can work for twenty minutes and then start failing partway through, which leaves a half-built directory and no obvious cause.

Each candidate gets proved with a real query:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# One real query. Get-ADDomain goes through the same web service every other command in
# this provider uses, so a candidate that satisfies it will satisfy them.
$answers = {
    param($candidate)
    try {
        $null = Get-ADDomain -Server $candidate @common
        return $true
    }
    catch {
        Write-Verbose "Domain controller '$candidate' did not answer: $($_.Exception.Message)"
        return $false
    }
}

A controller named with -Server is tried first and its failure is fatal, because somebody who names a domain controller means it. After that comes the PDC emulator, then every other controller the domain lists, and when discovery itself cannot answer the candidates are read straight out of DNS. A name in DNS, a service that is listening, and a directory that will answer a query are three different states, and only the last one is any use.

Whatever answers gets pinned for the rest of the session:

1
2
3
# The RSAT cmdlets take -Server; the DNS server cmdlets call the same thing -ComputerName.
$script:PSDefaultParameterValues['*-AD*:Server'] = $Server
$script:PSDefaultParameterValues['*-DnsServer*:ComputerName'] = $Server

Setting $PSDefaultParameterValues in module scope reaches the provider’s own functions when they call the RSAT cmdlets and reaches nothing else. Nothing is written into the caller’s session, nothing survives Remove-Module, a command with no -Server parameter is unaffected so the module’s own *-ADTest* functions are untouched despite matching the wildcard, and a caller who has set their own default for those cmdlets still wins. Disconnect-ADEnvironment clears it.

Teardown asks for proof

The old post described cleanup in one line: it nukes the entire TestData OU and everything in it, no prompts, no mercy. That is a fine description of what it did.

Teardown now refuses to delete anything it cannot prove it created. Every object the provider makes carries the seed tag in adminDescription:

1
adminDescription : ZZ-TEST-seed
1
Get-ADObject -LDAPFilter '(adminDescription=ZZ-TEST-seed)'

If you use that attribute for anything, read this before seeding, because the provider writes it on users, computers, groups, service accounts, the OUs themselves and the DNS zone objects.

Why that attribute. It is base schema, present in every AD since Windows 2000, so nothing gets installed and no schema extension happens. It is defined on top, so one attribute covers every class the provider creates. ADUC does not show it in the default view, so it stays out of what a person reads. And nothing else typically writes it, so the tag cannot be defeated by somebody editing a description.

What lost out. extensionAttribute1 through 15 come from the Exchange schema extension and do not exist on a forest that has never had Exchange, verified absent on the lab domain; where they do exist they are usually claimed already by HR synchronisation. A custom attribute was rejected outright: extending the schema is irreversible, an attribute can be deactivated and never deleted, it needs Schema Admins, and it replicates forest-wide permanently. A module whose promise is that teardown removes everything it made has no business leaving a permanent mark on your forest schema.

description carries only what the seed CSV says, so it still reads like a description.

The rule that falls out of the tag is asymmetric, and the asymmetry is the design:

Being inside the seeded container makes an object a candidate. Carrying the tag makes it a target.

A test OU is precisely the kind of place somebody parks a real object for a minute, so the evidence gets checked between finding an object and deleting it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
foreach ($object in @($InputObject | Where-Object { $_ })) {
    if ($object.adminDescription -eq $tag) {
        $object
        continue
    }

    $name = if ($object.Name) { $object.Name } else { $object.DistinguishedName }
    Write-Warning ("The $Kind '$name' is inside the seeded container but does not carry " +
        "$tag in adminDescription, so this module cannot prove it created it. Leaving it alone.")
}

Anything skipped gets named, because quietly declining to delete something is its own kind of surprise. -RemoveOUs will also decline to remove an OU still holding untagged objects, since a recursive delete would take away the object the sweep had just spared, seconds after warning about it.

The tag works in the other direction too. Teardown sweeps the whole domain for it, not only the seeded containers, and reports anything of the module’s own that has ended up somewhere else, so an object moved out of the tree by hand is still accounted for.

Two more things the old command did not have. -Keep takes Users, Devices, ServiceAccounts, Groups, PasswordPolicies, GroupPolicies and Dns, so the layers above the directory can be rebuilt without re-seeding three hundred people:

1
Remove-TestEnvironment -Keep Users, Groups -Force

And -WhatIf beats -Force on every destructive command in every provider, pinned by tests in each one. Somebody passing both switches is asking what would happen, so suppressing the prompt never suppresses the preview.

Service accounts learned Kerberos

Twenty-five accounts, as before, and the CSV gained columns. Eight of them register a service principal name against a seeded server: MSSQLSvc on the SQL host at three ports, HTTP on the reporting and mail hosts, CIFS and HOST on the file server. One registers its SPN on a DNS alias and not on the host it points at, the arrangement that breaks Kerberos the day the alias moves. One account delegates, constrained, from the web tier to the SQL principal.

Unconstrained delegation is never seeded and no switch asks for it. A misconfiguration sitting inert in test data is fine; a live weakness is not, and a review of a seeded domain should find the constrained delegation and nothing worse than it.

The old post mentioned the ServiceAccountPW-timestamp.txt file that appears next to you after a run. That still happens by default. There is now a second path, which came out of the SecretStore post and is the one I use:

1
2
New-ADTestServiceAccount -UseSecretStore
Get-ADTestPasswordFromVault -Name svc-webapp

One detail about the generated passwords is a Windows behaviour that is easy to miss. Complexity is usually described as three of five character classes, and that is half the rule. The check also refuses any password containing the account’s sAMAccountName, or any token of its display name three characters or longer, compared case-insensitively and split on comma, full stop, hyphen, underscore, space, tab and hash. Active Directory reports the refusal as The password does not meet the length, complexity, or history requirement of the domain, naming none of the three conditions.

Seeded accounts walk straight into that. The prefix puts the token TEST on every display name the module writes, and several service accounts carry a three-letter word of their own: Web, SQL, API, CRM, ERP, Dev, Log. Three characters is by far the most likely length to turn up by chance inside sixteen random ones. So the tokens get computed before the password is:

1
2
3
4
5
6
7
PS> Get-ADTestNameToken -DisplayName 'ZZ-TEST-Web Application Service' -SamAccountName 'svc-webapp'

TEST
Web
Application
Service
svc-webapp

Those go to New-TestPassword -NotContaining, which discards any candidate holding a forbidden substring. Measured across sixty thousand generated passwords, leaving them out refuses roughly one seed run in three hundred.

Every device resolves

The 688 devices are the same 688 devices. Each one now carries a unique address in 10.214.0.0/16 and resolves forward and reverse, in two Active Directory-integrated zones the seed owns: a forward zone under the domain and a reverse zone for that range.

Nothing is written into the domain’s own zone. An A record for a machine that does not exist, sitting in production DNS, would have only its name to say it was ours, and name-only ownership is what this provider refuses everywhere else. Both zones are directory objects, so both carry the seed tag, and teardown removes a zone only when it does.

Around the device records sit the shapes a review has to notice: an alias whose target has no computer, an address record with nothing behind it, one name with two addresses, and a reverse record whose forward name is missing.

Finding a zone’s directory object needs an explicit search base, which is the part I would not have guessed. A zone replicated to the domain lives under DC=DomainDnsZones,<domain>, one replicated to the forest under DC=ForestDnsZones,<forest>, and a legacy one under CN=MicrosoftDNS,CN=System. A Get-ADObject with no search base only looks in the domain naming context, finds none of the first two, and returns nothing without complaining:

1
2
3
4
5
$searchBases = @(
    "DC=DomainDnsZones,$DomainDN"
    "DC=ForestDnsZones,$ForestDN"
    "CN=MicrosoftDNS,CN=System,$DomainDN"
)

Removing a seeded child zone also leaves a delegation behind in the domain’s own zone, and that gets cleaned up with it.

Policy over the directory

Three fine-grained password policies sit over seeded groups at three precedences, and the strictest has the lowest number, so it wins for anyone who is also in a weaker group. One applies to a privileged group and never expires a password and never locks the account out. One has complexity off and reversible encryption on, the two settings a review should never find enabled anywhere.

They live in the Password Settings Container rather than under the seeded tree, so a recursive delete of the OU cannot reach them, and like everything else they are claimed at teardown by the tag and never by their names.

One Group Policy object comes with the seed, built by New-ADTestGroupPolicy: it denies logon to all 25 service accounts and links to the devices OU, so a policy report has a real user right assignment to read and a real link to follow.

The edge cases have a container and a command of their own, and -IncludeEdgeCase on the seed is what asks for them. New-ADTestEdgeCase builds the states a CSV cannot express: orphaned SIDs in access control entries, two groups sharing a display name in different OUs, Acme, Inc Contractors with its escaped comma and a user called Nakamura, Yuki, a group holding a computer and a contact alongside a user, an account with no userPrincipalName, and the Kerberos encryption spread that started the whole project.

Those objects are tagged by a sweep after the fact, the one place in the provider that does not tag at creation. Threading the tag through a dozen calls across several nested containers would leave a thirteenth to be forgotten later, and an object added to that command tomorrow gets tagged without anybody remembering to.

Names that are not Latin-1

The people in the old post were Latin, accented at most. Björn Åberg, José Niño, Zoë Müller. That finds the class of string bug Latin-1 exposes and no other.

Nine more people now carry writing systems that break different things, and because the other providers map their bulk tier from this one, the same scripts appear in every lab:

  • 姜 俊誉 carries an ideographic space, U+3000. It is whitespace to a human reader and not U+0020 to anything splitting on a space character.
  • 𠮷田 太郎 has a surname above the basic multilingual plane. One character, two UTF-16 code units, and a .Length of 2 for something that renders as a single glyph.
  • Дмитрий Волков and Ольга Соколова are Cyrillic, with homoglyphs a duplicate check made by eye cannot see.
  • Γιώργος Παπαδόπουλος has Greek’s positional final sigma, so a case fold and a round trip do not necessarily return the string you started with.
  • محمد الأحمد and فاطمة حداد are right-to-left, and do interesting things to anything building a display string by concatenation.
  • José Marchetti renders identically to José Niño, who was already in the data. One is a precomposed é, the other an e followed by a combining acute. Same picture, different bytes, and a comparison that does not normalise calls them different people.
  • Irmak Işık carries the Turkish dotless i, where upper and lower case do not map the way an invariant culture assumes.
  • Ämilia Groß has an eszett, upper-casing into two characters and changing the length of the string.
  • सुनीता चौधरी is Devanagari, with combining vowel signs.

Every login stays plain ASCII. junyuj, taroy, dmitriyv, giorgosp. The script belongs in the display name, where a real directory keeps it, and the key becomes a sAMAccountName or a userPrincipalName. A seed that put Devanagari in a logon name would be testing something no directory would ever hold.

The decomposed name is built from codepoints in the generator and never typed, since an editor that normalised the file on save would erase the case without changing a single visible character. Contract tests pin all of it, because these rows invite exactly the kind of tidying that removes the point: somebody sees a strange-looking name in a CSV and fixes it.

The same people in five directories

This is the one thing the old module could not do at all. The Entra, Authentik, FreeIPA and PingOne providers all build their bulk tier from these CSV files, so Christine Koch is christk in five directories at once, in Engineering, carrying the same title, with the same fourteen people reporting to her in every directory that records a manager. Hybrid identity matching between a domain and a tenant is testable against real name collisions and real manager chains, and a reconciliation script has two populations that correspond.

Phones and printers stay behind in the FreeIPA mapping, since those do not enrol in an identity domain. Workstations and servers cross over, each joining a host group for its kind and one for its office.

Still not a directory to copy

Repeating this from part one, since the AD provider is the one most likely to be pointed at a domain somebody cares about. A fair amount of this data models a badly configured directory on purpose: the reversible-encryption policy, the SPN on the alias, the orphaned SIDs, the group full of things that are not users. It exists so your script has something to be wrong about, and none of it belongs in a template.

Everything that is a live weakness rather than inert data is left out, and there is no switch for any of it.

Running it

1
2
3
4
Connect-TestEnvironment -Provider AD
New-TestEnvironment -ShowProgress -IncludeEdgeCase
Get-TestEnvironmentReport
Remove-TestEnvironment -WhatIf

Get-TestEnvironmentReport writes Console, JSON, CSV or HTML, and the HTML is still the part I did not write by hand. The old post’s three file formats are unchanged; what was a PSCustomObject from -PassThru is now a console view by default. What it counts has grown with the seed: the zones and their record counts, the accounts holding a principal name, the delegation, the Group Policy object and its link, each password policy with its precedence and the group it applies to, and the edge cases by kind. The console view is the one I read during a run; the JSON is the one to diff across a change, since two runs of a deterministic seed should differ in nothing but timestamps.

The unit tests bind against generated stubs under Tests/Stubs, appended to PSModulePath, so the AD suites run on a machine with no RSAT and reach no domain at all. The provider’s own page, with the full inventory and the teardown rules, is at Providers/AD/README.md.

Point it at a domain you can afford to reseed. Mine has been rebuilt more times than I have counted, which is the only reason there was anything to write up.

Next in the series: the Entra provider, where there is no long-lived API key to paste, the bootstrap credential is a human signed in for ninety seconds, and Graph reports replication lag three different ways depending on which call you made.