LAB -

Automating Active Directory Test User Resets with PowerShell

Ionut Balasa Jul 30, 2026

Testing anything that depends on user state means resetting that state between runs. My feature was tied to Active Directory: group membership, first-login provisioning, permission gates. One test cycle meant creating a batch of users, running the app against them, then tearing it all down so the next run started clean.

By hand in the AD console, that’s a ten-minute ritual. I was doing it twenty-odd times a day. The C# work I was actually paid for kept getting squeezed into the gaps.

So I replaced the ritual with a PowerShell script. This is a field report on that: what broke the first three times, and where scripting stops being the right tool. Nothing here is glamorous. That’s the point. Most of the value in scripting lives in unglamorous, high-frequency chores like this one.

Why not just write a proper tool

I live in C#, WPF, and backend services. My first instinct was a small .NET console app against System.DirectoryServices.AccountManagement. Build it, ship the binary, run it.

That instinct was wrong, for a reason that only shows up once you’re on the machine. The reset has to run where AD is reachable. In practice that meant a jump host and a couple of domain-joined VMs. No Visual Studio, no build toolchain, and in one case no matching .NET SDK.

A compiled tool puts you in a redeploy loop. Change code locally. Rebuild. Copy the artifact across a VPN that drops if you look at it wrong. Run it. Find it’s still wrong. Repeat.

PowerShell skips all of that. It’s already on every one of those boxes, and the ActiveDirectory module ships with RSAT, so it’s present anywhere anyone administers a domain. The source is the executable. When something’s wrong, you edit the file that’s already there and run it again. On a box you’re debugging live, that saves hours.

The docs make the job itself look trivial: New-ADUser, Remove-ADUser, done. What they gloss over is that AD is an eventually-consistent, replicated, stateful system. It does not care about your test loop. That’s where all my time went.

The first version, and why it lied to me

Here’s roughly what I wrote first.

# reset-test-users.ps1  (v1 - broken)
$ou = "OU=QA,OU=Testing,DC=<domain>,DC=<tld>"

Get-ADUser -Filter "Name -like 'qa.test*'" -SearchBase $ou | Remove-ADUser
1..5 | ForEach-Object {
    $name = "qa.test$_"
    New-ADUser -Name $name -SamAccountName $name -Path $ou `
            -AccountPassword (ConvertTo-SecureString 'REDACTED' -AsPlainText -Force) `
            -Enabled $true
    Add-ADGroupMember -Identity "AppTesters" -Members $name
}

It looks reasonable. Three things went wrong with it, in order.

Gotcha 1: Remove-ADUser blocks on a confirmation prompt

Run this unattended, say chained after a build step, and it hangs. No error. No output. Just a script that never returns.

Remove-ADUser treats deletion as high-impact and prompts:

Confirm
Are you sure you want to perform this action?
Performing the operation "Remove" on target "CN=qa.test1,OU=QA,...".
[Y] Yes  [A] Yes to All  [N] No  [L] No to All  [S] Suspend  [?] Help (default is "Y"):

In an interactive session you tap through this without noticing. In an automated one it sits there forever. The fix is -Confirm:$false on every destructive cmdlet:

Get-ADUser -Filter "Name -like 'qa.test*'" -SearchBase $ou |
    Remove-ADUser -Confirm:$false

The general rule: any AD cmdlet that changes state has an opinion about confirmation. An unattended script has to state its own opinion explicitly.

Gotcha 2: create-immediately-after-delete is a race

With confirmation suppressed, teardown and rebuild ran back to back. Intermittently, the rebuild failed:

New-ADUser : The specified account already exists The account I had just deleted. Except it wasn’t gone yet. Remove-ADUser returns as soon as the directory service accepts the deletion, not when the object has actually disappeared. On a single idle DC that window is tiny. Add a second domain controller, or put the DC under load, and the delete and the create can land on different replication states and collide. The docs never mention this. These cmdlets are asynchronous with respect to the thing you actually care about.

The naive fix is Start-Sleep. Don’t. Guess too short and you’re still racing. Guess too long and you pay the penalty on every run, twenty times a day. Poll for the object to actually disappear instead:

function Wait-ADUserGone {
    param([string]$Sam, [int]$TimeoutSec = 10)
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        try   { Get-ADUser -Identity $Sam -ErrorAction Stop | Out-Null }
        catch { return }   # not found = gone, which is what we want
        Start-Sleep -Milliseconds 250
    }
    throw "User $Sam still present after ${TimeoutSec}s"
}

Poll on the specific fact you depend on. Give it a timeout and a real failure if the assumption doesn’t hold. That’s what separates a script that’s flaky at 8 a.m. under load from one that just works.

Gotcha 3: group state wasn’t as clean as I assumed

The last one was subtler, and I’ll be honest: I never fully root-caused it.

Deleting a user is supposed to remove it from its groups, and mostly it did. But every so often I’d rebuild qa.test3, re-add it to AppTesters, and the app would behave as if the account still carried permissions from its previous incarnation. My best guess involves nested groups and replication timing between DCs. I couldn’t reproduce it on demand.

What I can say for certain: the harness behaved inconsistently across runs for reasons that had nothing to do with my code. That’s the worst failure mode a test harness can have. It makes you distrust the thing you’re trying to validate.

Rather than keep chasing the root cause, I stopped treating “delete the user” as sufficient. The reset now reconciles group membership explicitly after creation:

# after creating $name, force group state to exactly what the test expects
$desired = @("AppTesters")
$current = Get-ADPrincipalGroupMembership $name |
        Where-Object { $_.Name -ne "Domain Users" } |
        Select-Object -ExpandProperty Name

$current | Where-Object { $_ -notin $desired } |
    ForEach-Object { Remove-ADGroupMember -Identity $_ -Members $name -Confirm:$false }
$desired | Where-Object { $_ -notin $current } |
    ForEach-Object { Add-ADGroupMember  -Identity $_ -Members $name }

Declare the state you want and reconcile to it. Don’t infer it from a delete you hope was thorough. The inconsistency never came back.

The version that actually runs

Here it all is, folded together. SupportsShouldProcess means -WhatIf does a real dry run before anyone points this at a directory they care about.

<#
.SYNOPSIS
    Resets the AD test-user pool to a known-good baseline.
.PARAMETER Count
    Number of test users to create. Default 5.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [int]$Count = 5,
    [string]$Ou = "OU=QA,OU=Testing,DC=<domain>,DC=<tld>",
    [string]$Group = "AppTesters"
)

Import-Module ActiveDirectory

function Wait-ADUserGone {
    param([string]$Sam, [int]$TimeoutSec = 10)
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
    while ((Get-Date) -lt $deadline) {
        try   { Get-ADUser -Identity $Sam -ErrorAction Stop | Out-Null }
        catch { return }
        Start-Sleep -Milliseconds 250
    }
    throw "User $Sam still present after ${TimeoutSec}s"
}

function Read-TestSecret {
    # Uses the SecretManagement module; swap in whatever vault you use.
    Get-Secret -Name "qa-test-user-password"
}

# --- teardown -------------------------------------------------------------
Get-ADUser -Filter "Name -like 'qa.test*'" -SearchBase $Ou | ForEach-Object {
    if ($PSCmdlet.ShouldProcess($_.SamAccountName, "Remove-ADUser")) {
        Remove-ADUser -Identity $_ -Confirm:$false
        Wait-ADUserGone -Sam $_.SamAccountName
    }
}

# --- rebuild --------------------------------------------------------------
1..$Count | ForEach-Object {
    $name = "qa.test$_"
    $pw   = Read-TestSecret
    if ($PSCmdlet.ShouldProcess($name, "New-ADUser")) {
        New-ADUser -Name $name -SamAccountName $name -Path $Ou `
                -AccountPassword $pw -Enabled $true
        Add-ADGroupMember -Identity $Group -Members $name
    }
}

Write-Host "Reset complete: $Count users in $Group" -ForegroundColor Green

Run .\reset-test-users.ps1 and the environment is pristine in a few seconds. Run it with -WhatIf first to see exactly what it would touch.

The password comes from a secret store, not a literal in the file. A script that lives in a repo and creates enabled accounts is a credential-handling script, whether you meant it to be or not.

One prerequisite I glossed over and shouldn’t have: the script runs as you. Whatever account executes it needs create and delete rights on that OU, plus membership rights on the group. QA already had a delegated service account for the test OU, which is why this was painless for me. If you don’t have that delegation, sorting it out with your AD admins will probably take longer than writing the script.

Where this stops being the right tool

Scripting wins here because it skips the ceremony a real application demands. No project, no build, edit-in-place on the target box. That same skipped ceremony is why it becomes the wrong choice past a certain point.

Reach for a compiled .NET tool the moment the thing grows a real lifespan: a shared utility the whole QA team depends on, anything with a UI, anything that needs a meaningful test suite. Throwaway resets don’t get unit tests. Load-bearing tooling does, and you want a compiler catching mistakes before they reach a directory. In between sits Roslyn scripting (dotnet script, .csx files): the no-project-file feel, but real C#, for teams fluent in C# rather than PowerShell.

The honest structural limit: all of this is Windows-bound. The ActiveDirectory module being just there on any domain-joined box is the whole reason PowerShell wins in this scenario. That advantage evaporates the moment the workflow needs to run first-class on Linux. PowerShell was the sharpest tool available because I was operating in a Microsoft-shaped world.

One more test worth applying before you start: does the job deserve a script at all? Some jobs that feel like scripting jobs are really one-liners. A thing that runs once and gets forgotten doesn’t deserve gold-plating. This reset earned a real script because it ran twenty times a day for weeks. That frequency is the number to look for.

Conclusion

The reset went from a ten-minute chore to one command and a few seconds. Call it the better part of an hour a day back. The bigger win: the harness stopped lying to me, because its setup was finally deterministic. Knowing when a script is the correct answer, and when it has quietly outgrown being one, is the actual engineering call.

If you've got a stack of these chores quietly eating your team's days, or a migration or environment problem that's outgrown its scripts, reach out to Deviqon Labs, because this is the kind of work we do.

About the Author

Ionuț Bălașa is a backend developer at Deviqon Labs, with a path into software that ran through industrial automation and PLC programming before landing in .NET tooling and DevOps.

Subscribe to our newsletter

Rest assured we will not misuse your email