Maester 3.0: a new test engine, and a heads-up for preview users
Maester 3.0 merges into main on Thursday, 8 October 2026 at 10:00 UTC (8 pm AEST). From that moment the preview build of Maester on the PowerShell Gallery is Maester 3.0, and 3.0 has breaking changes.
Your next scheduled run after the merge will pick up 3.0. The biggest change is that Maester 3.0 needs PowerShell 7.4 or later. Windows PowerShell 5.1 is no longer supported. We've worked hard to keep your custom tests (including Pester tests) working, but a rewrite this size will have rough edges.
You're on the preview build if your pipeline does any of these:
Install-Module Maester -AllowPrereleaseorUpdate-Module Maester -AllowPrereleasemaester_version: previewin the Maester GitHub Action
To stay on 2.x, install the release build instead: drop -AllowPrerelease, or pin the version with Install-Module Maester -RequiredVersion 2.3.0. In the GitHub Action, use maester_version: latest.
If you're using Maester interactively and want to see what's coming, the preview is exactly where we want you. Try it, and tell us what breaks.
Every built-in security check is now a plain PowerShell function with its metadata attached. Maester reads that metadata to decide what runs, a small engine runs it, and the results keep the 2.x shape.
This post has two halves. The first half covers what most of you will notice: you no longer install a tests folder, and the way a test is written has changed. The second half is the technical detail for people who build pipelines on Maester, write a lot of custom tests, or just want to know how it works.
Maester 2.x and 3.0 side by sideโ
In 2.x a check came in two parts: a function in the module, and a Pester It block in a *.Tests.ps1 file that you copied into your own folder. Pester found and ran those files, so selecting, skipping and reporting all went through Pester.
Maester 2.x
- A check is a module function plus a Pester
Itwrapper - Tests are copied into your folder with
Install-MaesterTests - Severity lives in a 700-row
maester-config.json - Every check guards its own connection and licence inside a try/catch
- Pester is required, and tags are the only way to select tests
Maester 3.0
- A check is one function with a
[MaesterTest]attribute, plus a.mdfile - Built-in tests ship inside the module and run from it
- Severity, services, licences and authors are declared on the test
- The engine checks connections and licences, and turns exceptions into Error rows
- Pester is only needed for Pester-format custom tests; select by tag or by test ID
You no longer install or update a tests folder: updating the module updates the tests. And nothing needs Pester unless you wrote Pester tests yourself.
Anatomy of a native testโ
A Maester 3.0 test is a pair of files. Every test that ships with Maester is written this way, and your own tests use the same format.
Test.<ID>.ps1
One PowerShell function with a [MaesterTest(...)] attribute. It reads the tenant, decides, and returns $true or $false.
Test.<ID>.md
The description and remediation steps shown in the report, with a %TestResult% placeholder for the result.
Here's a real built-in test, MT.1012, shortened:
function Test-MtCaMfaForRiskySignIn {
[MaesterTest(
Id = 'MT.1012',
Title = 'At least one Conditional Access policy is configured to require MFA for risky sign-ins.',
Severity = 'High',
Category = 'Maester/Entra',
Tag = ('CA', 'Maester'),
Service = 'Graph', # skipped when Graph is not connected
CompatibleLicense = 'AAD_PREMIUM_P2', # skipped when the tenant has no Entra ID P2
Author = 'f-bader'
)]
[CmdletBinding()]
[OutputType([bool])]
param ()
$policies = Get-MtConditionalAccessPolicy | Where-Object { $_.state -eq 'enabled' }
# ... decide, then:
Add-MtTestResultDetail -Result $markdown
return $result
}
Notice what isn't there: no connection check, no licence check, and no outer try/catch. The test declares what it needs, and the engine handles the rest. In 2.x every check carried that boilerplate, and every check got it slightly differently.
The attribute is read, never runโ
Maester reads [MaesterTest] straight from the file's syntax tree. Listing tests, building the catalog and doing a dry run never execute any test code. That's why the values have to be constants: quoted strings, $true/$false, or a list in parentheses.
| Property | What it does |
|---|---|
Id, Title | Identity. The file is named Test.<Id>.ps1, and the ID doubles as a tag. |
Severity | Critical, High, Medium, Low or Info. Your config can override it. |
Category, Tag | Report grouping, and selection with -Tag / -ExcludeTag. |
Service | Services that must all be connected: Graph, ExchangeOnline, Teams, Azure and so on. |
CompatibleLicense | Service plans, any one of which is enough. & joins plans that are all required. |
Preview, LongRunning | Left out of a run unless you include them. |
Platform, TenantType, Cloud | Where the test applies: Windows-only tests, workforce or external tenants, sovereign clouds. |
InstanceSource | Makes the test a family that produces one result per item. |
Author, Contributor | Credit on maester.dev. |
The Markdown fileโ
Disabled Conditional Access policies must say why they are disabled, in the form `Disabled: <reason>`.
#### Remediation action
1. In the Microsoft Entra admin center, open **Protection** > **Conditional Access** > **Policies**.
2. For each policy listed below, add the reason to its name, or delete the policy.
<!--- Results --->
%TestResult%
Everything above <!--- Results ---> is the description in the report. %TestResult% is replaced with what you pass to Add-MtTestResultDetail -Result. The report shows the description even for tests that didn't run, so readers always see what a test is about.
What a test returnsโ
The function's job is to return a verdict. The engine turns it into a result row:
| The function | Result |
|---|---|
returns $true | Passed |
returns $false | Failed |
calls Add-MtTestResultDetail -Investigate | Investigate |
calls Add-MtTestResultDetail -SkippedBecause NotApplicable | Skipped (the call ends the test) |
| throws an error | Error, with the message as the reason |
| returns nothing | Skipped, reason NoResult |
Stray output becomes part of the return value. $list.Add($x) on an ArrayList writes an index to the pipeline, which turns your verdict into an InvalidReturn error. Assign such calls to $null or pipe them to Out-Null.
Parameters you can tuneโ
A test's param() block lists the values a user can change per tenant: thresholds, switches, objects to exclude. Each parameter needs a one-line description, and [MaesterParameter(Kind = ...)] tells a user interface what kind of object to offer.
param(
# Longest validity period, in days, that a certificate may be issued for.
[ValidateRange(1, 3650)]
[int] $MaximumValidityDays = 365,
# Members of these groups are exempt from this check.
[MaesterParameter(Kind = 'Entra.Group')]
[string[]] $ExcludedGroups
)
Values are set in maester-config.json and checked against the param() block before the test runs:
{
"TestSettings": [
{ "Id": "MT.1198", "Parameters": { "MaximumValidityDays": 180 } },
{ "Id": "MT.1012", "Severity": "Critical" },
{ "Id": "MT.1005", "Enabled": false, "Reason": "Handled by another control" }
]
}
That last row matters if you used to delete test files to turn tests off. In 3.0 the built-in tests come from the module, so disable a test in the config instead.
Write your first native testโ
The whole loop works offline until you actually run against a tenant:
# 1. Create Custom/Test.CONTOSO.1001.ps1 and Custom/Test.CONTOSO.1001.md
New-MtTest -Id CONTOSO.1001 -Title 'Guest invitations are restricted' -Service Graph -Severity High
# 2. Validate the metadata. No tenant connection needed.
Get-MtTest -Path ./Custom
# 3. Run just this test while you write it
Connect-Maester
Invoke-MtTest -Path ./Custom/Test.CONTOSO.1001.ps1
# 4. See what a full run would do, without running anything
Invoke-Maester -Path . -DryRun
Use a prefix of your own for IDs (CONTOSO.1001). The MT., CIS., CISA., EIDSCA., ORCA., AD- and AZDO. prefixes belong to the built-in tests.
Your existing Pester tests keep workingโ
Custom tests written for 2.x (*.Tests.ps1) still run, and their results appear in the same report. You just need Pester 5.7.1 or later installed yourself, because Maester no longer installs it. When you're ready to move over, Convert-MtTest -Path ./Custom converts them to native tests and tells you what needs a manual touch.
The life of an Invoke-Maester runโ
Everything below is PowerShell except one step, which runs in a small C# engine that ships inside the module.
Resolve the run config
Merge the config layers into one object: selection, test settings, environment overrides, output options.
Find the test sources
Built-in tests always come from the module. -Path adds your custom folder; -SkipBuiltIn runs your custom tests only.
Discover tests from metadata
Built-ins come from a catalog file in the module. Custom native tests are read from their syntax trees; Pester files get a static inventory.
Work out the selection
Combine -Tag, -ExcludeTag, -TestId, -ExcludeTestId, the include switches and the config. Stale 2.x copies of built-in tests in your folder are skipped.
Build the tenant context
Which services are connected, which licences the tenant has, tenant type, cloud and platform.
Plan: apply the gates
Each test passes the gates in order, or gets a row with a reason code. Parameters from config are bound and type-checked here.
ExecuteC#
The engine runs each planned test, captures its output, enforces timeouts and decides the outcome. Families expand into instances first.
Run Pester-format custom tests
Pester is imported only now, and only if you have Pester tests. Without Pester, those tests become Error rows and everything else still runs.
Build the result and write the outputs
Native and Pester rows merge into one result: HTML report, JSON, Markdown, CSV/Excel, NUnit or JUnit XML, mail and Teams.
-DryRun stops after the plan. You get a full report showing every test that would run, and the reason every other test wouldn't.
Gates and reason codesโ
Every test passes through the same gates, in the same order. The first gate that fails decides the result and the reason code, so the report always tells you why a test didn't run.
The six results are unchanged from 2.x:
The HTML report has a new reason column and filter, so a page full of skipped tests now explains itself.
Configuration layersโ
Config files are found from -Path: the folder, its tests subfolder, and up to five parent folders. Layers merge from the bottom up, and each one only needs the settings it changes.
maester-config.<tenantId>.jsonPer-tenant overrides. Wins over everything below.
Custom/maester-config.jsonYour customisations, kept apart from the root file.
maester-config.jsonThe root config in your folder.
Shipped defaultsThe module's own config: global settings only. Test defaults now live on the tests.
Pipelines can skip discovery entirely with -Config (a path, a hashtable or both) or the MAESTER_CONFIG environment variable.
The C# engineโ
The engine is a 37 KB DLL loaded as a nested module, and it does three jobs:
Attribute types
[MaesterTest] and [MaesterParameter] must be real .NET types for PowerShell to accept them on a function.
Session state
It tracks which test is running, so Add-MtTestResultDetail knows where its result belongs.
The scheduler
It runs each test, captures output and streams, enforces timeouts, handles Ctrl+C, and classifies the outcome.
Reliable timeouts, cancellation, and catching every way a script can end are hard to get right in pure PowerShell, so that part is C#. Everything else (selection, gates, config, families, results) stays in PowerShell. The scheduler is built for parallel runs, but 3.0 runs one test at a time on your session to stay as close to 2.x behaviour as possible.
The DLL is committed to the repository, so you never need the .NET SDK. CI rebuilds it from source on every change and fails if the bytes differ.
Families: one test, many resultsโ
Some checks run once per item: once per Entra recommendation, per Defender for Identity health issue, per drift folder. In 2.x these were Pester -ForEach blocks. In 3.0 the test names an InstanceSource function in the same file, and each item becomes its own row, such as MT.1024.<suffix>. -TestId MT.1024.* selects the whole family. The source only runs when the test actually runs, never during discovery or a dry run.
Results and outputsโ
The result JSON keeps its 2.x shape, so anything that reads it keeps working. Schema 2.1 only adds fields: ReasonCode, ReasonDetail, Source, Format (Native or Pester), Parameters and Diagnostics on each row; TenantContext, RunMetadata and Selection at the top level.
Maester now writes the NUnit 2.5 or JUnit XML file itself, for native and Pester tests together, with the same test-case names your pipelines expect. A run split across processes or containers merges back into one report with Merge-MtMaesterResult -SameRun.
What to do when you move to 3.0โ
-
Run Maester on PowerShell 7.4 or later.
-
Update the module, remove Maester 2.x, and start a new session:
Update-Module Maester -AllowPrerelease -ForceUninstall-Module Maester -MaximumVersion 2.99.99 -AllVersions -
Clean up your tests folder with
Update-MaesterTests -Path ./maester-tests -WhatIf, then without-WhatIf. It removes the 2.x copies of the built-in tests and keeps your custom tests and config. -
If you disabled built-in tests by deleting their files, disable them in
maester-config.jsoninstead. -
If you have Pester-format custom tests, install Pester 5.7.1 or later, or convert them with
Convert-MtTest. -
If a custom test calls a built-in
Test-Mt*function, callInvoke-MtTest -Id <ID>instead. The check functions are no longer exported. -
Try your usual command with
-DryRunfirst to see what would run.
A few results can come out differently from 2.x. The main one: a built-in test that throws is now Error, not Failed, so your failed count can go down and your error count up. The upgrade guide lists every change, and the native test guide covers the format in full. Both are in the preview docs from 8 October.
Tell us what breaksโ
This is the biggest change to Maester since it started. All 747 built-in checks moved to the new format, with tests that hold selection and tags to their 2.x behaviour, but your tenants and pipelines will find things ours didn't. If something breaks, please open an issue with the command you ran and what you saw. The work is in maester365/maester#2334 if you want to dig in.
Contributions have been paused during the rewrite. They reopen once 3.0 is on main, and new checks should use the native format from then on.
