The first green CI run for OrphanedSid, at the end of the last post, came with a coverage gate I’d just lowered. The workflow wanted 80%. The suite measured 62.28%:
| |
The suite behind that number had more than 2,400 passing tests. A module with three public commands and 57 private function files shouldn’t be able to hide a third of itself from that many tests, so I went looking for where they were pointed.
Getting 62% out of that many tests made me real sad.
Tests of a copy
The first answer came quickly. Six test files defined their own version of the function they were named for, and then tested that. This is the top of Set-ModifiedACL.Tests.ps1 before the fix:
Every test in that file called the copy above, never the Set-ModifiedACL in Private/ACL. The same was true for Get-ACLForRemoval, Invoke-SIDRemoval, New-ACLBackup, Initialize-ScriptExecution and Remove-OrphanedSidEntry. They all passed, and none of them could tell you anything about the module.
The fix commit made each of those files dot-source the real function and mock only what it calls. It also added tests for functions the public commands run that had none at all: the session start and stop, the per-object detector, the identity resolution helpers, the restore helpers, Test-BackupIntegrity and the processing summary. Coverage went to 82%, and the gate went back to 80% in time for 1.0.0.
Pointing tests at the real code found defects straight away. My favorite is in Invoke-RemovalWorkflow, which checked whether Set-ModifiedACL had worked like this:
| |
Despite the name, $applicationSuccess held the result object Set-ModifiedACL returns, with a Success property. Any object is truthy in PowerShell, including one whose Success is $false, so a failed write was reported as applied. It reads the property now:
The others were Remove-OrphanedSidEntry and Find-OrphanedSIDsInObject returning from inside their foreach after the first object, a log rotation reported as done when Move-Item had failed, and a removal count that came out empty on Windows PowerShell 5.1 when exactly one object matched.
A type only RSAT supplies
Test-OrphanedSID catches [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]. On a machine without RSAT, and that includes every CI runner, that type doesn’t exist, and a catch naming an unknown type fails with Unable to find type. Inside Test-OrphanedSID, that failure lands in the outer catch-all, and the catch-all treated it as orphaned. So in the test suite, every SID looked orphaned whatever the mocks said.
The shared stub file now defines a stand-in for the exception when RSAT is absent, the same way it stands in for the cmdlets in the Pester migration post.
Memory tests that measured the garbage collector
Two tests had a memory budget: process a small batch of strings, use less than 50 MB. On the 5.1 runner they measured 58 MB and 60 MB, which is a lot of memory for some strings. Both read the “before” figure with a collection and the “after” figure without one:
| |
The difference between those two numbers was mostly garbage that hadn’t been collected yet, so the test measured when the collector last ran. Collecting on both sides measures what the code kept:
Tests that always passed
Coverage at 82% got 1.0.0 out the door. It didn’t mean every covered line was being checked, and two days later I went back through the suite with one question for each test: if the behavior in its name broke, would it fail?
A lot of them wouldn’t. The 13 files under Tests/Integration and Tests/Performance, about 350 tests, never called the module. Each one defined a simulator and asserted on what the simulator returned:
| |
The name still says FindUnknownSID. Those files were “integration tests” for a script that had been a module for two days, and they’d have passed if the module were deleted.
The unit tests had smaller versions of the same problem. Some asserted on a constant:
Some caught their own failure. In Restore-ACLOperation.Tests.ps1, a failed call-count assertion was caught and replaced with a check that couldn’t fail on its own:
Others had no assertion at all, put timing budgets on calls that take microseconds, or tested an in-file copy like the six above.
What replaced them
The 13 simulator files became one, Tests/Integration/OrphanedSidWorkflow.Tests.ps1. It imports the module and runs Find-OrphanedSid, Remove-OrphanedSid and Restore-OrphanedSidBackup the way you would. Only the boundary is mocked: the AD cmdlets, plus Get-Acl and Set-Acl on AD:\ paths, read and write a hashtable of objects whose security descriptors are real ActiveDirectorySecurity instances.
The fake directory has a small cast of trustees, each there to prove one thing:
| |
The SID lookup behind every AD cmdlet mock answers for those trustees, and throws when it’s supposed to:
| |
Assertions are on what the commands return, what’s left in the directory afterward, and what lands on disk. A test like Keeps the live, built-in and unverified ACEs reads the security descriptors back out of the hashtable after a removal. If the module removed the wrong entry, the descriptor would show it.
The unit tests were rewritten in four batches by area, each file read against the code it tests. Assertions check exact values, messages and the parameters passed to collaborators. Hand-written call recorders from the Pester 3 days became Mock and Should -Invoke with exact counts, and fixtures moved from fixed %TEMP% folders to TestDrive.
Skipping the tests that found bugs
Once the tests could fail, some of them did, because the module was wrong. Those kept their correct assertion and got -Skip, with a comment naming the issue:
That kept the test rewrite separate from the module fixes, and it meant each fix came with a test ready to un-skip. The rewrite filed twenty issues that way, #12 through #31. One is an old friend:
Case-insensitive names had bitten a loop variable before, and here they were again in a different file. It sat in a code path no public command reaches. That’s the only reason it never showed up in the lab.
Checking that the tests can fail
“Every test can fail” is easy to claim. To check it, I broke module behaviors one at a time in a scratch worktree and ran the suite against each:
- removal taking unverified SIDs
- a failed lookup counting as orphaned
- the backup hash not being compared
- a backup written without a hash
- an ACE not being removed
- the safe file name length changing
- an AD failure not being audited
At least one test failed every time. Before the rewrite, the simulator files would’ve passed every one of them.
The numbers
The rewrite took the suite from 2,498 tests to 1,738, and line coverage over Public/ and Private/ from 82.4% to 93.2%. The gate went to 90%.
By 1.1.0, with the twenty skipped issues fixed and a few dozen unused functions deleted along with their tests, it’s 1,374 tests at 94.07%:
That 80% is the workflow’s CoveragePercentTarget, which only sets what Pester reports. The gate that fails the job is a separate check, and it’s 90.
I think those in-file copies were the result of the early GitHub Copilot harness, from before I established my ai-powershell-standards repo. The difference between generic LLM vs focused is insane. Whether it’s mine or your own, I can’t imagine going back to an agent without a standard or style guide.
