}
+
+ );
+}
diff --git a/src/components/TutorialChecklist/styles.module.css b/src/components/TutorialChecklist/styles.module.css
new file mode 100644
index 00000000..c7cdb2a1
--- /dev/null
+++ b/src/components/TutorialChecklist/styles.module.css
@@ -0,0 +1,80 @@
+/**
+ * End-of-page tutorial checklist. Reuses the --tut-* tokens declared by
+ * src/components/TutorialTracker/styles.module.css.
+ */
+
+.checklist {
+ margin-top: 3rem;
+ padding: 1.25rem 1.5rem 1.5rem;
+ border: 1px solid var(--tut-border);
+ border-left: 4px solid var(--tut-accent);
+ border-radius: 8px;
+ background: var(--tut-surface);
+}
+
+.checklistComplete {
+ border-left-color: var(--ifm-color-success);
+}
+
+.header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.title {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.count {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--tut-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.items {
+ margin: 1rem 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+ padding: 0.4rem 0.5rem;
+ border-radius: 6px;
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.item:hover {
+ background: var(--tut-track);
+}
+
+.itemChecked {
+ color: var(--tut-muted);
+}
+
+.input {
+ flex: 0 0 auto;
+ /* Nudge the box onto the first line's optical centre. */
+ margin-top: 0.25rem;
+ width: 1rem;
+ height: 1rem;
+ accent-color: var(--tut-accent);
+ cursor: pointer;
+}
+
+.done {
+ margin: 1rem 0 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--ifm-color-success);
+}
diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js
new file mode 100644
index 00000000..6951e17c
--- /dev/null
+++ b/src/components/TutorialTracker/index.js
@@ -0,0 +1,202 @@
+import React from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import TOCItems from '@theme/TOCItems';
+import { useCollapsible, Collapsible } from '@docusaurus/theme-common';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline';
+import styles from './styles.module.css';
+
+function CheckIcon() {
+ return (
+
+ );
+}
+
+function warnUnknownPage(currentPageId, modules) {
+ if (process.env.NODE_ENV !== 'development' || !currentPageId) {
+ return;
+ }
+ const known = modules.some((module) => module.pages.some((page) => page.id === currentPageId));
+ if (!known) {
+ console.warn(
+ `[TutorialTracker] Page "${currentPageId}" is not in the tutorial sidebar, so its ` +
+ 'progress will not be tracked. Check that it sits inside a module folder and is not ' +
+ 'excluded from the autogenerated sidebar.',
+ );
+ }
+}
+
+/**
+ * Headings of the current page, nested under it in the page list.
+ *
+ * `highlight` drives whether useTOCHighlight tracks the active heading. It must be enabled on
+ * exactly one TOC per page: the desktop and mobile trackers are both in the DOM at all times
+ * (CSS decides which is visible), so the mobile one opts out to avoid two components fighting
+ * over the same active-link classes.
+ */
+function PageHeadings({ highlight }) {
+ const { toc, frontMatter } = useDoc();
+
+ if (!toc || toc.length === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+/** Overall progress bar. Shown in both variants, including while the mobile one is collapsed. */
+function ProgressBar({ hydrated, totalDone, totalPages, percent }) {
+ return (
+
+
+
+ );
+}
+
+/** The navigable part: every module, then the current module's pages and headings. */
+function TrackerNav({ hydrated, modules, currentModule, highlight }) {
+ return (
+ <>
+
+
+ {currentModule ? currentModule.label : 'All modules'}
+
+
+ {modules.map((module) => (
+
+ {/* Linking the label to page one makes jumping between modules a single tap,
+ which matters most on mobile where the list is behind a toggle. */}
+ {module.permalink ? (
+
+ {module.label}
+
+ ) : (
+
+ {module.label}
+
+ )}
+ {module.upcoming ? (
+ Soon
+ ) : (
+
+ {hydrated ? `${module.done}/${module.total}` : module.total}
+
+ )}
+
+ )}
+ >
+ );
+}
+
+/**
+ * Tutorial progress tracker. Replaces the table of contents on tutorial pages, with the
+ * current page's headings nested inline so it serves both jobs.
+ *
+ * Desktop renders it in the right-hand column, styled like @theme/TOC. Mobile renders it
+ * above the content and collapses the navigation behind a toggle, the same way the theme's
+ * own TOCCollapsible does — the progress bar stays visible either way, since a progress
+ * tracker that hides your progress is not much of one.
+ */
+export default function TutorialTracker({ currentPageId, variant = 'desktop' }) {
+ const outline = useTutorialOutline(currentPageId);
+ const { hydrated, modules, currentModule, totalPages, totalDone, percent } = outline;
+ const { collapsed, toggleCollapsed } = useCollapsible({ initialState: true });
+
+ warnUnknownPage(currentPageId, modules);
+
+ const isDesktop = variant === 'desktop';
+ const countLabel = hydrated ? `${totalDone}/${totalPages}` : `${totalPages} pages`;
+
+ if (isDesktop) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css
new file mode 100644
index 00000000..07756b00
--- /dev/null
+++ b/src/components/TutorialTracker/styles.module.css
@@ -0,0 +1,338 @@
+/**
+ * Tutorial progress tracker.
+ *
+ * Replaces the table of contents in the right-hand column on tutorial pages, so it is sized
+ * and positioned like @theme/TOC rather than like a banner.
+ *
+ * Follows the --tut-* local custom property idiom used by src/pages/styles.module.css:
+ * declare a light/dark pair at the bottom, consume with var().
+ */
+
+.tracker {
+ font-size: 0.8rem;
+}
+
+/* Mirrors @theme/TOC so the panel behaves like the table of contents it replaces. */
+.trackerDesktop {
+ position: sticky;
+ top: calc(var(--ifm-navbar-height) + 1rem);
+ max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem));
+ overflow-y: auto;
+ /* Right padding keeps the module counts off the column edge and clear of the scrollbar. */
+ padding: 0 0.5rem 0 1rem;
+ border-left: 1px solid var(--tut-border);
+}
+
+@media (max-width: 996px) {
+ .trackerDesktop {
+ display: none;
+ }
+}
+
+/* The mobile variant sits above the content, where the collapsible TOC normally goes, and
+ matches @theme/TOCCollapsible so it reads as part of the theme. */
+.trackerMobile {
+ margin: 1rem 0 1.5rem;
+ padding: 0.4rem 0.8rem 0.6rem;
+ border-radius: var(--ifm-global-radius);
+ background-color: var(--ifm-menu-color-background-active);
+}
+
+.toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.3rem 0;
+ font-size: inherit;
+ text-align: left;
+}
+
+/* Chevron, using the same token as the theme's own collapsible controls. */
+.toggle::after {
+ content: '';
+ flex: 0 0 auto;
+ height: 1.25rem;
+ width: 1.25rem;
+ background: var(--ifm-menu-link-sublist-icon) 50% 50% / 2rem 2rem no-repeat;
+ filter: var(--ifm-menu-link-sublist-icon-filter);
+ transform: rotate(180deg);
+ transition: transform var(--ifm-transition-fast);
+}
+
+.toggleExpanded::after {
+ transform: none;
+}
+
+/* The count sits next to the chevron rather than pinned to the far edge. */
+.toggle .count {
+ margin-left: auto;
+}
+
+.collapsibleContent {
+ border-top: 1px solid var(--ifm-color-emphasis-300);
+ margin-top: 0.6rem;
+}
+
+@media (min-width: 997px) {
+ /* Prevent a hydration flash — the mobile tracker is server-rendered like the theme's own. */
+ .trackerMobile {
+ display: none;
+ }
+}
+
+@media print {
+ .tracker {
+ display: none;
+ }
+}
+
+/* ---- header */
+
+.header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.eyebrow {
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var(--tut-muted);
+}
+
+.count {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--tut-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ---- progress bar */
+
+.bar {
+ height: 4px;
+ margin-top: 0.4rem;
+ border-radius: 999px;
+ background: var(--tut-track);
+ overflow: hidden;
+}
+
+.barFill {
+ height: 100%;
+ border-radius: inherit;
+ background: var(--tut-accent);
+ /* Animates 0% -> real value once progress loads, so the placeholder reads as intentional. */
+ transition: width 400ms ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .barFill {
+ transition: none;
+ }
+}
+
+/* ---- module disclosure */
+
+.modules {
+ margin-top: 0.75rem;
+}
+
+.summary {
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 0.85rem;
+ padding: 0.15rem 0;
+}
+
+.summary::marker {
+ color: var(--tut-muted);
+}
+
+.moduleList {
+ margin: 0.4rem 0 0;
+ padding: 0.4rem 0 0.1rem 0.75rem;
+ border-left: 1px solid var(--tut-border);
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.moduleItem {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+}
+
+.moduleLabel {
+ color: var(--tut-muted);
+}
+
+.moduleLink:hover {
+ color: var(--tut-accent);
+ text-decoration: none;
+}
+
+.moduleLabelCurrent {
+ color: inherit;
+ font-weight: 600;
+}
+
+.moduleCount {
+ color: var(--tut-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.badge {
+ color: var(--tut-muted);
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+/* ---- pages in the current module */
+
+.pageList {
+ margin: 0.6rem 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.pageLink {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+ color: var(--ifm-menu-color);
+ line-height: 1.4;
+}
+
+.pageLink:hover {
+ color: var(--tut-accent);
+ text-decoration: none;
+}
+
+.pageLinkCurrent {
+ color: var(--tut-accent);
+ font-weight: 600;
+}
+
+.marker {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ /* Baseline-aligned with the first line of the title rather than centred on the block. */
+ align-self: flex-start;
+ margin-top: 0.15rem;
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid var(--tut-border-strong);
+ border-radius: 50%;
+ color: transparent;
+}
+
+.markerDone {
+ border-color: var(--tut-marker-bg);
+ background: var(--tut-marker-bg);
+ color: var(--tut-marker-fg);
+}
+
+.icon {
+ display: block;
+}
+
+.pageTitle {
+ min-width: 0;
+}
+
+/* ---- headings of the current page, nested under it */
+
+.headings {
+ margin: 0.15rem 0 0.35rem;
+ /* Indent to line up with the page title, past the completion marker. */
+ padding-left: 1.6rem;
+ list-style: none;
+ font-size: 0.78rem;
+}
+
+.headings ul {
+ margin: 0;
+ padding-left: 0.75rem;
+ list-style: none;
+}
+
+.headings li {
+ margin: 0;
+}
+
+.headings :global(.table-of-contents__link) {
+ display: block;
+ padding: 0.15rem 0;
+ color: var(--tut-muted);
+}
+
+.headings :global(.table-of-contents__link:hover),
+.headings :global(.table-of-contents__link--active) {
+ color: var(--tut-accent);
+ text-decoration: none;
+}
+
+/* Mobile variant: no active-heading tracking, so style the plain link class instead. */
+.headingLink {
+ display: block;
+ padding: 0.15rem 0;
+ color: var(--tut-muted);
+}
+
+.headingLink:hover {
+ color: var(--tut-accent);
+ text-decoration: none;
+}
+
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ---- theme tokens */
+
+:global(:root) {
+ --tut-surface: #f8f9fb;
+ --tut-border: var(--ifm-color-emphasis-200);
+ --tut-border-strong: var(--ifm-color-emphasis-400);
+ --tut-track: var(--ifm-color-emphasis-200);
+ --tut-muted: var(--ifm-color-emphasis-700);
+ /* Accent for active and hovered links. The dark value follows the same rule custom.css
+ applies to --ifm-link-color and .table-of-contents__link--active: the base primary is
+ too dark to read against a dark background. */
+ --tut-accent: var(--ifm-color-primary);
+ --tut-marker-bg: var(--ifm-color-primary);
+ --tut-marker-fg: #fff;
+}
+
+:global([data-theme='dark']) {
+ --tut-surface: rgba(255, 255, 255, 0.03);
+ --tut-border: var(--ifm-color-emphasis-300);
+ --tut-border-strong: var(--ifm-color-emphasis-400);
+ --tut-track: var(--ifm-color-emphasis-300);
+ --tut-muted: var(--ifm-color-emphasis-600);
+ --tut-accent: var(--ifm-color-primary-lightest);
+ --tut-marker-bg: var(--ifm-color-primary-lightest);
+ /* Dark tick on the lighter marker — white on #3eadff is too low contrast. */
+ --tut-marker-fg: #10161d;
+}
diff --git a/src/css/custom.css b/src/css/custom.css
index bfa58328..22b80593 100644
--- a/src/css/custom.css
+++ b/src/css/custom.css
@@ -79,6 +79,8 @@
--ifm-navbar-shadow: none;
--ifm-navbar-link-color: #fff;
--ifm-navbar-link-hover-color: var(--ifm-navbar-link-color);
+ /* Hover overlay shared by the navbar links and the colour-mode toggle. */
+ --nav-hover-overlay: rgb(0, 0, 0, 0.2);
--ifm-code-font-size: 90%;
--ifm-font-weight-semibold: 600;
--ifm-font-size-base: 16px;
@@ -110,12 +112,12 @@
}
/* color mode toggle styling */
-.navbar button[title*='light mode'] {
+.navbar button[class*='toggleButton'] {
color: #fff;
}
-.navbar button[title*='light mode']:hover {
- background: rgb(255, 255, 255, 0.2);
+.navbar button[class*='toggleButton']:hover {
+ background: var(--nav-hover-overlay);
}
/* navbar styling */
@@ -131,8 +133,27 @@
font-size: 19px;
}
-.navbar__link:hover {
- text-decoration: underline;
+/* Add a hover overlay to the navbar links on desktop. Default effect is active color which matches our navbar color. */
+.navbar__link.navbar__item {
+ position: relative;
+ transition: background-color var(--ifm-transition-fast) var(--ifm-transition-timing-default);
+}
+
+.navbar__link.navbar__item:hover {
+ background-color: var(--nav-hover-overlay);
+ border-radius: var(--ifm-global-radius);
+}
+
+/* Adds a border below the active navbar item (sidebar) on desktop. */
+.navbar__link.navbar__item.navbar__link--active::before {
+ content: '';
+ position: absolute;
+ right: var(--ifm-navbar-item-padding-horizontal);
+ bottom: 2px;
+ left: var(--ifm-navbar-item-padding-horizontal);
+ height: 2px;
+ border-radius: 2px;
+ background-color: currentColor;
}
.navbar-sidebar .menu__link,
@@ -216,4 +237,89 @@ table {
max-width: 400px;
padding: 10px;
vertical-align: middle;
-}
\ No newline at end of file
+}
+/**
+ * Diff highlighting in code blocks.
+ *
+ * Driven by the `diff-add` / `diff-remove` magic comments configured in
+ * docusaurus.config.js, e.g. in a powershell block:
+ *
+ * # diff-add
+ * [switch] $Force
+ *
+ * The negative margin lets the highlight span the full width of the block, matching how
+ * Docusaurus styles its own highlighted lines. The +/- marker sits in the block's left
+ * padding so it never shifts the code, and means the change is not signalled by colour alone.
+ */
+pre>code {
+ --diff-gutter: 0.3rem;
+ --diff-border: 3px;
+ --diff-inset: calc(var(--ifm-pre-padding) + var(--diff-gutter));
+}
+
+/* Numbered blocks lay their lines out as a table and zero their own side padding, so they are
+ left alone. The custom properties above stay declared for them either way, so a diff line
+ inside one still resolves its insets. */
+pre>code:not([class*='codeBlockLinesWithNumbering']) {
+ padding-left: var(--diff-inset);
+}
+.code-block-diff-add-line,
+.code-block-diff-remove-line {
+ position: relative;
+ display: block;
+ margin-right: calc(var(--ifm-pre-padding) * -1);
+ margin-left: calc(var(--diff-inset) * -1);
+ padding-right: var(--ifm-pre-padding);
+ padding-left: calc(var(--diff-inset) - var(--diff-border));
+ border-left: var(--diff-border) solid transparent;
+}
+
+/*
+ Boxed across the whole gutter and centred, so the glyph is evenly spaced whatever its width.
+
+ left is 0, not --diff-border: an absolutely positioned box resolves its offsets against the
+ padding box of the positioned ancestor, and that box already starts after the border. Insetting
+ by the border again pushed the marker exactly 3px right of centre.
+*/
+.code-block-diff-add-line::before,
+.code-block-diff-remove-line::before {
+ position: absolute;
+ left: 0;
+ width: calc(var(--diff-inset) - var(--diff-border));
+ text-align: center;
+ font-weight: 700;
+ opacity: 0.7;
+}
+
+.code-block-diff-add-line {
+ background-color: rgba(46, 160, 67, 0.15);
+ border-left-color: #2ea043;
+}
+
+.code-block-diff-add-line::before {
+ content: '+';
+ color: #2ea043;
+}
+
+.code-block-diff-remove-line {
+ background-color: rgba(248, 81, 73, 0.15);
+ border-left-color: #f85149;
+}
+
+.code-block-diff-remove-line::before {
+ content: '-';
+ color: #f85149;
+}
+
+[data-theme='dark'] .code-block-diff-add-line {
+ background-color: rgba(63, 185, 80, 0.18);
+ border-left-color: #3fb950;
+}
+
+[data-theme='dark'] .code-block-diff-add-line::before {
+ color: #3fb950;
+}
+
+[data-theme='dark'] .code-block-diff-remove-line {
+ background-color: rgba(248, 81, 73, 0.18);
+}
diff --git a/src/pages/index.js b/src/pages/index.js
index 20004852..3495e75c 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -5,6 +5,7 @@ import Link from '@docusaurus/Link';
import CodeBlock from '@theme/CodeBlock';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl';
+import { useLayoutDoc } from '@docusaurus/plugin-content-docs/client';
import styles from './styles.module.css';
const GITHUB_URL = 'https://github.com/pester/pester';
@@ -151,6 +152,18 @@ function GitHubStars() {
);
}
+/**
+ * Path to a doc in Docs in whichever version the reader last browsed,
+ * or latest if first visit/never changed or the docId doesn't exist in the older user-preferred version.
+ *
+ * A docId that doesn't exist in any version will throw during build.
+ */
+function useDocPath(docId) {
+ // null is only returned for drafts, which exist in development builds.
+ // 'default' == pluginId for Docs/Commands
+ return useLayoutDoc(docId, 'default')?.path;
+}
+
function Hero() {
const heroImg = useBaseUrl('img/home/hero-terminal.png');
return (
@@ -174,7 +187,7 @@ function Hero() {
-
+
Get Started
@@ -352,10 +365,10 @@ function CallToAction() {
Ready to write your first test?
Install Pester and go from zero to a green test in minutes.
-
+
Get Started
-
+
Installation guide
diff --git a/src/theme/DocItem/Layout/index.js b/src/theme/DocItem/Layout/index.js
new file mode 100644
index 00000000..c5a7ed8b
--- /dev/null
+++ b/src/theme/DocItem/Layout/index.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import OriginalDocItemLayout from '@theme-original/DocItem/Layout';
+import useRouteContext from '@docusaurus/useRouteContext';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+import TutorialTracker from '@site/src/components/TutorialTracker';
+import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData';
+
+/**
+ * Fallback so a tutorial page always shows the progress tracker.
+ *
+ * The tracker normally replaces the table of contents (see the DocItem/TOC wrappers), but
+ * DocItem/Layout only renders either TOC when the page has headings and does not set
+ * `hide_table_of_contents`. Without this, a heading-less tutorial page would silently lose
+ * the tracker altogether. In that case only, render it above the content instead.
+ *
+ * Every tutorial page currently has headings, so this path is not normally taken.
+ *
+ * Note: this is an unsafe swizzle. It only prepends a sibling and forwards props unchanged,
+ * so the blast radius is small, but re-check it when @docusaurus/theme-classic is upgraded.
+ */
+export default function DocItemLayoutWrapper(props) {
+ const { plugin } = useRouteContext();
+ const { metadata, frontMatter, toc } = useDoc();
+
+ const isTutorial =
+ plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID;
+ const tocWillRender = !frontMatter.hide_table_of_contents && toc.length > 0;
+
+ if (!isTutorial || tocWillRender) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/theme/DocItem/TOC/Desktop/index.js b/src/theme/DocItem/TOC/Desktop/index.js
new file mode 100644
index 00000000..fc06d799
--- /dev/null
+++ b/src/theme/DocItem/TOC/Desktop/index.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import OriginalDocItemTOCDesktop from '@theme-original/DocItem/TOC/Desktop';
+import useRouteContext from '@docusaurus/useRouteContext';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+import TutorialTracker from '@site/src/components/TutorialTracker';
+import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData';
+
+/**
+ * On tutorial pages the progress tracker takes the place of the table of contents in the
+ * right-hand column. The current page's headings are nested inside the tracker, so nothing
+ * is lost — see src/components/TutorialTracker.
+ *
+ * Note: this is an unsafe swizzle. Check it when @docusaurus/theme-classic is upgraded.
+ */
+export default function DocItemTOCDesktopWrapper(props) {
+ const { plugin } = useRouteContext();
+ const { metadata } = useDoc();
+
+ const isTutorial =
+ plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID;
+
+ if (!isTutorial) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/theme/DocItem/TOC/Mobile/index.js b/src/theme/DocItem/TOC/Mobile/index.js
new file mode 100644
index 00000000..65b2643c
--- /dev/null
+++ b/src/theme/DocItem/TOC/Mobile/index.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import OriginalDocItemTOCMobile from '@theme-original/DocItem/TOC/Mobile';
+import useRouteContext from '@docusaurus/useRouteContext';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+import TutorialTracker from '@site/src/components/TutorialTracker';
+import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData';
+
+/**
+ * Mobile counterpart of the desktop TOC replacement. Both variants are always in the DOM —
+ * CSS decides which is visible — so the mobile one renders its headings without active-link
+ * tracking to avoid two components competing over the same highlight classes.
+ *
+ * Note: this is an unsafe swizzle. Check it when @docusaurus/theme-classic is upgraded.
+ */
+export default function DocItemTOCMobileWrapper(props) {
+ const { plugin } = useRouteContext();
+ const { metadata } = useDoc();
+
+ const isTutorial =
+ plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID;
+
+ if (!isTutorial) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/theme/MDXComponents.js b/src/theme/MDXComponents.js
new file mode 100644
index 00000000..243b4244
--- /dev/null
+++ b/src/theme/MDXComponents.js
@@ -0,0 +1,11 @@
+import MDXComponents from '@theme-original/MDXComponents';
+import TutorialChecklist from '@site/src/components/TutorialChecklist';
+
+/**
+ * Components registered here are available in every .mdx file without an import.
+ * TutorialChecklist is used at the end of each tutorial page.
+ */
+export default {
+ ...MDXComponents,
+ TutorialChecklist,
+};
diff --git a/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js
new file mode 100644
index 00000000..b6b1ed71
--- /dev/null
+++ b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import useRouteContext from '@docusaurus/useRouteContext';
+import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';
+import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData';
+
+/**
+ * Note: this is an unsafe swizzle. It only adds a guard, so should be low risk,
+ * but check it when @docusaurus/theme-classic is upgraded.
+ */
+export default function DocsVersionDropdownNavbarItemWrapper(props) {
+ const { plugin } = useRouteContext();
+
+ // Only show version dropdown while browsing versioned docs (default plugin)
+ if (plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === 'default') {
+ return (
+ <>
+
+ >
+ );
+ }
+
+ // Hide version dropdown everywhere else, like home page and unversioned tutorial plugin.
+ return null;
+}
diff --git a/src/theme/Root.js b/src/theme/Root.js
new file mode 100644
index 00000000..a68af3ab
--- /dev/null
+++ b/src/theme/Root.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import { TutorialProgressProvider } from '@site/src/tutorial/TutorialProgressContext';
+
+/**
+ * Root is a Docusaurus extension point that wraps the entire app, above the router, and is
+ * never unmounted on client-side navigation. That makes it the right place for the tutorial
+ * progress provider: both the tracker (rendered by the DocItem/Layout wrapper) and the
+ * checklist (rendered from MDX) are descendants, so they share state with no extra wiring.
+ *
+ * The provider is inert outside /tutorial — it holds state that nothing else reads.
+ */
+export default function Root({ children }) {
+ return {children};
+}
diff --git a/src/tutorial/TutorialProgressContext.js b/src/tutorial/TutorialProgressContext.js
new file mode 100644
index 00000000..894529e1
--- /dev/null
+++ b/src/tutorial/TutorialProgressContext.js
@@ -0,0 +1,142 @@
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+
+/**
+ * Tutorial progress, persisted to localStorage.
+ *
+ * The version lives in both the storage key and a `version` field. Bumping the key is the
+ * escape hatch for a breaking rewrite (old data is orphaned and ignored); the field lets us
+ * migrate in place for additive changes.
+ *
+ * Only per-page checkbox booleans are stored. Module and overall totals are always derived
+ * from tutorialData at render time, so restructuring the tutorial can never desync progress.
+ *
+ * Shape:
+ * { version: 1, updatedAt: 0, pages: { "": { items: {"": true}, completedAt: null } } }
+ */
+const STORAGE_KEY = 'pester.tutorial.progress.v1';
+const SCHEMA_VERSION = 1;
+
+const EMPTY_PAGES = {};
+
+const TutorialProgressContext = createContext(null);
+
+/** Coerce whatever is in storage into a valid `pages` object. Never throws. */
+function migrate(raw) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return EMPTY_PAGES;
+ }
+ if (raw.version !== SCHEMA_VERSION) {
+ // Unknown or older schema. Progress is non-critical, so discard rather than guess.
+ return EMPTY_PAGES;
+ }
+ const { pages } = raw;
+ if (!pages || typeof pages !== 'object' || Array.isArray(pages)) {
+ return EMPTY_PAGES;
+ }
+ return pages;
+}
+
+function readStorage() {
+ try {
+ return migrate(JSON.parse(window.localStorage.getItem(STORAGE_KEY)));
+ } catch {
+ // Missing key, invalid JSON, or blocked storage access.
+ return EMPTY_PAGES;
+ }
+}
+
+function writeStorage(pages) {
+ try {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ version: SCHEMA_VERSION, updatedAt: Date.now(), pages }),
+ );
+ } catch {
+ // Safari private mode throws on write, and quota can be exhausted. In-memory state
+ // still works for this session, so a failed write must not break the page.
+ }
+}
+
+export function TutorialProgressProvider({ children }) {
+ // Starts empty so the first client render matches the prerendered HTML exactly. The
+ // effect below fills in the real value after mount, which is a normal update, not a
+ // hydration mismatch.
+ const [pages, setPages] = useState(EMPTY_PAGES);
+ const [hydrated, setHydrated] = useState(false);
+
+ useEffect(() => {
+ setPages(readStorage());
+ setHydrated(true);
+
+ // Keep multiple open tabs in sync.
+ const onStorage = (event) => {
+ if (event.key === STORAGE_KEY) {
+ setPages(readStorage());
+ }
+ };
+ window.addEventListener('storage', onStorage);
+ return () => window.removeEventListener('storage', onStorage);
+ }, []);
+
+ const setItemChecked = useCallback((pageId, itemId, checked, allItemIds) => {
+ setPages((prev) => {
+ const page = prev[pageId] ?? { items: {}, completedAt: null };
+ const items = { ...page.items, [itemId]: checked };
+ const complete = allItemIds.length > 0 && allItemIds.every((id) => items[id] === true);
+ const next = {
+ ...prev,
+ // Preserve the original completedAt so re-checking a box doesn't reset the date.
+ [pageId]: { items, completedAt: complete ? (page.completedAt ?? Date.now()) : null },
+ };
+ writeStorage(next);
+ return next;
+ });
+ }, []);
+
+ const resetPage = useCallback((pageId) => {
+ setPages((prev) => {
+ if (!prev[pageId]) {
+ return prev;
+ }
+ const next = { ...prev };
+ delete next[pageId];
+ writeStorage(next);
+ return next;
+ });
+ }, []);
+
+ const resetAll = useCallback(() => {
+ setPages(EMPTY_PAGES);
+ writeStorage(EMPTY_PAGES);
+ }, []);
+
+ const value = useMemo(
+ () => ({ hydrated, pages, setItemChecked, resetPage, resetAll }),
+ [hydrated, pages, setItemChecked, resetPage, resetAll],
+ );
+
+ return {children};
+}
+
+/**
+ * Access tutorial progress. Returns a safe inert value when used outside the provider so a
+ * stray in a non-tutorial page renders instead of crashing the build.
+ */
+export function useTutorialProgress() {
+ const context = useContext(TutorialProgressContext);
+ if (!context) {
+ return {
+ hydrated: false,
+ pages: EMPTY_PAGES,
+ setItemChecked: () => {},
+ resetPage: () => {},
+ resetAll: () => {},
+ };
+ }
+ return context;
+}
+
+/** True when every checklist item recorded for the page is checked. */
+export function isPageComplete(pages, pageId) {
+ return Boolean(pages[pageId]?.completedAt);
+}
diff --git a/src/tutorial/tutorialData.js b/src/tutorial/tutorialData.js
new file mode 100644
index 00000000..c463fcd6
--- /dev/null
+++ b/src/tutorial/tutorialData.js
@@ -0,0 +1,23 @@
+/**
+ * The tutorial structure is NOT declared here.
+ *
+ * Modules and pages are derived at runtime from the generated sidebar — categories become
+ * modules, doc links become pages, and their labels are the page titles. See
+ * src/tutorial/useTutorialOutline.js. That keeps the sidebar the single source of truth, so
+ * adding a page means adding a file and nothing else.
+ *
+ * Only what the sidebar cannot express lives here.
+ */
+
+export const TUTORIAL_PLUGIN_ID = 'tutorial';
+
+/**
+ * Modules that are planned but not written yet.
+ *
+ * These have no docs, so they cannot come from the sidebar. They are shown in the tracker,
+ * greyed out and not linkable, so the shape of the whole tutorial is visible up front. Delete
+ * an entry when its module is written — the sidebar will pick it up automatically.
+ *
+ * Format: {id: string, label: string}
+ */
+export const upcomingModules = [];
diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js
new file mode 100644
index 00000000..034c4f32
--- /dev/null
+++ b/src/tutorial/useTutorialOutline.js
@@ -0,0 +1,77 @@
+import { useMemo } from 'react';
+import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client';
+import { upcomingModules } from './tutorialData';
+import { isPageComplete, useTutorialProgress } from './TutorialProgressContext';
+
+/**
+ * Derives the tracker's view of the tutorial from the generated sidebar plus stored progress.
+ *
+ * Top-level sidebar categories are modules and their doc links are pages. Because the sidebar
+ * is autogenerated from the folder structure, adding a page to the tutorial needs no change
+ * here, and a page can never be missing from the tracker.
+ *
+ * Sidebar item labels are the page titles: the tutorial pages deliberately set no
+ * `sidebar_label`, and Docusaurus falls back to `title`.
+ *
+ * Totals are derived, never persisted, so restructuring the tutorial cannot leave stored
+ * progress inconsistent.
+ */
+export function useTutorialOutline(currentPageId) {
+ const { hydrated, pages } = useTutorialProgress();
+ const sidebar = useDocsSidebar();
+
+ return useMemo(() => {
+ const written = (sidebar?.items ?? [])
+ // Start from root "Tutorial" category (used for breadcrumbs, see sidebarsTutorial.js)
+ .find((item) => item.type === 'category' && item.label === 'Tutorial')?.items
+ .filter((item) => item.type === 'category')
+ .map((category) => {
+ const modulePages = category.items
+ .filter((item) => item.type === 'link' && item.docId)
+ .map((item) => ({
+ id: item.docId,
+ title: item.label,
+ // Sidebar hrefs already include baseUrl, so they are used as-is.
+ permalink: item.href,
+ done: isPageComplete(pages, item.docId),
+ isCurrent: item.docId === currentPageId,
+ }));
+
+ return {
+ // First segment of a page id is the module folder — stable, unlike the label.
+ id: modulePages[0]?.id.split('/')[0] ?? category.label,
+ label: category.label,
+ upcoming: false,
+ permalink: modulePages[0]?.permalink,
+ pages: modulePages,
+ total: modulePages.length,
+ done: modulePages.filter((p) => p.done).length,
+ isCurrent: modulePages.some((p) => p.isCurrent),
+ };
+ })
+ // A module with no pages would be an empty category; nothing to show.
+ .filter((module) => module.total > 0);
+
+ const planned = upcomingModules.map((module) => ({
+ ...module,
+ upcoming: true,
+ permalink: undefined,
+ pages: [],
+ total: 0,
+ done: 0,
+ isCurrent: false,
+ }));
+
+ const totalPages = written.reduce((sum, m) => sum + m.total, 0);
+ const totalDone = written.reduce((sum, m) => sum + m.done, 0);
+
+ return {
+ hydrated,
+ modules: [...written, ...planned],
+ currentModule: written.find((m) => m.isCurrent) ?? null,
+ totalPages,
+ totalDone,
+ percent: totalPages === 0 ? 0 : Math.round((totalDone / totalPages) * 100),
+ };
+ }, [hydrated, pages, currentPageId, sidebar]);
+}
diff --git a/static/_redirects b/static/_redirects
index cf87b70c..352e6db8 100644
--- a/static/_redirects
+++ b/static/_redirects
@@ -5,3 +5,6 @@
# Redirect versioned URLs for latest (e.g. /docs/v6 -> /docs)
/docs/v6/* /docs/:splat 302
+
+# The tutorial has no landing page - send the bare URL to its first page
+/tutorial /tutorial/introduction/welcome 302
diff --git a/tutorial/1-introduction/1-welcome.mdx b/tutorial/1-introduction/1-welcome.mdx
new file mode 100644
index 00000000..5311d8d3
--- /dev/null
+++ b/tutorial/1-introduction/1-welcome.mdx
@@ -0,0 +1,75 @@
+---
+id: welcome
+title: Welcome
+description: A follow-along tutorial that takes a sample PowerShell module from no tests at all to a fully tested module with mocks, isolated file operations and code coverage
+---
+
+Most Pester documentation is written as reference material — you arrive knowing what you want, and you look it up. This tutorial is the other thing. It is a single story, told in order, where you build one real PowerShell module and test it from nothing to done.
+
+There is no sample project to download. This time you will type every command from scratch, or copy the text if that's what you prefer.
+
+## What you will build
+
+The module is called **Planetarium**. It is small enough to keep in your head and awkward enough to be interesting:
+
+```
+Planetarium/
+├── Planetarium.psd1
+├── Planetarium.psm1
+├── Data/
+│ └── planets.csv
+├── Public/
+│ ├── Get-Planet.ps1
+│ ├── Get-PlanetDistance.ps1
+│ └── Export-PlanetReport.ps1
+└── Private/
+ ├── ConvertTo-AstronomicalUnit.ps1
+ ├── Get-PlanetData.ps1
+ └── Test-PlanetName.ps1
+```
+
+It has public functions that users call, private helpers that they should not, a data file worth faking and a function that writes reports to disk. Each of those is a testing problem with its own chapter.
+
+You will not build it all at once. It starts as an empty folder and grows a piece at a time, with tests arriving alongside each piece.
+
+## How the tutorial is organised
+
+The tutorial is split into modules, and each module into pages. You are on the first page of the first module now.
+
+| Module | What it covers |
+| --- | --- |
+| **Introduction** | What you need installed before you start |
+| **Testing a module** | Building Planetarium and testing it end to end |
+| **Organising tests** | Grouping tests, shared setup, and choosing what runs |
+| **Mocking** | Replacing the data source your code depends on |
+| **Working with files** | Isolating file operations with `TestDrive` |
+| **Code coverage** | Finding the code your tests never touch |
+| **Setting up CI** | Running the whole thing on every push |
+
+They are meant to be done in order — each one builds on the module the previous one left behind.
+
+## Tracking your progress
+
+Every page in this tutorial opens with a progress panel and ends with a checklist.
+
+Tick every box in a page's checklist and that page is marked complete. The progress tracker at the top of the page updates immediately and shows you where you are in the module and in the tutorial overall.
+
+:::note Your progress stays in this browser
+Progress is saved to your browser's local storage. That way you can come back and continue later at your own pace. Nothing is sent to the server, so clearing your browser data will reset it.
+:::
+
+The checklists are for you, not for us. They are worth taking seriously precisely because nobody is checking: if you cannot honestly tick "I can explain why the test failed", the next page will be harder than it needs to be.
+
+## What you should already know
+
+You should be comfortable writing basic PowerShell — functions, parameters, `Get-ChildItem`, piping things around. You do not need to have written a test before, in PowerShell or anywhere else. Every Pester concept is introduced when it first becomes necessary rather than up front.
+
+If you are coming back to Pester after a while, note that this tutorial teaches Pester v6 and a fair amount has changed. Coming from v5, read [v5 to v6](../../docs/migrations/v5-to-v6); coming from v4 or earlier, start with [v4 to v5](../../docs/migrations/v4-to-v5) — the jump from v4 is the larger of the two.
+
+
+
+Next up: getting Pester installed and confirming the version you are running.
diff --git a/tutorial/1-introduction/2-prerequisites.mdx b/tutorial/1-introduction/2-prerequisites.mdx
new file mode 100644
index 00000000..87eb4a3b
--- /dev/null
+++ b/tutorial/1-introduction/2-prerequisites.mdx
@@ -0,0 +1,78 @@
+---
+id: prerequisites
+title: Prerequisites
+description: Install Pester 6, confirm the version you are actually running and set up an editor before starting the tutorial
+---
+
+Before building anything, let's make sure the tools are in place. This page is short, but the version check at the end matters more than it looks — a surprising number of confusing Pester problems turn out to be "you are running an older version than you think".
+
+## PowerShell
+
+Pester v6 runs on:
+
+- **Windows PowerShell 5.1**, or
+- **PowerShell 7.4 or newer**
+
+Check what you have:
+
+```powershell
+$PSVersionTable.PSVersion
+```
+
+If you are on PowerShell 7 but older than 7.4, install a current release from [the PowerShell repository](https://github.com/PowerShell/PowerShell). This tutorial works on Windows, macOS and Linux.
+
+## Installing Pester
+
+Install from the PowerShell Gallery:
+
+```powershell
+Install-Module -Name Pester -Force
+```
+
+On Windows you may already have Pester 3.4.0 installed — it ships with the operating system. That version cannot be updated in place, and it is different enough from v6 that following this tutorial with it will not work. If `Install-Module` complains about an existing installation, the [installation guide](../../docs/introduction/installation) covers the exact commands to get around it.
+
+## Confirming the version
+
+Import the module using `-PassThru` so we can verify the version you have available.
+
+```powershell
+Import-Module Pester -PassThru
+```
+
+```
+ModuleType Version PreRelease Name
+---------- ------- ---------- ----
+Script 6.0.0 Pester
+```
+
+If the version shown starts with a `6`, you are ready.
+
+:::warning An old Pester can be loaded without you asking
+PowerShell loads a module automatically the first time you call one of its commands, and it picks the highest version it can find — but if an older Pester is *already* loaded in your session, it stays loaded. If the version above is not what you expect, open a fresh PowerShell session and check again before doing anything else.
+:::
+
+## An editor
+
+You'll need a text editor to edit the scripts and test files. This tutorial will use the terminal to run tests, so any text editor will work.
+
+For new users, we recommend [Visual Studio Code](https://code.visualstudio.com/) with the [PowerShell extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell) for the best experience: it discovers your tests as you write them and lets you run or debug a single test without leaving the file. See [VS Code page](../../docs/usage/vscode) for more details.
+
+## A folder to work in
+
+Create somewhere to build the module and move into it:
+
+```powershell
+New-Item -Path ./pester-tutorial -ItemType Directory
+Set-Location ./pester-tutorial
+```
+
+Every path in this tutorial is relative to that folder.
+
+
+
+That is the setup done. Next you will create the Planetarium module itself.
diff --git a/tutorial/1-introduction/_category_.json b/tutorial/1-introduction/_category_.json
new file mode 100644
index 00000000..16a97d6e
--- /dev/null
+++ b/tutorial/1-introduction/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Introduction",
+ "collapsed": false
+}
diff --git a/tutorial/2-testing-a-module/1-setup.mdx b/tutorial/2-testing-a-module/1-setup.mdx
new file mode 100644
index 00000000..10e4dc90
--- /dev/null
+++ b/tutorial/2-testing-a-module/1-setup.mdx
@@ -0,0 +1,108 @@
+---
+id: setup
+title: Creating the sample module
+description: Build the Planetarium module scaffold with a manifest and a loader that dot-sources public and private functions
+---
+
+Before you can test a module, you need a module. This page builds the empty shell of Planetarium — no functions yet, just the structure that everything else hangs off. It is deliberately the same structure most real PowerShell modules use, because the testing problems in later pages come *from* that structure.
+
+## The layout
+
+Create the folders from inside your `pester-tutorial` working folder:
+
+```powershell
+New-Item -Path ./Planetarium/Public -ItemType Directory -Force
+New-Item -Path ./Planetarium/Private -ItemType Directory -Force
+```
+
+That gives you the three folders that matter:
+
+- **`Planetarium/`** - the module's root folder.
+- **`Planetarium/Public/`** — functions your users call. These get exported.
+- **`Planetarium/Private/`** — helpers that only the module itself uses, hidden from the users.
+
+That split is the whole reason this module is interesting to test. Public functions are easy to reach from a test. Private ones, by design, are not — that is a page of its own later.
+
+## The loader
+
+The `.psm1` file is the module's root script, executed when you import the module. Ours does not define any functions itself; it finds the `.ps1` files, one per function, and dot-sources them.
+
+```powershell title="Planetarium/Planetarium.psm1"
+$public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -Exclude *.Tests.ps1 -ErrorAction SilentlyContinue)
+$private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -Exclude *.Tests.ps1 -ErrorAction SilentlyContinue)
+
+foreach ($file in @($public + $private)) {
+ . $file.FullName
+}
+
+Export-ModuleMember -Function @($public.BaseName)
+```
+
+Two details are worth pausing on.
+
+`Export-ModuleMember -Function @($public.BaseName)` exports everything in `Public/` and nothing in `Private/`. The folder a file sits in is what decides whether it is part of your public surface — there is no attribute or naming convention doing it.
+
+`-Exclude *.Tests.ps1` allows us to place the tests files next to the code. Without that exclusion the loader would dot-source your test files into the module — and then export them.
+
+:::warning Test files next to your code need excluding from the loader
+If you skip `-Exclude *.Tests.ps1`, your tests get loaded as part of the module. This tends to fail in confusing ways rather than obvious ones, because your `Describe` blocks run at import time. If you would rather keep tests entirely separate, a mirrored `tests/` folder is the other convention Pester supports — see [file placement and naming](../../docs/usage/file-placement-and-naming).
+:::
+
+## The manifest
+
+The `.psd1` manifest is the module's metadata. Generate it rather than writing it by hand:
+
+```powershell
+New-ModuleManifest -Path ./Planetarium/Planetarium.psd1 `
+ -RootModule 'Planetarium.psm1' `
+ -ModuleVersion '0.1.0' `
+ -Author 'Your Name' `
+ -Description 'Explore the solar system.' `
+ -PowerShellVersion '5.1'
+```
+
+Open the generated file and find the `FunctionsToExport` line:
+
+```powershell title="Planetarium/Planetarium.psd1"
+FunctionsToExport = '*'
+```
+
+This is a second gate in front of `Export-ModuleMember`. A function has to pass **both** to be visible to your users. Leaving it as `'*'` means the manifest waves everything through and the `.psm1` makes the real decision, which is what we want while functions are still being added.
+
+:::tip Update before publishing a module
+Using `FunctionsToExport = '*'` is convenient while developing, but should be changed to an array of public function names before publishing. This is required for best performance and auto-import.
+
+For this module: `FunctionsToExport = @('Get-Planet','Get-PlanetDistance','Export-PlanetReport')`.
+:::
+
+## Checking it imports
+
+Nothing is in the module yet, but we can still try to load it:
+
+```powershell
+Import-Module ./Planetarium/Planetarium.psd1 -Force -PassThru
+```
+
+```
+Export-ModuleMember: Cannot bind parameter 'Function' to the target. Exception setting "Function": "Cannot process argument because the value of argument "pattern" is null. Change the value of argument "pattern" to a non-null value."
+
+ModuleType Version PreRelease Name
+---------- ------- ---------- ----
+Script 0.1.0 Planetarium
+```
+
+The error above is expected for now, as there are no function files in the `Public` folder yet.
+
+:::note Why `-Force` appears on every import in this tutorial
+PowerShell will not re-import a module it already has loaded. Without `-Force` you would keep testing the version of the code you imported the first time, and edits would appear to have no effect. This will bite you at least once anyway; now you will know what it is.
+:::
+
+
+
+The shell is up. Next you will write a test — before there is anything to test.
diff --git a/tutorial/2-testing-a-module/2-first-test.mdx b/tutorial/2-testing-a-module/2-first-test.mdx
new file mode 100644
index 00000000..993c4a34
--- /dev/null
+++ b/tutorial/2-testing-a-module/2-first-test.mdx
@@ -0,0 +1,166 @@
+---
+id: first-test
+title: Running your first test
+description: Use New-Fixture to scaffold a function and its test file, watch the test fail, then implement Get-Planet and watch it pass
+---
+
+You are going to write the test before the function exists. Not out of dogma — it is just the fastest way to see what Pester actually does, because a test that fails for the right reason tells you more than one that passes.
+
+## Scaffolding with New-Fixture
+
+Pester ships a command that creates a function and its test file as a matching pair:
+
+```powershell
+New-Fixture -Name Get-Planet -Path ./Planetarium/Public
+```
+
+```
+ Directory: /home/you/pester-tutorial/Planetarium/Public
+
+UnixMode User Group LastWriteTime Size Name
+-------- ---- ----- ------------- ---- ----
+-rw-r--r-- you you 07/19/2026 18:45 94 Get-Planet.ps1
+-rw-r--r-- you you 07/19/2026 18:45 195 Get-Planet.Tests.ps1
+```
+
+Two files. Here is the function:
+
+```powershell title="Planetarium/Public/Get-Planet.ps1"
+function Get-Planet {
+ throw [NotImplementedException]'Get-Planet is not implemented.'
+}
+```
+
+And the test:
+
+```powershell title="Planetarium/Public/Get-Planet.Tests.ps1"
+BeforeAll {
+ . $PSCommandPath.Replace('.Tests.ps1', '.ps1')
+}
+
+Describe "Get-Planet" {
+ It "Returns expected output" {
+ Get-Planet | Should -Be "YOUR_EXPECTED_VALUE"
+ }
+}
+```
+
+That is the anatomy of every Pester test file:
+
+- **`BeforeAll`** runs setup before the tests in its block. Here it loads the code under test by dot-sourcing the `.ps1` next to it — `$PSCommandPath` is the test file's own path, and `.Replace('.Tests.ps1', '.ps1')` turns it into the function's path.
+- **`Describe`** groups related tests and names the thing being tested.
+- **`It`** is a single test. The name should read as a sentence describing behaviour.
+- **`Should`** is the assertion.
+
+:::warning Loading code belongs inside `BeforeAll`, not at the top of the file
+Pester reads every test file twice — once to discover what tests exist, then again to run them. Code at the top level of the file runs during *both* passes. Putting your dot-source or `Import-Module` inside `BeforeAll` keeps it in the run pass, where it belongs. [Discovery and run](../../docs/usage/discovery-and-run) explains what this two-phase model buys you.
+:::
+
+## Watching it fail
+
+Run it:
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Public
+```
+
+```
+Running tests from 1 files.
+[-] Get-Planet.Returns expected output 29ms
+ NotImplementedException: Get-Planet is not implemented.
+ at Get-Planet, /home/you/pester-tutorial/Planetarium/Public/Get-Planet.ps1:2
+ at , /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1:7
+Tests completed in 333ms
+Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+`[-]` marks a failed test. Read the two `at` lines from the bottom up: line 7 of the test file called the function, and line 2 of the function threw. That is the correct failure — the scaffold is wired up properly and the function genuinely does not work yet.
+
+## Implementing the function
+
+Replace the generated function with something real:
+
+```powershell title="Planetarium/Public/Get-Planet.ps1"
+function Get-Planet {
+ [CmdletBinding()]
+ param (
+ [string] $Name = '*'
+ )
+
+ $planets = @(
+ [PSCustomObject] @{ Name = 'Mercury'; Order = 1; DistanceFromSunKm = 57909050 }
+ [PSCustomObject] @{ Name = 'Venus'; Order = 2; DistanceFromSunKm = 108208000 }
+ [PSCustomObject] @{ Name = 'Earth'; Order = 3; DistanceFromSunKm = 149598023 }
+ [PSCustomObject] @{ Name = 'Mars'; Order = 4; DistanceFromSunKm = 227939200 }
+ [PSCustomObject] @{ Name = 'Jupiter'; Order = 5; DistanceFromSunKm = 778570000 }
+ [PSCustomObject] @{ Name = 'Saturn'; Order = 6; DistanceFromSunKm = 1433530000 }
+ [PSCustomObject] @{ Name = 'Uranus'; Order = 7; DistanceFromSunKm = 2872460000 }
+ [PSCustomObject] @{ Name = 'Neptune'; Order = 8; DistanceFromSunKm = 4495060000 }
+ )
+
+ $planets | Where-Object Name -Like $Name
+}
+```
+
+Now the test needs to assert something true. Replace the `It` block:
+
+```powershell title="Planetarium/Public/Get-Planet.Tests.ps1"
+BeforeAll {
+ . $PSCommandPath.Replace('.Tests.ps1', '.ps1')
+}
+
+Describe 'Get-Planet' {
+ It 'Returns all eight planets by default' {
+ (Get-Planet).Count | Should-Be 8
+ }
+}
+```
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Public
+```
+
+```
+Running tests from 1 files.
+[+] /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1 305ms
+Tests completed in 313ms
+Tests Passed: 1, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+`[+]` and one passing test. All fixed.
+
+## A note on the assertion
+
+You may have noticed the generated test used `Should -Be` and we switched to `Should-Be`. Both are real, both work, and the hyphen is the entire difference in appearance.
+
+:::note You will see both `Should-Be` and `Should -Be`
+`Should-Be` is a command in its own right, added in Pester v6, and is [the recommended way to assert in v6](../../docs/assertions/should-command). `Should -Be` is the older operator style — `Should` with a `-Be` parameter — and it is what nearly all existing Pester code, blog posts and Stack Overflow answers use, including the file `New-Fixture` generates.
+
+They coexist in v6 and you can mix them freely, even in one file. This tutorial uses the newer `Should-Be` style throughout.
+:::
+
+## Reading a failure
+
+Failures are the output you will spend the most time with, so break one on purpose. Change the expected count to `9` and run again:
+
+```
+Running tests from 1 files.
+[-] Get-Planet.Returns all eight planets by default 70ms
+ Expected [int] 9, but got [int] 8.
+ at (Get-Planet).Count | Should-Be 9, /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1:7
+Tests completed in 355ms
+Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Put the `8` back and confirm you are green again before moving on.
+
+
+
+One passing test against a dot-sourced file. Next, testing it the way it will actually be used — as a module.
diff --git a/tutorial/2-testing-a-module/3-public-functions.mdx b/tutorial/2-testing-a-module/3-public-functions.mdx
new file mode 100644
index 00000000..fd84d6d6
--- /dev/null
+++ b/tutorial/2-testing-a-module/3-public-functions.mdx
@@ -0,0 +1,189 @@
+---
+id: public-functions
+title: Testing public functions
+description: Switch from dot-sourcing to importing the module so your tests exercise the same public surface your users get, and add a function that depends on private helpers
+---
+
+The test you have works, but it is testing something slightly different from what your users will run. It dot-sources one `.ps1` file directly. Your users import a module. This page closes that gap.
+
+## Why dot-sourcing is not enough
+
+Dot-sourcing `Get-Planet.ps1` loads that one file and nothing else. That means your test suite cannot tell you:
+
+- whether `Get-Planet` is actually **exported** — a function missing from `FunctionsToExport` passes every dot-sourced test and is invisible to users
+- whether the function works when its **private helpers** are loaded alongside it, which is the only way it ever runs in reality
+
+Importing the module instead fixes both. The test then calls the function through the same front door your users do, and a broken export becomes a test failure instead of a support ticket.
+
+## Importing the module
+
+Change the `BeforeAll` in your test file:
+
+```powershell title="Planetarium/Public/Get-Planet.Tests.ps1"
+BeforeAll {
+ # diff-remove
+ . $PSCommandPath.Replace('.Tests.ps1', '.ps1')
+ # diff-add
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+```
+
+`$PSScriptRoot` is the folder holding the test file, so `../Planetarium.psd1` walks up from `Public/` to the manifest. Import the **manifest**, not the `.psm1` — the manifest is what your users get, and it is the thing that carries `FunctionsToExport`.
+
+:::tip `-Force` is not optional here
+Without it, a module already loaded in your session stays loaded, and you will spend a genuinely upsetting amount of time wondering why your fix changed nothing.
+:::
+
+## Filling out the tests
+
+With the module imported, write tests for the behaviour that actually matters:
+
+```powershell title="Planetarium/Public/Get-Planet.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Get-Planet' {
+ It 'Returns all eight planets by default' {
+ (Get-Planet).Count | Should-Be 8
+ }
+
+ It 'Returns the planets in order from the sun' {
+ $names = (Get-Planet).Name
+ $names[0] | Should-Be 'Mercury'
+ $names[-1] | Should-Be 'Neptune'
+ }
+
+ It 'Returns a single planet when given an exact name' {
+ $planet = Get-Planet -Name 'Earth'
+ $planet.Name | Should-Be 'Earth'
+ $planet.Order | Should-Be 3
+ }
+
+ It 'Supports wildcards' {
+ (Get-Planet -Name 'M*').Name | Should-BeCollection @('Mercury', 'Mars')
+ }
+
+ It 'Returns nothing for an unknown planet' {
+ Get-Planet -Name 'Pluto' | Should-BeNull
+ }
+}
+```
+
+Three assertions worth calling out:
+
+`Should-BeCollection` is not the same as `Should-Be`. Pester v6 splits value assertions from collection assertions: `Should-Be` compares one thing to one thing, `Should-BeCollection` compares sizes and then items. Reach for the collection one whenever the actual value is a list.
+
+`Should-BeNull` covers the "found nothing" case. It is easy to skip this test and easy to regret it — a filter that silently returns everything when it matches nothing is a classic bug.
+
+Multiple assertions in one `It` are fine. The test stops at the first failure, so keep them related, as they are here.
+
+Run it:
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Public
+```
+
+```
+Tests Passed: 5, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+## A function with dependencies
+
+Now add a function that leans on the private side of the module. First the two helpers:
+
+```powershell title="Planetarium/Private/ConvertTo-AstronomicalUnit.ps1"
+function ConvertTo-AstronomicalUnit {
+ param (
+ [Parameter(Mandatory)]
+ [double] $Kilometre
+ )
+
+ [math]::Round($Kilometre / 149597870.7, 3)
+}
+```
+
+```powershell title="Planetarium/Private/Test-PlanetName.ps1"
+function Test-PlanetName {
+ param (
+ [string] $Name
+ )
+
+ $known = 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune'
+ $Name -in $known
+}
+```
+
+Then the public function that uses both:
+
+```powershell title="Planetarium/Public/Get-PlanetDistance.ps1"
+function Get-PlanetDistance {
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+
+ if (-not (Test-PlanetName -Name $Name)) {
+ throw "Unknown planet '$Name'."
+ }
+
+ $planet = Get-Planet -Name $Name
+ ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm
+}
+```
+
+This is where importing the module pays off. `Get-PlanetDistance` calls two functions that live in other files. Dot-sourcing `Get-PlanetDistance.ps1` on its own would fail immediately with `The term 'Test-PlanetName' is not recognized`. Importing the module loads everything together, exactly as it runs in production.
+
+## Testing it
+
+```powershell title="Planetarium/Public/Get-PlanetDistance.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Get-PlanetDistance' {
+ It 'Reports Earth as one astronomical unit from the sun' {
+ Get-PlanetDistance -Name 'Earth' | Should-Be 1
+ }
+
+ It 'Reports Mercury as closer to the sun than Earth' {
+ Get-PlanetDistance -Name 'Mercury' | Should-BeLessThan 1
+ }
+
+ It 'Throws for a planet it does not know' {
+ { Get-PlanetDistance -Name 'Pluto' } | Should-Throw -ExceptionMessage "Unknown planet 'Pluto'."
+ }
+}
+```
+
+The error-case test is the interesting one. Note the braces: `{ Get-PlanetDistance -Name 'Pluto' }` is a **scriptblock**, not a call. You are handing Pester the code to run, so it can catch what comes out. Without the braces the exception would escape and fail the test before `Should-Throw` ever saw it.
+
+`-ExceptionMessage` pins down *which* error you expect. Leaving it off would pass on any exception at all, including a typo in the function name — so a test that asserts "it throws" often ends up asserting nothing useful.
+
+:::warning Do not test a missing mandatory parameter this way
+It is tempting to add `{ Get-PlanetDistance } | Should-Throw` to check `-Name` is required. Do not. A missing mandatory parameter makes PowerShell *prompt* rather than throw, and your test run will hang there forever waiting for input that is never coming — which in CI means a build that times out with no useful output.
+:::
+
+Run the whole module:
+
+```powershell
+Invoke-Pester -Path ./Planetarium
+```
+
+```
+Tests Passed: 8, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Eight passing tests across two files. For more on module-aware testing, [Unit testing within modules](../../docs/usage/modules) is the reference companion to this page.
+
+
+
+Both private helpers are now covered only indirectly, through the public function that happens to call them. Next: testing them head-on.
diff --git a/tutorial/2-testing-a-module/4-private-functions.mdx b/tutorial/2-testing-a-module/4-private-functions.mdx
new file mode 100644
index 00000000..6398e459
--- /dev/null
+++ b/tutorial/2-testing-a-module/4-private-functions.mdx
@@ -0,0 +1,129 @@
+---
+id: private-functions
+title: Testing private functions
+description: Reach non-exported module functions with InModuleScope, and understand why it should be your last resort rather than your default
+---
+
+`ConvertTo-AstronomicalUnit` and `Test-PlanetName` are not exported. Calling them from a test the ordinary way does not work:
+
+```powershell
+ConvertTo-AstronomicalUnit -Kilometre 149597870.7
+```
+
+```
+The term 'ConvertTo-AstronomicalUnit' is not recognized as a name of a cmdlet, function, script file, or executable program.
+```
+
+That is not a bug — it is the module doing its job. Private functions are private. But sometimes you still want a test pointed directly at one, and Pester gives you a way in.
+
+## Should you, though?
+
+Before the mechanics, the honest answer about when to use this.
+
+Private functions are already tested by your public tests. Every `Get-PlanetDistance` test you wrote on the previous page ran `Test-PlanetName` and `ConvertTo-AstronomicalUnit` too. Testing through the public surface is the better default: it tests the behaviour users depend on, and it lets you restructure your internals freely without rewriting tests.
+
+Reaching inside earns its keep when a helper has meaningful logic of its own — edge cases, rounding, parsing, boundaries — that would need a contrived public call to reach. `ConvertTo-AstronomicalUnit` qualifies: its rounding behaviour is worth pinning down directly rather than inferring from a distance lookup.
+
+:::warning Prefer testing through your public functions
+The official guidance in [Unit testing within modules](../../docs/usage/modules) is blunt about this: `InModuleScope` "prevents you from properly testing your published functions, does not ensure that your functions are actually published and slows down test discovery by loading the module. Aim to avoid it altogether ... or at least limit it to inside the `It` block."
+
+That last clause is the practical rule. Keep `InModuleScope` inside individual `It` blocks, never wrapped around your `Describe`.
+:::
+
+## InModuleScope
+
+`InModuleScope` runs a script block as if it were code inside the module, so everything private is in reach:
+
+```powershell title="Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'ConvertTo-AstronomicalUnit' {
+ It 'Is not exported from the module' {
+ Get-Command -Module Planetarium -Name 'ConvertTo-AstronomicalUnit' -ErrorAction SilentlyContinue |
+ Should-BeNull
+ }
+
+ It 'Converts one astronomical unit of kilometres to 1' {
+ InModuleScope Planetarium {
+ ConvertTo-AstronomicalUnit -Kilometre 149597870.7 | Should-Be 1
+ }
+ }
+
+ It 'Rounds to three decimal places' {
+ InModuleScope Planetarium {
+ ConvertTo-AstronomicalUnit -Kilometre 57909050 | Should-Be 0.387
+ }
+ }
+}
+```
+
+Note the shape: each `InModuleScope` sits *inside* an `It`, wrapping only the code that needs module access.
+
+The first test is the odd one out, and it is deliberate. It asserts the function is **not** exported, from outside the module scope — an executable statement that this helper is meant to stay private. If someone later moves the file into `Public/`, that test fails and asks them whether they meant to.
+
+## Passing values in
+
+A script block handed to `InModuleScope` does not inherit your test's variables. This does not work:
+
+```powershell
+$name = 'Earth'
+InModuleScope Planetarium {
+ Test-PlanetName -Name $name | Should-BeTrue # $name is empty in here
+}
+```
+
+Use `-Parameters` to hand values across the boundary. Combined with `-ForEach`, it makes short work of table-driven cases:
+
+```powershell title="Planetarium/Private/Test-PlanetName.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Test-PlanetName' {
+ It 'Accepts <_>' -ForEach @('Mercury', 'Earth', 'Neptune') {
+ InModuleScope Planetarium -Parameters @{ Name = $_ } {
+ Test-PlanetName -Name $Name | Should-BeTrue
+ }
+ }
+
+ It 'Rejects Pluto' {
+ InModuleScope Planetarium {
+ Test-PlanetName -Name 'Pluto' | Should-BeFalse
+ }
+ }
+}
+```
+
+`-ForEach` runs the `It` once per item, and `<_>` in the test name is replaced with the current value (`$_`) — so this produces three separately named, separately reported tests rather than one loop that stops at the first failure. [Data driven tests](../../docs/usage/data-driven-tests) goes further with this.
+
+`-Parameters @{ Name = $_ }` passes the current item in, where it arrives as `$Name`.
+
+:::tip Things you create inside `InModuleScope` do not persist
+Variables and functions defined inside the script block disappear when it ends. If you need one to outlive the block, use the `script:` scope modifier.
+:::
+
+## Running it
+
+```powershell
+Invoke-Pester -Path ./Planetarium
+```
+
+```
+Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Fifteen tests: five for `Get-Planet`, three for `Get-PlanetDistance`, three for `ConvertTo-AstronomicalUnit` and four for `Test-PlanetName` — the `-ForEach` block counting as three.
+
+ to generate one test per case'},
+ {id: 'fifteen', label: 'My suite reports 15 passing tests'},
+]} />
+
+Next up: configuring the run and customizing Pester's output.
diff --git a/tutorial/2-testing-a-module/5-output.mdx b/tutorial/2-testing-a-module/5-output.mdx
new file mode 100644
index 00000000..892a6ccf
--- /dev/null
+++ b/tutorial/2-testing-a-module/5-output.mdx
@@ -0,0 +1,181 @@
+---
+id: output
+title: Configuring the run
+description: Control what Pester prints with New-PesterConfiguration, inspect the result object in code and write a test result file for other tools to read
+---
+
+So far every run has used `Invoke-Pester -Path`, which is fine for one file and limiting for everything else. This page covers the configuration object, the three audiences for Pester's output, and how to get the right one for each.
+
+## The configuration object
+
+`New-PesterConfiguration` returns a settings object you fill in and hand to `Invoke-Pester`:
+
+```powershell
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.Output.Verbosity = 'Detailed'
+
+Invoke-Pester -Configuration $config
+```
+
+This is how Pester v6 is configured. `Invoke-Pester` offers a few convenience parameters like `-Path` and `-Output` for quick runs, but most features and customizations will require the configuration option.
+
+Discovering what is available is easiest from the object itself, since every setting carries its own description:
+
+```powershell
+$config.Output
+```
+
+The full list is in [Configuration](../../docs/usage/configuration).
+
+## Verbosity
+
+`Output.Verbosity` decides how much you see. The useful values are `None`, `Normal`, `Detailed` and `Diagnostic`.
+
+`Normal` is the default and reports per file — right for a suite you expect to pass:
+
+```powershell
+$config.Output.Verbosity = 'Normal'
+Invoke-Pester -Configuration $config
+```
+
+```
+Running tests from 4 files.
+[+] /home/you/pester-tutorial/Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1 311ms
+[+] /home/you/pester-tutorial/Planetarium/Private/Test-PlanetName.Tests.ps1 45ms
+[+] /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1 87ms
+[+] /home/you/pester-tutorial/Planetarium/Public/Get-PlanetDistance.Tests.ps1 48ms
+Tests completed in 502ms
+Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+`Detailed` reports every individual test, grouped by `Describe`:
+
+```powershell
+$config.Output.Verbosity = 'Detailed'
+Invoke-Pester -Configuration $config
+```
+
+```
+Pester v6.0.0
+
+Running tests from 4 files.
+
+Running tests from '/home/you/pester-tutorial/Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1'
+Describing ConvertTo-AstronomicalUnit
+ [+] Is not exported from the module 42ms
+ [+] Converts one astronomical unit of kilometres to 1 19ms
+ [+] Rounds to three decimal places 3ms
+
+Running tests from '/home/you/pester-tutorial/Planetarium/Private/Test-PlanetName.Tests.ps1'
+Describing Test-PlanetName
+ [+] Accepts Mercury 16ms
+ [+] Accepts Earth 2ms
+ [+] Accepts Neptune 2ms
+ [+] Rejects Pluto 2ms
+...
+Tests completed in 536ms
+Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+This is the view that makes your test *names* matter. Read those three `Accepts` lines — that is the `-ForEach` block from the previous page, and `<_>` is why each case is named after its own data instead of appearing three times as `Accepts <_>`.
+
+Reach for `Diagnostic` when a test is behaving impossibly and you need to see Pester's discovery and mock decisions. It is a firehose; do not start there.
+
+## The result object
+
+Printed output is for humans. When you want to process the result in code, for a release gate, a summary comment, a custom report — ask for the result object instead:
+
+```powershell
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.Run.PassThru = $true
+
+$result = Invoke-Pester -Configuration $config
+
+"Result: $($result.Result)"
+"Passed: $($result.PassedCount) Failed: $($result.FailedCount) Total: $($result.TotalCount)"
+"Duration: $($result.Duration)"
+```
+
+```
+Result: Passed
+Passed: 15 Failed: 0 Total: 15
+Duration: 00:00:00.4371584
+```
+
+`Run.PassThru` is what makes `Invoke-Pester` return anything — without it you get output on screen and nothing you can act on.
+
+Every test is reachable through `$result.Tests`, which is how you build your own reporting:
+
+```powershell
+$result.Tests |
+ Select-Object Name, Result, @{ Name = 'Error'; Expression = { $_.ErrorRecord.Exception.Message } }
+```
+
+[The result object](../../docs/usage/result-object) documents the full structure.
+
+## Test result files
+
+The third audience is other tools like CI systems and automated reporting solutions. They prefer test result files in popular formats, which they can use to annotate failures and render reports.
+
+```powershell
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.TestResult.Enabled = $true
+$config.TestResult.OutputPath = './testResults.xml'
+
+Invoke-Pester -Configuration $config
+```
+
+```xml title="testResults.xml"
+
+
+
+```
+
+The default format is `NUnitXml`, which nearly every CI system understands. `JUnitXml` and `NUnit3` are also available via `TestResult.OutputFormat`. See [Test results](../../docs/usage/test-results) for which to pick.
+
+The file is a build artifact — something a run produces, not source. If you keep your projects under version control, add it to your `.gitignore`:
+
+```
+testResults.xml
+```
+
+## Putting it together
+
+Retyping that configuration every time gets old fast. Save it as `test.ps1` in the root of your working folder — the one containing `Planetarium`:
+
+```powershell title="test.ps1"
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.Output.Verbosity = 'Detailed'
+$config.TestResult.Enabled = $true
+$config.TestResult.OutputPath = './testResults.xml'
+
+Invoke-Pester -Configuration $config
+```
+
+```powershell
+./test.ps1
+```
+
+This is how you run the full suite for the rest of the tutorial, which later modules will extend on. When you are iterating on one file, `Invoke-Pester -Path ./Planetarium/Public/Get-Planet.Tests.ps1` is still the quicker loop. `./test.ps1` is for "is everything still green".
+
+:::note This script does not fail yet
+A failing test does not, on its own, make `Invoke-Pester` return an error — so depending on how this script is invoked, a pipeline can report success over a suite full of red. The [CI module](../ci/test-script) fixes that with one more line, once there is a pipeline to fix it for.
+:::
+
+
+
+## What you have built
+
+Starting from an empty folder, you now have a PowerShell module with a manifest, a loader, public and private functions, and fifteen tests covering all of it — exported behaviour, error cases, wildcard filtering and internal helpers — plus a script that runs them all and emits a results file.
+
+The remaining modules pick up from exactly here. The next one reorganises the tests you already have, so the suite stays readable as it grows. Then the planet data moves out of the function and into a file, which finally gives you a dependency worth mocking. `Export-PlanetReport` arrives and needs `TestDrive` to test without leaving debris. Code coverage finds a branch none of these tests touch. And the whole thing ends up running on three operating systems on every push.
diff --git a/tutorial/2-testing-a-module/_category_.json b/tutorial/2-testing-a-module/_category_.json
new file mode 100644
index 00000000..840f7d16
--- /dev/null
+++ b/tutorial/2-testing-a-module/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Testing a module",
+ "collapsed": false
+}
diff --git a/tutorial/3-organising-tests/1-grouping-tests.mdx b/tutorial/3-organising-tests/1-grouping-tests.mdx
new file mode 100644
index 00000000..f9897783
--- /dev/null
+++ b/tutorial/3-organising-tests/1-grouping-tests.mdx
@@ -0,0 +1,155 @@
+---
+id: grouping-tests
+title: Grouping with Context
+description: Group related tests with Context, learn when each of BeforeAll, BeforeEach, AfterEach and AfterAll runs, and explain a failing assertion with -Because
+---
+
+Fifteen tests, four files, and every file is a flat list of `It` blocks. That is fine at fifteen. It stops being fine somewhere around fifty, when "which of these tests are about wildcards again?" becomes a real question.
+
+This page is about structure. No new behaviour gets tested — the suite still reports fifteen at the end — but it will be easier to read for the rest of the tutorial.
+
+## Context
+
+`Context` groups related tests inside a `Describe`. It takes a name and a script block, exactly like `Describe` does, and it can hold its own setup.
+
+The convention worth adopting: **`Describe` names the thing, `Context` names the situation. `It` names what we're testing**
+
+Restructure `Get-Planet.Tests.ps1`:
+
+```powershell title="Planetarium/Public/Get-Planet.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Get-Planet' {
+ Context 'When called without a name' {
+ It 'Returns all eight planets' {
+ (Get-Planet).Count | Should-Be 8
+ }
+
+ It 'Returns them in order from the sun' {
+ $names = (Get-Planet).Name
+ $names[0] | Should-Be 'Mercury'
+ $names[-1] | Should-Be 'Neptune'
+ }
+ }
+
+ Context 'When filtering by name' {
+ It 'Returns a single planet for an exact name' {
+ $planet = Get-Planet -Name 'Earth'
+ $planet.Name | Should-Be 'Earth'
+ $planet.Order | Should-Be 3
+ }
+
+ It 'Supports wildcards' {
+ (Get-Planet -Name 'M*').Name | Should-BeCollection @('Mercury', 'Mars')
+ }
+
+ It 'Returns nothing for an unknown planet' {
+ Get-Planet -Name 'Pluto' | Should-BeNull
+ }
+ }
+}
+```
+
+```powershell
+./test.ps1
+```
+
+```
+Describing Get-Planet
+ Context When called without a name
+ [+] Returns all eight planets 7ms
+ [+] Returns them in order from the sun 5ms
+ Context When filtering by name
+ [+] Returns a single planet for an exact name 6ms
+ [+] Supports wildcards 17ms
+ [+] Returns nothing for an unknown planet 1ms
+```
+
+Same five tests, now indented under the situation they belong to.
+
+Notice what happened to the names. `Returns all eight planets by default` became `Returns all eight planets` — the "by default" is now implied. A test name should not have to restate its own conditions once a `Context` states them.
+
+## The setup and teardown family
+
+You have been using `BeforeAll` since the first test. It has three siblings, and the difference is *how often* they run:
+
+| Block | Runs |
+| --- | --- |
+| `BeforeAll` | Once, before the first test in its block |
+| `BeforeEach` | Before every test in its block |
+| `AfterEach` | After every test in its block |
+| `AfterAll` | Once, after the last test in its block |
+
+All four can go in a `Describe` or a `Context`, and they apply to everything nested inside. That is why the single `BeforeAll` at the top of your file — importing the module — is still doing its job for tests that now live two levels deep.
+
+The ordering is easiest to believe when you watch it. Drop this in a scratch file and run it:
+
+```powershell title="scratch/Ordering.Tests.ps1"
+Describe 'Ordering' {
+ BeforeAll { Write-Host 'BeforeAll' }
+ BeforeEach { Write-Host ' BeforeEach' }
+ AfterEach { Write-Host ' AfterEach' }
+ AfterAll { Write-Host 'AfterAll' }
+
+ It 'first' { }
+ It 'second' { }
+}
+```
+
+```powershell
+Invoke-Pester -Path ./scratch/Ordering.Tests.ps1 -Output Detailed
+```
+
+```
+BeforeAll
+Describing Ordering
+ BeforeEach
+ AfterEach
+ [+] first 23ms
+ BeforeEach
+ AfterEach
+ [+] second 1ms
+AfterAll
+```
+
+`BeforeAll` and `AfterAll` ran once each, no matter how many tests follow; `BeforeEach` and `AfterEach` ran once per test.
+
+The `[+]` lines look out of place — each appears *after* its own `AfterEach` rather than between the two. That is a reporting artifact, not an ordering one: Pester prints a test's result line only once the test is completely finished, and `AfterEach` is part of finishing.
+
+Delete that file once you have seen it — it is a demonstration, not part of the suite.
+
+:::tip Which one to reach for
+Use `BeforeAll` for anything expensive or read-only — importing a module, loading fixture data. Use `BeforeEach` when a test would otherwise inherit state from the test before it.
+
+Planetarium never ends up needing `BeforeEach` — every test builds what it needs and changes nothing.
+:::
+
+## Explaining a failure with -Because
+
+Every `Should-*` assertion takes a `-Because` parameter. The text is folded into the failure message:
+
+```powershell
+(Get-Planet).Count | Should-Be 8 -Because 'the solar system has eight planets'
+```
+
+```
+[-] Returns all eight planets 77ms
+ Expected [int] 8, because the solar system has eight planets, but got [int] 9.
+```
+
+It earns its place when the expected value is a magic number whose origin is not obvious from the test. `Should-Be 8` says what; `-Because 'the solar system has eight planets'` says why, to whoever is troubleshooting the error in the future.
+
+It is noise on an assertion that already explains itself, so use it sparingly rather than everywhere.
+
+
+
+Next: running a selection of tests.
diff --git a/tutorial/3-organising-tests/2-choosing-what-runs.mdx b/tutorial/3-organising-tests/2-choosing-what-runs.mdx
new file mode 100644
index 00000000..e1908efc
--- /dev/null
+++ b/tutorial/3-organising-tests/2-choosing-what-runs.mdx
@@ -0,0 +1,164 @@
+---
+id: choosing-what-runs
+title: Choosing what runs
+description: Label tests with -Tag and filter on them, skip tests conditionally, and use BeforeDiscovery to build the data that -ForEach and -Skip need
+---
+
+Running everything is the right default. It stops being practical the moment part of your suite is slow, or platform-specific, or only meaningful against a real database.
+
+Pester gives you different tools for that, and the difference matters: **filters** decide what gets selected to run, **skip** marks a test as deliberately not run.
+
+## Tagging tests
+
+The most used filter in Pester is tags. `-Tag` goes on `Describe`, `Context` or `It`, and is inherited by everything inside. Your two private-function files are a natural group — they reach into the module with `InModuleScope`, and they are the tests you would drop first if you only wanted to check the public surface:
+
+```powershell title="Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1"
+# diff-remove
+Describe 'ConvertTo-AstronomicalUnit' {
+# diff-add
+Describe 'ConvertTo-AstronomicalUnit' -Tag 'Internal' {
+```
+
+```powershell title="Planetarium/Private/Test-PlanetName.Tests.ps1"
+# diff-remove
+Describe 'Test-PlanetName' {
+# diff-add
+Describe 'Test-PlanetName' -Tag 'Internal' {
+```
+
+Now you can run the public surface on its own:
+
+```powershell
+Invoke-Pester -Path ./Planetarium -ExcludeTagFilter 'Internal'
+```
+
+```
+Tests Passed: 8, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 7
+```
+
+Or only the internals:
+
+```powershell
+Invoke-Pester -Path ./Planetarium -TagFilter 'Internal'
+```
+
+```
+Tests Passed: 7, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 8
+```
+
+The same two filters exist on the configuration object as `Filter.Tag` and `Filter.ExcludeTag`, which is how you would set them in `test.ps1` or from a CI variable.
+
+:::note `NotRun` is not `Skipped`
+Read those totals again: the filtered-out tests are counted as **NotRun**, and `Skipped` stays at zero.
+
+Pester discovers every test either way — that is how it knows there are fifteen — then runs only the ones your filter selected. `NotRun` means "not selected". `Skipped` means "selected, then deliberately passed over", which is the next section. Keeping them in separate columns means a filtered run cannot quietly hide a test you thought was executing.
+:::
+
+Typical tags used in projects are `Slow`, `Integration`, `Unit`, `WindowsOnly` etc. The useful test is whether you would ever want to run with the tag, or without it — a tag nobody filters on is just a comment.
+
+## Skipping tests
+
+`-Skip` marks a test as not to be run, while keeping it visible in the output:
+
+```powershell
+It 'Talks to the real API' -Skip {
+ # not written yet
+}
+```
+
+```
+[!] Talks to the real API 1ms
+```
+
+`[!]` rather than `[+]` or `[-]`, and the summary counts it under `Skipped`. That visibility is the entire point: a skipped test nags, whereas a commented-out test is invisible and eventually forgotten.
+
+It becomes very useful when combined with a **condition**:
+
+```powershell
+It 'Uses the Windows registry' -Skip:(-not $IsWindows) {
+ # ...
+}
+```
+
+On Windows this runs; everywhere else it reports as skipped instead of failing. That is how a cross-platform suite handles the parts that genuinely cannot run everywhere — and it is how you would keep the CI matrix in the last module green if the module ever grew a platform-specific feature.
+
+Both `-Skip` examples above are illustrations rather than tests to add — Planetarium has nothing that needs skipping yet.
+
+## BeforeDiscovery
+
+Here is the catch, and it is the one thing on this page that trips people up.
+
+`-Skip:(-not $IsWindows)` is evaluated when Pester *discovers* your tests, not when it runs them. Same for the `-ForEach` list you wrote back in the private-functions page. So a variable set in `BeforeAll` is no good to either of them — `BeforeAll` has not run yet.
+
+`BeforeDiscovery` is the block that runs during discovery, and it exists precisely for building the data those parameters need. Let's try it out:
+
+```powershell title="Planetarium/Private/Test-PlanetName.Tests.ps1"
+# diff-add-start
+BeforeDiscovery {
+ $KnownPlanets = @('Mercury', 'Earth', 'Neptune')
+}
+# diff-add-end
+
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Test-PlanetName' -Tag 'Internal' {
+ # diff-remove
+ It 'Accepts <_>' -ForEach @('Mercury', 'Earth', 'Neptune') {
+ # diff-add
+ It 'Accepts <_>' -ForEach $KnownPlanets {
+ InModuleScope Planetarium -Parameters @{ Name = $_ } {
+ Test-PlanetName -Name $Name | Should-BeTrue
+ }
+ }
+
+ It 'Rejects Pluto' {
+ InModuleScope Planetarium {
+ Test-PlanetName -Name 'Pluto' | Should-BeFalse
+ }
+ }
+}
+```
+
+```powershell
+./test.ps1
+```
+
+```
+Describing Test-PlanetName
+ [+] Accepts Mercury 16ms
+ [+] Accepts Earth 2ms
+ [+] Accepts Neptune 2ms
+ [+] Rejects Pluto 2ms
+```
+
+Identical results — three separately named tests, same as before. What changed is that the list now has a name and a home, so it can grow, be read from a file, or be shared between several `It` blocks.
+
+That is the everyday use of `BeforeDiscovery`: **generating tests from data.** One test per file in a folder, one per row of a fixture, one per supported culture.
+
+:::warning A variable from `BeforeDiscovery` is not available inside your tests
+`BeforeDiscovery` runs in the discovery pass and `It` bodies run in the run pass, and variables do not survive the trip. `$KnownPlanets` is empty if you reference it inside an `It`.
+
+The way across is the data itself. `-ForEach` hands each item to the test as `$_`, and when you pass hashtables their keys arrive as named variables — available in `BeforeAll` too, not just in `It`:
+
+```powershell
+Context 'Earth' -ForEach @(@{ Planet = 'Earth'; Order = 3 }) {
+ BeforeAll { $expected = "$Planet is number $Order" }
+ It 'knows its order' { $expected | Should-Be 'Earth is number 3' }
+}
+```
+
+If you want the full picture of which code runs in which pass, [Discovery and run](../../docs/usage/discovery-and-run) is the page to read — it is the model that explains most surprising Pester behaviour.
+:::
+
+
+
+Next we'll be testing code without its dependency.
diff --git a/tutorial/3-organising-tests/_category_.json b/tutorial/3-organising-tests/_category_.json
new file mode 100644
index 00000000..046ef9b9
--- /dev/null
+++ b/tutorial/3-organising-tests/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Organising tests",
+ "collapsed": false
+}
diff --git a/tutorial/4-mocking/1-a-real-dependency.mdx b/tutorial/4-mocking/1-a-real-dependency.mdx
new file mode 100644
index 00000000..8e6349cf
--- /dev/null
+++ b/tutorial/4-mocking/1-a-real-dependency.mdx
@@ -0,0 +1,115 @@
+---
+id: a-real-dependency
+title: Giving the module a dependency
+description: Move the planet data out of the function and into a CSV file shipped with the module, creating a real dependency worth isolating in tests
+---
+
+`Get-Planet` holds its data in a hard-coded array, so it has no dependencies and always behaves identically. That is convenient and unrealistic — real functions read files, call APIs and query databases.
+
+This page moves the data into a file. The next two pages will then deal with the consequences.
+
+## Moving the data into a file
+
+Create a `Data` folder in the module and put the planets in a CSV:
+
+```powershell
+New-Item -Path ./Planetarium/Data -ItemType Directory -Force
+```
+
+```csv title="Planetarium/Data/planets.csv"
+Name,Order,DistanceFromSunKm
+Mercury,1,57909050
+Venus,2,108208000
+Earth,3,149598023
+Mars,4,227939200
+Jupiter,5,778570000
+Saturn,6,1433530000
+Uranus,7,2872460000
+Neptune,8,4495060000
+```
+
+## Reading it
+
+Add a private helper that loads the file. It is private because it is an implementation detail — users ask for planets, not for the file the planets happen to live in.
+
+```powershell title="Planetarium/Private/Get-PlanetData.ps1"
+function Get-PlanetData {
+ [CmdletBinding()]
+ param ()
+
+ $path = Join-Path $PSScriptRoot '../Data/planets.csv'
+
+ Import-Csv -Path $path | ForEach-Object {
+ [PSCustomObject] @{
+ Name = $_.Name
+ Order = [int] $_.Order
+ DistanceFromSunKm = [double] $_.DistanceFromSunKm
+ }
+ }
+}
+```
+
+Two things here are load-bearing.
+
+**`$PSScriptRoot` resolves to the folder of the file that defines the function** — `Private/` — even though the function is dot-sourced into the module. That is what makes `../Data/planets.csv` correct regardless of the directory the user happens to be in when they call your module.
+
+**The `ForEach-Object` block re-types the data.** `Import-Csv` returns every column as a string, so without this `Order` would be `'3'` rather than `3`.
+
+Your existing tests would *not* catch that. `$planet.Order | Should-Be 3` passes against `'3'`, because `Should-Be` compares values and PowerShell converts the string first. Delete the `ForEach-Object` block and all fifteen tests stay green while the module quietly hands out strings.
+
+That is a gap worth closing, and it needs an assertion about the type rather than the value. You will write it on the next page, once mocking makes it possible to feed the function strings on purpose.
+
+## Simplifying Get-Planet
+
+`Get-Planet` now just filters whatever the data source hands back:
+
+```powershell title="Planetarium/Public/Get-Planet.ps1"
+function Get-Planet {
+ [CmdletBinding()]
+ param (
+ [string] $Name = '*'
+ )
+
+ # diff-remove-start
+ $planets = @(
+ [PSCustomObject] @{ Name = 'Mercury'; Order = 1; DistanceFromSunKm = 57909050 }
+ # ...six more planets...
+ [PSCustomObject] @{ Name = 'Neptune'; Order = 8; DistanceFromSunKm = 4495060000 }
+ )
+
+ $planets | Where-Object Name -Like $Name
+ # diff-remove-end
+ # diff-add
+ Get-PlanetData | Where-Object Name -Like $Name
+}
+```
+
+## Nothing should have broken
+
+This was a refactor: the behaviour is meant to be identical. Your test suite is how you find out whether it actually is.
+
+```powershell
+./test.ps1
+```
+
+```
+Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Fifteen tests, all still green, without a single test file changing. That is the return on the work from the previous module — you just restructured how the module gets its data and confirmed in under a second that nothing regressed.
+
+:::note This is also the moment the tests got worse
+Every one of those tests now reads a real file from disk. They still pass, but they are no longer testing `Get-Planet` alone — they are testing `Get-Planet`, plus `Get-PlanetData`, plus `Import-Csv`, plus the filesystem, plus the contents of `planets.csv`. Edit that CSV and unrelated tests start failing.
+
+That coupling is what the next page removes.
+:::
+
+
+
+Next: cutting the tests loose from that file.
diff --git a/tutorial/4-mocking/2-your-first-mock.mdx b/tutorial/4-mocking/2-your-first-mock.mdx
new file mode 100644
index 00000000..2e340f6b
--- /dev/null
+++ b/tutorial/4-mocking/2-your-first-mock.mdx
@@ -0,0 +1,124 @@
+---
+id: your-first-mock
+title: Your first mock
+description: Replace a module's internal data source with Mock -ModuleName so tests control their own data instead of depending on a file on disk
+---
+
+Your tests currently depend on the contents of `planets.csv`. Add a planet to that file and `Returns all eight planets` starts failing — a test about filtering, broken by a data edit.
+
+A mock replaces a command for the duration of a test. Instead of `Get-PlanetData` reading a file, it returns whatever you tell it to.
+
+## Mocking inside a module
+
+```powershell title="Planetarium/Public/Get-Planet.Mocking.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Get-Planet with mocked data' {
+ BeforeAll {
+ Mock -ModuleName Planetarium Get-PlanetData {
+ @(
+ [PSCustomObject] @{ Name = 'Aiur'; Order = 1; DistanceFromSunKm = 100000000 }
+ [PSCustomObject] @{ Name = 'Shakuras'; Order = 2; DistanceFromSunKm = 200000000 }
+ )
+ }
+ }
+
+ It 'Returns whatever the data source provides' {
+ (Get-Planet).Name | Should-BeCollection @('Aiur', 'Shakuras')
+ }
+
+ It 'Filters the mocked data the same way' {
+ (Get-Planet -Name 'A*').Name | Should-Be 'Aiur'
+ }
+}
+```
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Public/Get-Planet.Mocking.Tests.ps1
+```
+
+```
+Tests Passed: 2, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+The planets are obviously fictional, and that is the point. These tests assert that `Get-Planet` returns and filters *whatever its data source gives it* — which is the actual behaviour of the function. The real solar system is data, not logic, and it is not this test's job.
+
+## Why `-ModuleName` is required
+
+This is the detail that costs people the most time.
+
+`Get-Planet` runs inside the module's own scope. When it calls `Get-PlanetData`, PowerShell resolves that name *within the module*, and a mock defined in your test file lives outside it. Without `-ModuleName`, your mock sits somewhere the module will never look, and the real function runs — your test passes or fails for reasons unrelated to the mock you thought you installed.
+
+`-ModuleName Planetarium` injects the mock into the module's scope, where the call actually resolves.
+
+:::warning A mock without `-ModuleName` fails silently
+Nothing errors. The mock is simply never used, and the real command runs instead. If a mock appears to have no effect, this is the first thing to check.
+:::
+
+Note also what you did *not* have to do: no `InModuleScope` wrapper. `-ModuleName` reaches the private `Get-PlanetData` without dragging your whole test inside the module — which is exactly the preference the [modules guide](../../docs/usage/modules) recommends over `InModuleScope`.
+
+## Mocking a built-in command
+
+You can mock commands you did not write, including PowerShell's own. Here the target is `Import-Csv`, one layer deeper. The thing under test is now `Get-PlanetData` itself — a private helper — so the test goes in its own file next to the helper, tagged `Internal` like the other private-function tests:
+
+```powershell title="Planetarium/Private/Get-PlanetData.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Get-PlanetData' -Tag 'Internal' {
+ It 'Converts the CSV strings into numbers' {
+ Mock -ModuleName Planetarium Import-Csv {
+ @([PSCustomObject] @{ Name = 'Aiur'; Order = '3'; DistanceFromSunKm = '149597870.7' })
+ }
+
+ InModuleScope Planetarium {
+ $planet = Get-PlanetData
+ $planet.Name | Should-Be 'Aiur'
+ $planet.Order | Should-Be 3
+ $planet.Order | Should-HaveType ([int])
+ $planet.DistanceFromSunKm | Should-HaveType ([double])
+ }
+ }
+}
+```
+
+This is the test that closes the gap left at the end of the previous page. Feeding in strings — `'3'`, not `3` — and then asserting on the types that come out is what proves the conversion happens. `Should-HaveType` is the assertion that can do it: `Should-Be` would pass either way, because it compares values and lets PowerShell convert.
+
+The mock is what makes the input certain. `Import-Csv` happens to return strings today, so this test would pass without it — but only by accident of the current storage format. Swap the CSV for JSON, which preserves numbers, and an unmocked version of this test would go green without the conversion ever running. Mocking the input pins the test to the behaviour rather than to the file format.
+
+Try deleting the `ForEach-Object` block from `Get-PlanetData` and run the suite: this is now the one test that fails. Restore it after, before continuing.
+
+`InModuleScope` is here because the test calls the private `Get-PlanetData` directly. The mock still uses `-ModuleName`, since it must be installed into the module regardless of where the call is made from.
+
+Run the new file:
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Private/Get-PlanetData.Tests.ps1 -Output Detailed
+```
+
+```
+Describing Get-PlanetData
+ [+] Converts the CSV strings into numbers 16ms
+```
+
+## Which layer to mock
+
+You have now mocked at two depths, and the choice matters:
+
+- **`Get-PlanetData`** — mocking your own seam. Tests stay readable, and they survive a change of storage format. Switch the CSV to JSON and these tests do not care. Prefer this.
+- **`Import-Csv`** — mocking the plumbing. Necessary when the thing under test *is* the plumbing, as with the type conversion above, but it welds your test to the current implementation. Move to JSON and this test breaks even though the behaviour did not change.
+
+The rule of thumb: mock the seam you own, as close to the thing under test as you can get.
+
+
+
+Next: asserting that the mock was actually called, and the way Pester v6 refuses to guess.
diff --git a/tutorial/4-mocking/3-verifying-calls.mdx b/tutorial/4-mocking/3-verifying-calls.mdx
new file mode 100644
index 00000000..9b0a0b56
--- /dev/null
+++ b/tutorial/4-mocking/3-verifying-calls.mdx
@@ -0,0 +1,142 @@
+---
+id: verifying-calls
+title: Verifying calls
+description: Assert that a mocked command was called with Should-Invoke, target specific calls with -ParameterFilter, and understand why a mock that matches nothing throws
+---
+
+A mock lets you control what a command returns. `Should-Invoke` lets you assert it was called at all — which is how you test behaviour that leaves no return value behind.
+
+## Should-Invoke
+
+Add a third test to the `Get-Planet with mocked data` block, below the two you already have:
+
+```powershell title="Planetarium/Public/Get-Planet.Mocking.Tests.ps1"
+Describe 'Get-Planet with mocked data' {
+ # ... BeforeAll and the two existing It blocks ...
+
+ It 'Filters the mocked data the same way' {
+ (Get-Planet -Name 'A*').Name | Should-Be 'Aiur'
+ }
+
+ # diff-add-start
+ It 'Reads the data source exactly once per call' {
+ Get-Planet | Out-Null
+
+ Should-Invoke Get-PlanetData -ModuleName Planetarium -Times 1 -Exactly
+ }
+ # diff-add-end
+}
+```
+
+`Should-Invoke` does not install anything; it simply checks that the mock defined in `BeforeAll` is being called in this test.
+
+Note the use of `-Exactly`. `-Times 1` on its own means "at least once", so a function that read the file three times would pass. With `-Exactly` this test would catch a refactor that accidentally re-reads the CSV per planet — a bug that might be invisible to the user but reduce performance.
+
+The `-ModuleName` rule from the previous page applies here too: you are asking about a mock that lives in the module's scope, so you have to say so.
+
+:::tip Assert on calls only when the call is the behaviour
+`Should-Invoke` is the right tool for "it wrote to the log", "it retried twice", "it did not delete anything". It is the wrong tool for things you can check by looking at the result. Asserting on both the return value and the exact call sequence tends to produce tests that fail every time you tidy up the implementation, without ever catching a real bug.
+:::
+
+## Targeting specific calls
+
+`-ParameterFilter` narrows a mock to calls whose arguments match, allowing you to customize responses for different calls. Let's give it a try:
+
+```powershell title="Planetarium/Private/Get-PlanetData.Tests.ps1"
+Describe 'Get-PlanetData' -Tag 'Internal' {
+ # ... the 'Converts the CSV strings into numbers' test ...
+
+ # diff-add-start
+ It 'Reads the CSV shipped with the module' {
+ Mock -ModuleName Planetarium Import-Csv {
+ @([PSCustomObject] @{ Name = 'Aiur'; Order = '1'; DistanceFromSunKm = '100' })
+ } -ParameterFilter { $Path -like '*planets.csv' }
+
+ InModuleScope Planetarium { (Get-PlanetData).Name | Should-Be 'Aiur' }
+
+ Should-Invoke Import-Csv -ModuleName Planetarium -Times 1 -Exactly
+ }
+ # diff-add-end
+}
+```
+
+Inside the filter, parameters are available as variables — `$Path` is whatever was passed as `-Path`. The mock only applies when the filter returns true, so this one says: intercept reads of files ending with `planets.csv`, and nothing else.
+
+That doubles as an assertion. If the module ever reads a different file, the filter stops matching.
+
+## Mocks do not fall through
+
+A mock with a `-ParameterFilter` only applies to calls that match it. If a call reaches a mocked command and *nothing* matches, Pester throws rather than guessing — it will not quietly run the real command behind your back.
+
+Let's break it on purpose to see how this works. Change `planets` to `moons` in the test you just added:
+
+```powershell title="Planetarium/Private/Get-PlanetData.Tests.ps1"
+ It 'Reads the CSV shipped with the module' {
+ Mock -ModuleName Planetarium Import-Csv {
+ @([PSCustomObject] @{ Name = 'Aiur'; Order = '1'; DistanceFromSunKm = '100' })
+ # diff-remove
+ } -ParameterFilter { $Path -like '*planets.csv' }
+ # diff-add
+ } -ParameterFilter { $Path -like '*moons.csv' }
+
+ # ... rest of the test unchanged ...
+ }
+```
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Private/Get-PlanetData.Tests.ps1 -Output Detailed
+```
+
+```
+Describing Get-PlanetData
+ [+] Converts the CSV strings into numbers 49ms
+ [-] Reads the CSV shipped with the module 29ms
+ RuntimeException: No mock for command 'Import-Csv' matched the call: none of the parameter filters matched, and there is no default mock to fall back to. Add a default mock (e.g. `Mock Import-Csv { ... }`) or adjust an existing -ParameterFilter.
+ The following parameter filters were evaluated and did not match:
+ { $Path -like '*moons.csv' } bound parameters: Path = /home/you/pester-tutorial/Planetarium/Private/../Data/planets.csv
+```
+
+Read the last line: it shows the filter that was evaluated *and* the arguments it was evaluated against. The filter wanted `*moons.csv`, the actual path ends in `planets.csv` — the mismatch is right there, with no guessing required.
+
+This behavior is new in Pester v6. Previous versions called the original command when the filter failed. Tests could still pass based on the real data, and you would trust a mock that was never used.
+
+### Giving a mock a fallback
+
+Sometimes you genuinely want "handle this specific case, and everything else generically". Say so explicitly by adding a second mock with no `-ParameterFilter` — an unfiltered mock matches any call, so it becomes the fallback:
+
+```powershell
+Mock Get-Thing { 'default' } # everything else
+Mock Get-Thing { 'one' } -ParameterFilter { $Id -eq 1 } # the specific case
+```
+
+The example above is only used for illustration. Your `Import-Csv` mock should intercept exactly one file, so leaving it filtered and unmatched-is-an-error is the behaviour you want.
+
+:::tip
+See [Mocking](../../docs/usage/mocking#pesterboundparameters) for an example of using the default mock to call the original command.
+:::
+
+**Change `moons` back to `planets` before moving on.**
+
+## Running the suite
+
+```powershell
+./test.ps1
+```
+
+```
+Tests Passed: 20, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Twenty tests: the original fifteen, plus five that no longer care what is in `planets.csv`.
+
+There is also `Should-NotInvoke`, the mirror image, for asserting a command was *not* called — "it did not delete anything", "it did not retry". It takes the same `-ModuleName` and `-ParameterFilter` parameters.
+
+
+
+Next module: testing functions that generate files without causing a mess.
diff --git a/tutorial/4-mocking/_category_.json b/tutorial/4-mocking/_category_.json
new file mode 100644
index 00000000..e94b22fb
--- /dev/null
+++ b/tutorial/4-mocking/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Mocking",
+ "collapsed": false
+}
diff --git a/tutorial/5-working-with-files/1-writing-files.mdx b/tutorial/5-working-with-files/1-writing-files.mdx
new file mode 100644
index 00000000..1aba4321
--- /dev/null
+++ b/tutorial/5-working-with-files/1-writing-files.mdx
@@ -0,0 +1,86 @@
+---
+id: writing-files
+title: A function that writes files
+description: Add Export-PlanetReport to the module and see why testing code that touches the filesystem needs more care than testing code that returns values
+---
+
+Every function so far has returned a value. Testing those is easy: call it, look at what came back. This page adds a function whose entire purpose is a side effect — it writes a file and returns nothing useful.
+
+## Export-PlanetReport
+
+```powershell title="Planetarium/Public/Export-PlanetReport.ps1"
+function Export-PlanetReport {
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [string] $Path,
+
+ [string] $Name = '*'
+ )
+
+ $planets = @(Get-Planet -Name $Name)
+
+ if ($planets.Count -eq 0) {
+ throw "No planets matched '$Name'."
+ }
+
+ $report = foreach ($planet in $planets) {
+ '{0,-8} {1} AU' -f $planet.Name, (ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm)
+ }
+
+ Set-Content -Path $Path -Value $report
+}
+```
+
+It leans on almost everything built so far: `Get-Planet` for the data, the private `ConvertTo-AstronomicalUnit` for the conversion, and the module loader to wire them together. It goes in `Public/`, so it is exported automatically.
+
+(The `@( )` around `Get-Planet` is a PowerShell detail, not a Pester one: it guarantees `.Count` works even when a single planet comes back.)
+
+Try it:
+
+```powershell
+Import-Module ./Planetarium/Planetarium.psd1 -Force
+Export-PlanetReport -Path ./report.txt
+Get-Content ./report.txt
+```
+
+```
+Mercury 0.387 AU
+Venus 0.723 AU
+Earth 1 AU
+Mars 1.524 AU
+Jupiter 5.204 AU
+Saturn 9.583 AU
+Uranus 19.201 AU
+Neptune 30.048 AU
+```
+
+## The problem with testing this
+
+You have just created a file in your source folder. Delete it:
+
+```powershell
+Remove-Item ./report.txt
+```
+
+Now think about what a test for this function has to do. It needs a path to write to, and afterwards that file should not still be there. Doing this by hand goes wrong quickly:
+
+- **Writing into your source folder** leaves junk next to your code, and one forgotten cleanup means a test that passes only because a previous run left the file behind.
+- **Writing to a fixed temp path** breaks the moment two tests use the same name, and breaks harder when two Pester runs happen at once.
+- **Cleaning up in `AfterEach`** is bookkeeping you have to maintain — every file every test creates must be remembered and removed, and an interrupted run still leaves debris for the next one.
+
+Every one of these produces the same nasty class of bug: tests whose result depends on what previous runs left lying around. They pass on your machine, fail in CI, and pass again after you delete something by hand.
+
+:::note Could you just mock `Set-Content`?
+You could — `Mock Set-Content` and `Should-Invoke` would tell you the function tried to write. But it would not tell you the file has eight lines, or that Earth's line reads `Earth 1 AU`. You would be asserting that your code called a command, not that it produced a correct report.
+
+Mock the filesystem when the write itself is the behaviour. When the *content* matters, write real files somewhere disposable — which is exactly what the next page is about.
+:::
+
+
+
+Next: the disposable filesystem Pester gives you for free.
diff --git a/tutorial/5-working-with-files/2-testdrive.mdx b/tutorial/5-working-with-files/2-testdrive.mdx
new file mode 100644
index 00000000..c5ea0b7b
--- /dev/null
+++ b/tutorial/5-working-with-files/2-testdrive.mdx
@@ -0,0 +1,104 @@
+---
+id: testdrive
+title: Isolating files with TestDrive
+description: Use Pester's TestDrive to give every test file a disposable filesystem that cleans itself up, then assert on the files your code actually wrote
+---
+
+Pester gives every test file its own temporary directory, cleans it up afterwards, and names it randomly so parallel runs cannot collide. It is called **TestDrive**, and it needs no setup at all — it is simply there.
+
+## Two ways to reach it
+
+- **`TestDrive:\`** — a PowerShell drive, for use with PowerShell commands.
+- **`$TestDrive`** — the same location as a plain filesystem path.
+
+Prefer `$TestDrive` with `Join-Path`. `TestDrive:\` only exists inside PowerShell, so the moment a path reaches a .NET method or an external executable it breaks — and `Export-PlanetReport` hands its path to `Set-Content`, which is fine, right up until someone changes it to `[System.IO.File]::WriteAllLines`.
+
+## Testing the report
+
+```powershell title="Planetarium/Public/Export-PlanetReport.Tests.ps1"
+BeforeAll {
+ Import-Module "$PSScriptRoot/../Planetarium.psd1" -Force
+}
+
+Describe 'Export-PlanetReport' {
+ It 'Creates the report file' {
+ $path = Join-Path $TestDrive 'report.txt'
+
+ Test-Path -Path $path | Should-BeFalse
+ Export-PlanetReport -Path $path
+ Test-Path -Path $path | Should-BeTrue
+ }
+
+ It 'Writes one line per planet' {
+ $path = Join-Path $TestDrive 'all.txt'
+
+ Export-PlanetReport -Path $path
+
+ (Get-Content -Path $path).Count | Should-Be 8
+ }
+
+ It 'Writes the name and the distance in astronomical units' {
+ $path = Join-Path $TestDrive 'earth.txt'
+
+ Export-PlanetReport -Path $path -Name 'Earth'
+
+ Get-Content -Path $path | Should-Be 'Earth 1 AU'
+ }
+
+ It 'Throws when no planet matches' {
+ $path = Join-Path $TestDrive 'nothing.txt'
+
+ { Export-PlanetReport -Path $path -Name 'Pluto' } |
+ Should-Throw -ExceptionMessage "No planets matched 'Pluto'."
+ }
+}
+```
+
+```powershell
+Invoke-Pester -Path ./Planetarium/Public/Export-PlanetReport.Tests.ps1 -Output Detailed
+```
+
+```
+Describing Export-PlanetReport
+ [+] Creates the report file 45ms
+ [+] Writes one line per planet 8ms
+ [+] Writes the name and the distance in astronomical units 4ms
+ [+] Throws when no planet matches 24ms
+Tests completed in 362ms
+Tests Passed: 4, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Four real files were written and four are already gone. Look in your working folder — no report files anywhere.
+
+The first test asserts the file is absent *before* the call as well as present after. Without that, a leftover file from an earlier run could make the test pass on its own.
+
+## Scoping
+
+TestDrive is not one directory for the whole run. The rules that matter day to day:
+
+1. A clean drive is created **per test file**, at the first top-level `Describe` or `Context`.
+2. Files made in a block are visible to everything nested inside it.
+3. On leaving a block, files created *during* that block are removed.
+4. When the file finishes, the whole drive goes.
+
+Each `It` test above generated unique filenames but in the same folder. Once the last test in the `Describe` block was done, all four files were cleaned up.
+
+:::warning Modifications to inherited files are not undone
+Cleanup works by tracking which paths existed when a block was entered. Create a file in `Describe`, change it inside a `Context`, and the change survives after the `Context` ends — the file already existed, so it is excluded from that block's cleanup.
+
+Create files in the block or test that needs it and avoid reuse when possible. You don't want tests to depend on execution order, where one test might break another.
+:::
+
+## What this bought you
+
+Compare against the alternatives from the previous page, point by point. Nothing lands in your source folder. The randomised directory name means two tests — or two whole Pester runs at once — never collide. And cleanup is Pester's job rather than bookkeeping in your `AfterEach`, so nothing is left behind even when a run fails or is interrupted.
+
+
+
+Next module: finding out which parts of the module you're not testing.
diff --git a/tutorial/5-working-with-files/_category_.json b/tutorial/5-working-with-files/_category_.json
new file mode 100644
index 00000000..398ebc35
--- /dev/null
+++ b/tutorial/5-working-with-files/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Working with files",
+ "collapsed": false
+}
diff --git a/tutorial/6-code-coverage/1-measuring.mdx b/tutorial/6-code-coverage/1-measuring.mdx
new file mode 100644
index 00000000..a4b27e6c
--- /dev/null
+++ b/tutorial/6-code-coverage/1-measuring.mdx
@@ -0,0 +1,136 @@
+---
+id: measuring
+title: Measuring coverage
+description: Turn on Pester's code coverage to find out which parts of the Planetarium module the test suite never executes
+---
+
+Running the full test suite now will show twenty-four tests passing. That tells you the things you thought to test work. It says nothing about the code you forgot.
+
+Code coverage answers a narrower question than people usually assume: *which lines of my code ran while the tests ran?* Not whether they were tested well — just whether they executed at all. Lines that never execute are by definition untested, and that is worth knowing.
+
+## Turning it on
+
+Code Coverage is enabled through the configuration object. Update `test.ps1` and try it out:
+
+```powershell title="test.ps1"
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.Output.Verbosity = 'Detailed'
+$config.TestResult.Enabled = $true
+$config.TestResult.OutputPath = './testResults.xml'
+# diff-add-start
+$config.CodeCoverage.Enabled = $true
+$config.CodeCoverage.Path = './Planetarium'
+# diff-add-end
+
+Invoke-Pester -Configuration $config
+```
+
+```powershell
+./test.ps1
+```
+
+```
+Tests completed in 870ms
+Tests Passed: 24, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+Processing code coverage result.
+Covered 100% / 75%. 36 analyzed Commands in 7 Files.
+```
+
+Read that last line carefully — it trips people up. **`100%` is what you achieved; `75%` is the target you are being measured against.** The target is `CodeCoverage.CoveragePercentTarget`, which defaults to 75.
+
+:::warning Make sure `CodeCoverage.Path` points to your code
+`Run.Path` says which *tests* to run; `CodeCoverage.Path` says which *code* to measure. It will default to `Run.Path` which works in this tutorial, but might return 0% coverage if you used a dedicated `tests` folder. Set both options explicitly to avoid surprises.
+:::
+
+Pester v6 uses a profiler-based tracer by default, which is fast enough to leave on routinely.
+
+## 100% is not the finish line
+
+The module is at 100%, and it would be a mistake to read that as "fully tested".
+
+Coverage measures execution, not assertion. This test would give `ConvertTo-AstronomicalUnit` full coverage while checking nothing at all:
+
+```powershell
+It 'Runs' {
+ InModuleScope Planetarium { ConvertTo-AstronomicalUnit -Kilometre 149597870.7 }
+}
+```
+
+The line ran, so it is covered. Nothing was asserted, so it is untested. Coverage cannot tell those apart — which is why chasing a perfect coverage as a goal can lead to poor tests.
+
+What coverage is genuinely good at is the opposite direction: **it finds code you forgot entirely.** A 100% score is weak evidence of quality; an uncovered line is strong evidence of a gap.
+
+## Adding a feature
+
+Time to create a real gap the way it actually happens — by adding a feature.
+
+`Export-PlanetReport` currently overwrites without asking, which is unfriendly for something that writes files. Add a guard:
+
+```powershell title="Planetarium/Public/Export-PlanetReport.ps1"
+function Export-PlanetReport {
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory)]
+ [string] $Path,
+
+ # diff-remove
+ [string] $Name = '*'
+ # diff-add-start
+ [string] $Name = '*',
+
+ [switch] $Force
+ # diff-add-end
+ )
+
+ $planets = @(Get-Planet -Name $Name)
+
+ if ($planets.Count -eq 0) {
+ throw "No planets matched '$Name'."
+ }
+
+ # diff-add-start
+ if ((Test-Path -Path $Path) -and -not $Force) {
+ throw "'$Path' already exists. Use -Force to overwrite it."
+ }
+ # diff-add-end
+
+ $report = foreach ($planet in $planets) {
+ '{0,-8} {1} AU' -f $planet.Name, (ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm)
+ }
+
+ Set-Content -Path $Path -Value $report
+}
+```
+
+Run the suite:
+
+```powershell
+./test.ps1
+```
+
+```
+Tests Passed: 24, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+```
+
+Still all green. Every existing test writes to a fresh path in TestDrive, so none of them hits the new branch — and none of them fails. Nothing in that output hints that you just shipped an untested code path. Code Coverage does:
+
+```
+Covered 95% / 75%. 40 analyzed Commands in 7 Files.
+Missed commands:
+
+File Class Function Line Command
+---- ----- -------- ---- -------
+Planetarium/Public/Export-PlanetReport.ps1 Export-PlanetReport 19 throw "'$Path' already exists. Use -Force to …
+Planetarium/Public/Export-PlanetReport.ps1 Export-PlanetReport 19 throw "'$Path' already exists. Use -Force to …
+```
+
+
+
+Next: testing the new feature to close the gap.
diff --git a/tutorial/6-code-coverage/2-closing-the-gaps.mdx b/tutorial/6-code-coverage/2-closing-the-gaps.mdx
new file mode 100644
index 00000000..e8be2b66
--- /dev/null
+++ b/tutorial/6-code-coverage/2-closing-the-gaps.mdx
@@ -0,0 +1,136 @@
+---
+id: closing-the-gaps
+title: Closing the gaps
+description: Read the missed-command table, write the tests it points at, and produce a coverage report file for CI to consume
+---
+
+Coverage stands at 95%, and the run has already told you which lines to look at.
+
+## Reading the missed commands
+
+```
+Missed commands:
+
+File Class Function Line Command
+---- ----- -------- ---- -------
+Planetarium/Public/Export-PlanetReport.ps1 Export-PlanetReport 19 throw "'$Path' already exists. Use -Force to …
+Planetarium/Public/Export-PlanetReport.ps1 Export-PlanetReport 19 throw "'$Path' already exists. Use -Force to …
+```
+
+This table is printed for you whenever coverage is on and `Output.Verbosity` is set to `Detailed`, which `test.ps1` already does.
+
+The line appears twice because Pester counts `throw` keyword as one command, and the message string as another. This is a special case for `throw`.
+
+Now we know which code we need to test next.
+
+:::tip Getting at the same data in code
+You can create your own report using the result object (`Run.PassThru`) and processing the data in `$result.CodeCoverage.CommandsMissed`.
+
+```powershell
+$config.Run.PassThru = $true
+$result = Invoke-Pester -Configuration $config
+# StartColumn will confirm the two missed commands above are in fact different. One entry for `throw` and one for the message string
+$result.CodeCoverage.CommandsMissed | Format-Table Function, Line, StartColumn, Command
+```
+:::
+
+## Writing the missing tests
+
+The uncovered branch has two behaviours worth pinning: it refuses by default, and `-Force` overrides it. Add both tests at the bottom of the `Describe` block, below the four you already have:
+
+```powershell title="Planetarium/Public/Export-PlanetReport.Tests.ps1"
+Describe 'Export-PlanetReport' {
+ # ... the four existing It blocks ...
+
+ # diff-add-start
+ It 'Refuses to overwrite an existing report' {
+ $path = Join-Path $TestDrive 'existing.txt'
+ Export-PlanetReport -Path $path
+
+ { Export-PlanetReport -Path $path } |
+ Should-Throw -ExceptionMessage "*already exists. Use -Force to overwrite it."
+ }
+
+ It 'Overwrites an existing report when -Force is used' {
+ $path = Join-Path $TestDrive 'forced.txt'
+ Export-PlanetReport -Path $path
+ Export-PlanetReport -Path $path -Name 'Earth' -Force
+
+ Get-Content -Path $path | Should-Be 'Earth 1 AU'
+ }
+ # diff-add-end
+}
+```
+
+Both call `Export-PlanetReport` twice: once to create the file, and once more to hit the file exists error.
+
+The exception message starts with the filepath which is randomized per run, so a wildcard `*` is used to match the stable part and confirm it's the correct exception.
+
+The second test asserts *content*, not just that no error occurred. Writing `Earth` over an eight-planet report and then checking the file holds exactly one line proves the overwrite really happened rather than the write being skipped.
+
+## Back to green
+
+```powershell
+./test.ps1
+```
+
+```
+Tests Passed: 26, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+Covered 100% / 75%. 40 analyzed Commands in 7 Files.
+```
+
+Back at 100% coverage. Enjoy this rare moment.
+
+## A report file for CI
+
+When Code Coverage is enabled it writes a report to `./coverage.xml` by default that can be used by CI systems and other coverage reporting tools. You control the path using the `CodeCoverage.OutputPath` option - we'll just set the default explicit:
+
+```powershell title="test.ps1"
+$config.CodeCoverage.Enabled = $true
+$config.CodeCoverage.Path = './Planetarium'
+# diff-add
+$config.CodeCoverage.OutputPath = './coverage.xml'
+```
+
+```xml title="coverage.xml"
+
+
+
+
+
+Last module: running all of this automatically on every push.
diff --git a/tutorial/6-code-coverage/_category_.json b/tutorial/6-code-coverage/_category_.json
new file mode 100644
index 00000000..a0dd0edb
--- /dev/null
+++ b/tutorial/6-code-coverage/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Code coverage",
+ "collapsed": false
+}
diff --git a/tutorial/7-ci/1-test-script.mdx b/tutorial/7-ci/1-test-script.mdx
new file mode 100644
index 00000000..eb12498c
--- /dev/null
+++ b/tutorial/7-ci/1-test-script.mdx
@@ -0,0 +1,106 @@
+---
+id: test-script
+title: A test script that fails the build
+description: Turn the Pester configuration into a reusable script that produces artifacts and, critically, exits non-zero when tests fail
+---
+
+`test.ps1` has been growing since the output module: it runs the suite, prints detailed results and writes both artifacts. It needs one more line before a pipeline can rely on it — the one that makes it fail.
+
+## The missing line
+
+```powershell title="test.ps1"
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+# diff-add
+$config.Run.Exit = $true
+$config.Output.Verbosity = 'Detailed'
+$config.TestResult.Enabled = $true
+$config.TestResult.OutputPath = './testResults.xml'
+$config.CodeCoverage.Enabled = $true
+$config.CodeCoverage.Path = './Planetarium'
+$config.CodeCoverage.OutputPath = './coverage.xml'
+
+Invoke-Pester -Configuration $config
+```
+
+That is the finished script: run the tests, write both artifacts, exit non-zero if anything failed.
+
+## Why the exit code is the important line
+
+**A failing Pester test does not, by itself, fail your build.**
+
+By default `Invoke-Pester` reports failures and returns normally. Some CI systems notice the failure anyway; many do not. Whether your build goes red would depend on *how the script happens to be invoked* — a horrible thing to leave to chance.
+
+`Run.Exit = $true` removes the guesswork: the script itself exits non-zero when tests fail, which every CI system understands. Prove both directions rather than trusting it:
+
+```powershell
+pwsh -NoProfile -File ./test.ps1
+$LASTEXITCODE
+```
+
+```
+Tests Passed: 26, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
+0
+```
+
+Now break something on purpose — change a `Should-Be 8` to `Should-Be 9` — and run it again:
+
+```
+Tests Passed: 25, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0
+1
+```
+
+Exit code 1. *That* is what makes CI red. Put the `8` back.
+
+The code is in fact the **number of failures** — one failing test exits with `1`, five with `5`. Handy at a glance, though the results file is where the detail lives.
+
+:::note `Invoke-Pester -CI` is the shorthand — but not for this
+Pester has a `-CI` switch that sets exactly two things:
+
+```powershell
+TestResult.Enabled = $true
+Run.Exit = $true
+```
+
+It does **not** enable code coverage. Since this script wants coverage too — along with control over the output paths and verbosity — it sets those options directly instead. If you only need test results and a correct exit code, `Invoke-Pester -Path ./Planetarium -CI` is the one-liner version of most of this page.
+:::
+
+## It still works locally
+
+You have been running this script since the output module, and adding `Run.Exit` does not change that:
+
+```powershell
+./test.ps1
+$LASTEXITCODE
+```
+
+`Run.Exit` is safe interactively. It ends the script rather than your session, and leaves `$LASTEXITCODE` behind so you can check the result.
+
+Running the identical script locally and in CI removes an entire genre of problem — the one where a build fails remotely and cannot be reproduced because CI was doing something subtly different.
+
+## Artifacts
+
+Two files come out of every run:
+
+- `testResults.xml` — NUnit format, per-test results
+- `coverage.xml` — JaCoCo format, coverage data
+
+Both are build output, so keep them out of version control:
+
+```
+testResults.xml
+coverage.xml
+```
+
+They exist for the machine that runs the build, not for you. The next page hands them to GitHub.
+
+
+
+Next: setting up your CI pipeline to run our test automatically.
diff --git a/tutorial/7-ci/2-github-actions.mdx b/tutorial/7-ci/2-github-actions.mdx
new file mode 100644
index 00000000..f619d8c9
--- /dev/null
+++ b/tutorial/7-ci/2-github-actions.mdx
@@ -0,0 +1,163 @@
+---
+id: github-actions
+title: Running on every push
+description: Wire the test script into a GitHub Actions workflow that installs Pester, runs the suite across operating systems and publishes the results
+---
+
+The test script is the hard part and it is already done. A CI workflow is mostly plumbing: check out the code, install Pester, run `./test.ps1`, keep the artifacts.
+
+## The workflow
+
+```yaml title=".github/workflows/test.yml"
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Pester
+ shell: pwsh
+ run: Install-Module Pester -MinimumVersion 6.0.0 -Force -SkipPublisherCheck -Scope CurrentUser
+
+ - name: Run tests
+ shell: pwsh
+ run: ./test.ps1
+
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results
+ path: |
+ testResults.xml
+ coverage.xml
+```
+
+:::note This part requires git and GitHub
+Everything so far ran locally; running in CI means publishing the code to GitHub. If your working folder is not a git repository yet, turn it into one and commit your files:
+
+```bash
+git init
+git add .
+git commit -m "Planetarium module with tests"
+```
+
+Then [create a GitHub repository and push to it](https://docs.github.com/en/get-started/quickstart/create-a-repo). If you have never used git, that guide covers everything this module needs.
+:::
+
+Commit that and push. Every push to `main` and every pull request now runs your 26 tests.
+
+Three details are doing real work.
+
+**`shell: pwsh`** on every PowerShell step. The default shell on `ubuntu-latest` is bash, and PowerShell 7 is preinstalled but not the default. Omit this and the step tries to run PowerShell as bash. On Windows runners the default is `powershell` — Windows PowerShell 5.1 — which is a *different* shell from `pwsh`, so being explicit avoids a second, subtler version of the same problem.
+
+**`-Scope CurrentUser`** on the install. The runner user is not an administrator, and an all-users install needs elevation. `-SkipPublisherCheck` is there for the Windows runner, which ships the Microsoft-signed Pester 3.4.0 you met in the prerequisites — without it, installing over a module signed by a different publisher is refused.
+
+**`if: always()`** on the upload. Without it, the artifact step is skipped whenever a previous step fails — which is precisely when you most want the test results. This one line is the difference between a red build you can diagnose from the artifact and one you have to reproduce locally.
+
+## Testing on more than one platform
+
+The single job above tests less than the module claims to support. The tutorial has worked on Windows, Linux and macOS all along, and the manifest says `PowerShellVersion = '5.1'` — Windows PowerShell 5.1 is a genuinely different engine from PowerShell 7, and it is where the surprises live. A matrix runs the same job once per combination, so extend the workflow to test all of it:
+
+```yaml title=".github/workflows/test.yml"
+jobs:
+ test:
+ # diff-add-start
+ name: ${{ matrix.name }} on ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ shell: [pwsh]
+ name: [PowerShell 7]
+ include:
+ - os: windows-latest
+ shell: powershell
+ name: Windows PowerShell 5.1
+ # diff-add-end
+
+ # diff-remove
+ runs-on: ubuntu-latest
+ # diff-add
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Pester
+ # diff-remove
+ shell: pwsh
+ # diff-add
+ shell: ${{ matrix.shell }}
+ run: Install-Module Pester -MinimumVersion 6.0.0 -Force -SkipPublisherCheck -Scope CurrentUser
+
+ - name: Run tests
+ # diff-remove
+ shell: pwsh
+ # diff-add
+ shell: ${{ matrix.shell }}
+ run: ./test.ps1
+
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ # diff-remove
+ name: test-results
+ # diff-add
+ name: test-results-${{ matrix.os }}-${{ matrix.shell }}
+ path: |
+ testResults.xml
+ coverage.xml
+```
+
+This produces four jobs. The `os` list crossed with `shell: [pwsh]` gives three — PowerShell 7 on each operating system. The `include` adds a fourth: `windows-latest` running under `powershell`, the built-in Windows PowerShell 5.1 shell. That fourth job is the one that tests what the manifest promises. (For a module of your own that does not support 5.1, drop the `include` and raise `PowerShellVersion` in the manifest instead.)
+
+The job `name` is customized to identify all combinations in the CI report, e.g. `PowerShell 7 on ubuntu-latest` and `Windows PowerShell 5.1 on windows-latest`.
+
+`fail-fast: false` matters here. The default cancels every other job the moment one fails, so a Windows-only bug would abort the Linux and macOS runs and hide whether the failure is platform-specific. Turning it off costs a few runner minutes and tells you far more.
+
+The artifact name now includes both matrix values. Jobs uploading to the same artifact name is an error, and `${{ matrix.os }}` alone would still collide for the two Windows jobs.
+
+:::note This is where the cross-platform bugs show up
+`Join-Path` and `$TestDrive` have been quietly protecting you. Hard-coded `\` separators, case-sensitivity assumptions, and `C:\temp` paths all work on your machine and fail on Linux. A matrix is how you find them, and it is the main reason this module is worth doing even for a small module.
+:::
+
+## Where to go from here
+
+The workflow above is a complete, working setup. Natural next steps, in rough order of value:
+
+- **Publish the test results** as a check run so failures annotate the pull request directly, using something like `dorny/test-reporter`, which reads the NUnit file you are already producing.
+- **Send `coverage.xml` to a coverage service** — the JaCoCo format is widely supported.
+- **Require the check** in branch protection, so a red build actually blocks the merge. Until you do this, CI is advisory.
+- **Cache the Pester install** if the install step becomes a meaningful share of the run time.
+
+## You are done
+
+Starting from an empty folder, you have built a PowerShell module with a manifest, a loader, public and private functions, an external data file and a function that writes reports — and tested all of it:
+
+- 26 tests across six test files
+- Public functions tested through the module's real front door, which proves they are exported
+- Private helpers reached with `InModuleScope`, sparingly
+- A data source replaced by mocks so tests own their data
+- File operations isolated in `TestDrive`, leaving nothing behind
+- 100% coverage, arrived at by reading `CommandsMissed` rather than chasing a number
+- A test script that fails correctly, running on three operating systems — and on Windows PowerShell 5.1 — on every push
+
+The reference documentation goes deeper on everything here: [mocking](../../docs/usage/mocking), [TestDrive](../../docs/usage/testdrive), [code coverage](../../docs/usage/code-coverage), [configuration](../../docs/usage/configuration) and [the result object](../../docs/usage/result-object). If you write assertions you wish existed, [custom assertions](../../docs/assertions/custom-assertions) is the next thing worth reading.
+
+
diff --git a/tutorial/7-ci/_category_.json b/tutorial/7-ci/_category_.json
new file mode 100644
index 00000000..289627ad
--- /dev/null
+++ b/tutorial/7-ci/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Setting up CI",
+ "collapsed": false
+}
diff --git a/versioned_docs/version-v4/commands/Add-AssertionOperator.mdx b/versioned_docs/version-v4/commands/Add-AssertionOperator.mdx
index 5462690e..0277ac20 100644
--- a/versioned_docs/version-v4/commands/Add-AssertionOperator.mdx
+++ b/versioned_docs/version-v4/commands/Add-AssertionOperator.mdx
@@ -1,8 +1,5 @@
---
-# Override id to have common docId for Commands navbar-link for all versions
-id: Add-ShouldOperator
-# Override slug so url matches v4 name
-slug: Add-AssertionOperator
+id: Add-AssertionOperator
title: Add-AssertionOperator
description: Help page for the Powershell Pester "Add-AssertionOperator" command
keywords:
diff --git a/versioned_sidebars/version-v4-sidebars.json b/versioned_sidebars/version-v4-sidebars.json
index ddfde6fd..8827b57b 100644
--- a/versioned_sidebars/version-v4-sidebars.json
+++ b/versioned_sidebars/version-v4-sidebars.json
@@ -1,128 +1,137 @@
{
"docs": [
{
- "collapsed": true,
"type": "category",
- "label": "Introduction",
+ "label": "Documentation",
+ "collapsible": false,
"items": [
{
- "type": "doc",
- "id": "quick-start"
- },
- {
- "type": "doc",
- "id": "introduction/installation"
- },
- {
- "type": "doc",
- "id": "introduction/powershell-core-limitations"
- }
- ]
- },
- {
- "collapsed": true,
- "type": "category",
- "label": "Usage",
- "items": [
- {
- "type": "doc",
- "id": "usage/assertions"
- },
- {
- "type": "doc",
- "id": "usage/mocking"
- },
- {
- "type": "doc",
- "id": "usage/modules"
- },
- {
- "type": "doc",
- "id": "usage/testdrive"
- },
- {
- "type": "doc",
- "id": "usage/testregistry"
- },
- {
- "type": "doc",
- "id": "usage/test-results"
- },
- {
- "type": "doc",
- "id": "usage/code-coverage"
+ "collapsed": true,
+ "type": "category",
+ "label": "Introduction",
+ "items": [
+ {
+ "type": "doc",
+ "id": "quick-start"
+ },
+ {
+ "type": "doc",
+ "id": "introduction/installation"
+ },
+ {
+ "type": "doc",
+ "id": "introduction/powershell-core-limitations"
+ }
+ ]
+ },
+ {
+ "collapsed": true,
+ "type": "category",
+ "label": "Usage",
+ "items": [
+ {
+ "type": "doc",
+ "id": "usage/assertions"
+ },
+ {
+ "type": "doc",
+ "id": "usage/mocking"
+ },
+ {
+ "type": "doc",
+ "id": "usage/modules"
+ },
+ {
+ "type": "doc",
+ "id": "usage/testdrive"
+ },
+ {
+ "type": "doc",
+ "id": "usage/testregistry"
+ },
+ {
+ "type": "doc",
+ "id": "usage/test-results"
+ },
+ {
+ "type": "doc",
+ "id": "usage/code-coverage"
+ }
+ ]
+ },
+ {
+ "collapsed": true,
+ "type": "category",
+ "label": "Migration Guides",
+ "items": [
+ {
+ "type": "doc",
+ "id": "migrations/v3-to-v4"
+ }
+ ]
+ },
+ {
+ "collapsed": true,
+ "type": "category",
+ "label": "Additional Resources",
+ "items": [
+ {
+ "type": "doc",
+ "id": "additional-resources/articles"
+ },
+ {
+ "type": "doc",
+ "id": "additional-resources/courses"
+ },
+ {
+ "type": "doc",
+ "id": "additional-resources/misc"
+ },
+ {
+ "type": "doc",
+ "id": "additional-resources/projects"
+ },
+ {
+ "type": "doc",
+ "id": "additional-resources/videos"
+ }
+ ]
+ },
+ {
+ "collapsed": true,
+ "type": "category",
+ "label": "Contributing",
+ "items": [
+ {
+ "type": "doc",
+ "id": "contributing/introduction"
+ },
+ {
+ "type": "doc",
+ "id": "contributing/reporting-issues"
+ },
+ {
+ "type": "doc",
+ "id": "contributing/feature-requests"
+ },
+ {
+ "type": "doc",
+ "id": "contributing/pull-requests"
+ }
+ ]
}
]
- },
- {
- "collapsed": true,
- "type": "category",
- "label": "Migration Guides",
- "items": [
- {
- "type": "doc",
- "id": "migrations/v3-to-v4"
- }
- ]
- },
- {
- "collapsed": true,
- "type": "category",
- "label": "Additional Resources",
- "items": [
- {
- "type": "doc",
- "id": "additional-resources/articles"
- },
- {
- "type": "doc",
- "id": "additional-resources/courses"
- },
- {
- "type": "doc",
- "id": "additional-resources/misc"
- },
- {
- "type": "doc",
- "id": "additional-resources/projects"
- },
- {
- "type": "doc",
- "id": "additional-resources/videos"
- }
- ]
- },
- {
- "collapsed": true,
- "type": "category",
- "label": "Contributing",
- "items": [
- {
- "type": "doc",
- "id": "contributing/introduction"
- },
- {
- "type": "doc",
- "id": "contributing/reporting-issues"
- },
- {
- "type": "doc",
- "id": "contributing/feature-requests"
- },
- {
- "type": "doc",
- "id": "contributing/pull-requests"
- }
- ]
- },
+ }
+ ],
+ "commands": [
{
- "collapsed": true,
"type": "category",
"label": "Command Reference",
+ "collapsible": false,
"items": [
{
"type": "doc",
- "id": "commands/Add-ShouldOperator"
+ "id": "commands/Add-AssertionOperator"
},
{
"type": "doc",
diff --git a/versioned_sidebars/version-v5-sidebars.json b/versioned_sidebars/version-v5-sidebars.json
index d47a449b..ad178970 100644
--- a/versioned_sidebars/version-v5-sidebars.json
+++ b/versioned_sidebars/version-v5-sidebars.json
@@ -1,79 +1,95 @@
{
- "docs": {
- "Introduction": [
- "quick-start",
- "introduction/installation"
- ],
- "Usage": [
- "usage/file-placement-and-naming",
- "usage/importing-tested-functions",
- "usage/test-file-structure",
- "usage/discovery-and-run",
- "usage/data-driven-tests",
- "usage/setup-and-teardown",
- "usage/tags",
- "usage/skip",
- "usage/mocking",
- "usage/modules",
- "usage/testdrive",
- "usage/testregistry",
- "usage/test-results",
- "usage/code-coverage",
- "usage/configuration",
- "usage/output",
- "usage/vscode",
- "usage/troubleshooting"
- ],
- "Assertions": [
- "assertions/should-command",
- "assertions/assertions",
- "assertions/custom-assertions"
- ],
- "Migration Guides": [
- "migrations/v4-to-v5",
- "migrations/breaking-changes-in-v5",
- "migrations/v3-to-v4"
- ],
- "Additional Resources": [
- "additional-resources/articles",
- "additional-resources/courses",
- "additional-resources/misc",
- "additional-resources/projects",
- "additional-resources/videos"
- ],
- "Contributing": [
- "contributing/introduction",
- "contributing/reporting-issues",
- "contributing/feature-requests",
- "contributing/pull-requests"
- ],
- "Command Reference": [
- "commands/Add-ShouldOperator",
- "commands/AfterAll",
- "commands/AfterEach",
- "commands/Assert-MockCalled",
- "commands/Assert-VerifiableMock",
- "commands/BeforeAll",
- "commands/BeforeDiscovery",
- "commands/BeforeEach",
- "commands/Context",
- "commands/ConvertTo-JUnitReport",
- "commands/ConvertTo-NUnitReport",
- "commands/ConvertTo-Pester4Result",
- "commands/Describe",
- "commands/Export-JUnitReport",
- "commands/Export-NUnitReport",
- "commands/Get-ShouldOperator",
- "commands/InModuleScope",
- "commands/Invoke-Pester",
- "commands/It",
- "commands/Mock",
- "commands/New-Fixture",
- "commands/New-MockObject",
- "commands/New-PesterConfiguration",
- "commands/New-PesterContainer",
- "commands/Set-ItResult",
- "commands/Should"
- ]
- }
+ "docs": [
+ {
+ "type": "category",
+ "label": "Documentation",
+ "collapsible": false,
+ "items": [
+ {
+ "Introduction": [
+ "quick-start",
+ "introduction/installation"
+ ],
+ "Usage": [
+ "usage/file-placement-and-naming",
+ "usage/importing-tested-functions",
+ "usage/test-file-structure",
+ "usage/discovery-and-run",
+ "usage/data-driven-tests",
+ "usage/setup-and-teardown",
+ "usage/tags",
+ "usage/skip",
+ "usage/mocking",
+ "usage/modules",
+ "usage/testdrive",
+ "usage/testregistry",
+ "usage/test-results",
+ "usage/code-coverage",
+ "usage/configuration",
+ "usage/output",
+ "usage/vscode",
+ "usage/troubleshooting"
+ ],
+ "Assertions": [
+ "assertions/should-command",
+ "assertions/assertions",
+ "assertions/custom-assertions"
+ ],
+ "Migration Guides": [
+ "migrations/v4-to-v5",
+ "migrations/breaking-changes-in-v5",
+ "migrations/v3-to-v4"
+ ],
+ "Additional Resources": [
+ "additional-resources/articles",
+ "additional-resources/courses",
+ "additional-resources/misc",
+ "additional-resources/projects",
+ "additional-resources/videos"
+ ],
+ "Contributing": [
+ "contributing/introduction",
+ "contributing/reporting-issues",
+ "contributing/feature-requests",
+ "contributing/pull-requests"
+ ]
+ }
+ ]
+ }
+ ],
+ "commands": [
+ {
+ "type": "category",
+ "label": "Command Reference",
+ "collapsible": false,
+ "items": [
+ "commands/Add-ShouldOperator",
+ "commands/AfterAll",
+ "commands/AfterEach",
+ "commands/Assert-MockCalled",
+ "commands/Assert-VerifiableMock",
+ "commands/BeforeAll",
+ "commands/BeforeDiscovery",
+ "commands/BeforeEach",
+ "commands/Context",
+ "commands/ConvertTo-JUnitReport",
+ "commands/ConvertTo-NUnitReport",
+ "commands/ConvertTo-Pester4Result",
+ "commands/Describe",
+ "commands/Export-JUnitReport",
+ "commands/Export-NUnitReport",
+ "commands/Get-ShouldOperator",
+ "commands/InModuleScope",
+ "commands/Invoke-Pester",
+ "commands/It",
+ "commands/Mock",
+ "commands/New-Fixture",
+ "commands/New-MockObject",
+ "commands/New-PesterConfiguration",
+ "commands/New-PesterContainer",
+ "commands/Set-ItResult",
+ "commands/Should"
+ ]
+ }
+ ]
}