Skip to content

10 · Project — Admin Toolkit Module

Time to bring Level 3 together into one real deliverable: an AdminToolkit module that reports disk space and top CPU consumers, then assembles both into a single JSON report — with validated advanced functions, a proper Public/Private module layout, and Pester tests that mock across module boundaries.

Goal

  1. Get-DiskSpaceReport — reports used/free space per filesystem drive, flagging any drive over a configurable warning threshold.
  2. Get-TopProcesses — the top N processes by CPU time.
  3. New-AdminReport — combines both into one object, writes it as JSON, and reports how many drives are in a warning state.
  4. A private helper (Format-ReportTimestamp) shared internally, not exported.
  5. A Pester suite covering all three public functions, mocking the underlying system cmdlets so tests don't depend on the actual disk layout or running processes of whatever machine runs them.

Project layout

AdminToolkit/
├── AdminToolkit.psd1
├── AdminToolkit.psm1
├── Public/
│   ├── Get-DiskSpaceReport.ps1
│   ├── Get-TopProcesses.ps1
│   └── New-AdminReport.ps1
├── Private/
│   └── Format-ReportTimestamp.ps1
└── Tests/
    └── AdminToolkit.Tests.ps1

The public functions

# Public/Get-DiskSpaceReport.ps1
function Get-DiskSpaceReport {
    [CmdletBinding()]
    param(
        [double]$WarningThresholdPercent = 80
    )
    $drives = Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -ne $null }
    foreach ($d in $drives) {
        $total = $d.Used + $d.Free
        if ($total -eq 0) { continue }
        $pctUsed = [math]::Round(($d.Used / $total) * 100, 1)
        [pscustomobject]@{
            Drive       = $d.Name
            UsedGB      = [math]::Round($d.Used / 1GB, 2)
            FreeGB      = [math]::Round($d.Free / 1GB, 2)
            PercentUsed = $pctUsed
            Status      = if ($pctUsed -ge $WarningThresholdPercent) { "Warning" } else { "OK" }
        }
    }
}
# Public/Get-TopProcesses.ps1
function Get-TopProcesses {
    [CmdletBinding()]
    param([int]$Count = 5)
    Get-Process |
        Sort-Object -Property CPU -Descending |
        Select-Object -First $Count -Property Id, ProcessName,
            @{N='CPU_Seconds'; E={ [math]::Round($_.CPU, 2) }},
            @{N='MemoryMB'; E={ [math]::Round($_.WorkingSet64 / 1MB, 1) }}
}
# Private/Format-ReportTimestamp.ps1
function Format-ReportTimestamp {
    (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
}
# Public/New-AdminReport.ps1
function New-AdminReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$OutputPath,
        [double]$DiskWarningPercent = 80,
        [int]$TopProcessCount = 5
    )

    $disk  = Get-DiskSpaceReport -WarningThresholdPercent $DiskWarningPercent
    $procs = Get-TopProcesses -Count $TopProcessCount

    $report = [pscustomobject]@{
        GeneratedAt  = Format-ReportTimestamp
        Disks        = $disk
        TopProcesses = $procs
        WarningCount = @($disk | Where-Object Status -eq 'Warning').Count
    }

    $report | ConvertTo-Json -Depth 5 | Out-File -FilePath $OutputPath -Encoding utf8
    Write-Verbose "Report written to $OutputPath"
    return $report
}

@(...) around the Where-Object in WarningCount guards against a single-item result: PowerShell unwraps a one-element pipeline result to a scalar, so without @(), .Count on exactly one warning would throw (scalars have no .Count) — a classic collection-vs-scalar trap. Wrapping in @() forces it to stay an array regardless of how many items come back, including zero.

The loader and manifest

# AdminToolkit.psm1
$here = $PSScriptRoot
foreach ($folder in 'Private', 'Public') {
    $path = Join-Path $here $folder
    if (Test-Path $path) {
        Get-ChildItem -Path $path -Filter '*.ps1' | ForEach-Object { . $_.FullName }
    }
}
$publicFunctions = Get-ChildItem -Path (Join-Path $here 'Public') -Filter '*.ps1' |
    ForEach-Object { $_.BaseName }
Export-ModuleMember -Function $publicFunctions
# AdminToolkit.psd1
@{
    RootModule        = 'AdminToolkit.psm1'
    ModuleVersion     = '1.0.0'
    GUID              = 'c1d2e3f4-5566-7788-99aa-bbccddeeff00'
    Author            = 'Mastery Path'
    Description       = 'Small local admin reporting toolkit: disk space, top processes, JSON report.'
    PowerShellVersion = '5.1'
    FunctionsToExport = @('Get-DiskSpaceReport', 'Get-TopProcesses', 'New-AdminReport')
}

Running it

Import-Module ./AdminToolkit.psd1 -Force
Get-DiskSpaceReport | Format-Table
Drive  UsedGB FreeGB PercentUsed Status
-----  ------ ------ ----------- ------
/     204.39   23.88        89.5 Warning
Temp  204.39   23.88        89.5 Warning
Get-TopProcesses -Count 3 | Format-Table
  Id ProcessName                     CPU_Seconds MemoryMB
  -- -----------                     ----------- --------
1260 zoom.us                            10020.11     76.5
1636 Google Chrome for Testing Helper    9941.95     62.5
1495 node                                3329.75     71.1
$r = New-AdminReport -OutputPath ./report.json -Verbose
$r.WarningCount
VERBOSE: Report written to ./report.json
2
{
  "GeneratedAt": "2026-08-26T10:40:01",
  "Disks": [
    {
      "Drive": "/",
      "UsedGB": 204.39,
      "FreeGB": 23.88,
      "PercentUsed": 89.5,
      "Status": "Warning"
    },
    ...
  ]
}

Tests, mocking across the module boundary

# Tests/AdminToolkit.Tests.ps1
BeforeAll {
    Import-Module "$PSScriptRoot/../AdminToolkit.psd1" -Force
}

Describe "Get-DiskSpaceReport" {
    BeforeAll {
        Mock Get-PSDrive -ModuleName AdminToolkit {
            @(
                [pscustomobject]@{ Name = "C"; Used = 90GB; Free = 10GB },
                [pscustomobject]@{ Name = "D"; Used = 20GB; Free = 80GB }
            )
        }
    }

    It "flags a drive over the warning threshold" {
        $result = Get-DiskSpaceReport -WarningThresholdPercent 80
        ($result | Where-Object Drive -eq "C").Status | Should -Be "Warning"
    }

    It "does not flag a drive under the warning threshold" {
        $result = Get-DiskSpaceReport -WarningThresholdPercent 80
        ($result | Where-Object Drive -eq "D").Status | Should -Be "OK"
    }

    It "computes percent used correctly" {
        (Get-DiskSpaceReport | Where-Object Drive -eq "C").PercentUsed | Should -Be 90
    }
}

Describe "New-AdminReport" {
    BeforeAll {
        Mock Get-DiskSpaceReport -ModuleName AdminToolkit {
            @([pscustomobject]@{ Drive="C"; UsedGB=90; FreeGB=10; PercentUsed=90; Status="Warning" })
        }
        Mock Get-TopProcesses -ModuleName AdminToolkit {
            @([pscustomobject]@{ Id=1; ProcessName="test"; CPU_Seconds=1.0; MemoryMB=10.0 })
        }
    }

    It "counts warnings correctly" {
        $path = [System.IO.Path]::GetTempFileName()
        (New-AdminReport -OutputPath $path).WarningCount | Should -Be 1
        Remove-Item $path
    }

    It "writes valid JSON to the output path" {
        $path = [System.IO.Path]::GetTempFileName()
        New-AdminReport -OutputPath $path | Out-Null
        { Get-Content $path -Raw | ConvertFrom-Json } | Should -Not -Throw
        Remove-Item $path
    }
}

Get-PSDrive is mocked with -ModuleName AdminToolkit since Get-DiskSpaceReport calls it internally — the same trap from module 05. Mocking Get-DiskSpaceReport and Get-TopProcesses in the New-AdminReport tests means those tests verify composition (does the report correctly assemble and count what its dependencies return) completely independent of whether the disk logic itself is correct — that part is already covered by its own Describe block above.

Invoke-Pester -Path ./Tests/AdminToolkit.Tests.ps1 -Output Detailed
Describing Get-DiskSpaceReport
  [+] flags a drive over the warning threshold 113ms
  [+] does not flag a drive under the warning threshold 5ms
  [+] computes percent used correctly 11ms
Describing New-AdminReport
  [+] counts warnings correctly 28ms
  [+] writes valid JSON to the output path 48ms
Tests Passed: 5, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

How It Actually Works

The loader pattern this toolkit uses — dot-sourcing every file under Private/ and Public/ from the .psm1, then Export-ModuleMembering only the Public function names — relies entirely on the shared-session- state mechanics from Module 04: dot-sourcing inside a module's own script runs each file's code directly in the module's session state rather than creating a further nested scope, so every private helper becomes visible to every public function as if the whole toolkit were one file, while the manifest/Export-ModuleMember boundary is the only thing that keeps private helpers unreachable from a consumer's scope.

Testing across that module boundary is where Pester's scope-based Mock mechanics (Module 05) get exercised for real: a test that imports the toolkit module and mocks one of its unexported private functions has to do so from a scope Pester can actually inject the mock into — typically by running the mock and the test's It block inside InModuleScope ToolkitModuleName { ... }, which temporarily re-parents the test's execution into the module's own session state so the function-table shadowing Mock relies on actually intercepts calls the public functions make to their private helpers. Without InModuleScope, a Mock declared from the caller's normal test scope can't see (or override) a private function the module never exported, because the mock's shadowing function would be injected into the wrong session state entirely.

The -WhatIf/ShouldProcess gating on any destructive public function in this toolkit works because [CmdletBinding(SupportsShouldProcess)] propagates $WhatIfPreference down through $PSCmdlet.ShouldProcess() calls exactly as covered in Module 02 — bundling several admin actions behind one public function doesn't change that mechanism at all, it just means every risky operation inside the function body needs its own ShouldProcess guard, since the attribute enables the capability per function, not an automatic wrap around everything the function does.

Stretch goals

  • Add a Get-ServiceStatusReport public function checking a list of service names against Get-Service, include it in New-AdminReport, and mock Get-Service in a new test Describe block.
  • Add an -EmailTo parameter to New-AdminReport that, when a warning exists, calls Send-MailMessage (mocked in tests) with the report summarized in the body.
  • Package the module with a ScheduledTask/cron entry (module 09) that runs New-AdminReport nightly and only alerts when WarningCount -gt 0.
  • Add ValidateScript to -OutputPath in New-AdminReport confirming the parent directory exists before attempting the write, with a clear error message if not.