Page complete. Your progress is saved in this browser.
}
+
+ );
+}
diff --git a/src/components/TutorialChecklist/styles.module.css b/src/components/TutorialChecklist/styles.module.css
new file mode 100644
index 00000000..67221844
--- /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(--ifm-color-primary);
+ 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(--ifm-color-primary);
+ 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..668fde5b
--- /dev/null
+++ b/src/components/TutorialTracker/index.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import { getPageIndex } from '@site/src/tutorial/tutorialData';
+import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline';
+import styles from './styles.module.css';
+
+function CheckIcon() {
+ return (
+
+ );
+}
+
+function warnUnknownPage(currentPageId) {
+ if (process.env.NODE_ENV === 'development' && currentPageId && !getPageIndex().has(currentPageId)) {
+ console.warn(
+ `[TutorialTracker] Page "${currentPageId}" is not listed in src/tutorial/tutorialData.js, ` +
+ 'so its progress will not be tracked. Add it to the matching module.',
+ );
+ }
+}
+
+export default function TutorialTracker({ currentPageId }) {
+ const { hydrated, modules, currentModule, totalPages, totalDone, percent } =
+ useTutorialOutline(currentPageId);
+
+ warnUnknownPage(currentPageId);
+
+ return (
+
+ );
+}
diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css
new file mode 100644
index 00000000..9d6b5415
--- /dev/null
+++ b/src/components/TutorialTracker/styles.module.css
@@ -0,0 +1,236 @@
+/**
+ * Tutorial progress tracker.
+ *
+ * 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 {
+ margin-bottom: 2rem;
+ padding: 1rem 1.25rem 1.25rem;
+ border: 1px solid var(--tut-border);
+ border-radius: 10px;
+ background: var(--tut-surface);
+}
+
+/* ---- header */
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.headerText {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+}
+
+.eyebrow {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--tut-muted);
+}
+
+.count {
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.percent {
+ font-size: 1.35rem;
+ font-weight: 700;
+ line-height: 1.2;
+ color: var(--ifm-color-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ---- progress bar */
+
+.bar {
+ height: 6px;
+ margin-top: 0.75rem;
+ border-radius: 999px;
+ background: var(--tut-track);
+ overflow: hidden;
+}
+
+.barFill {
+ height: 100%;
+ border-radius: inherit;
+ background: var(--ifm-color-primary);
+ /* 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: 1rem;
+ border-top: 1px solid var(--tut-border);
+ padding-top: 0.75rem;
+}
+
+.summary {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ cursor: pointer;
+ font-size: 0.9rem;
+ list-style-position: outside;
+}
+
+.summaryLabel {
+ font-weight: 600;
+}
+
+.summaryHint {
+ font-size: 0.8rem;
+ color: var(--tut-muted);
+}
+
+.moduleList {
+ margin: 0.75rem 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.moduleItem {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-size: 0.875rem;
+}
+
+.moduleLabel {
+ color: var(--tut-muted);
+}
+
+.moduleLabelCurrent {
+ color: inherit;
+ font-weight: 600;
+}
+
+.moduleCount {
+ color: var(--tut-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.badge {
+ padding: 0.05rem 0.45rem;
+ border-radius: 999px;
+ background: var(--tut-track);
+ color: var(--tut-muted);
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+/* ---- pages in the current module */
+
+.pageList {
+ margin: 1rem 0 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: tut-page;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.pageLink {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.35rem 0.5rem;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ color: var(--ifm-font-color-base);
+ text-decoration: none;
+}
+
+.pageLink:hover {
+ background: var(--tut-track);
+ color: var(--ifm-font-color-base);
+ text-decoration: none;
+}
+
+.pageLinkCurrent {
+ background: var(--tut-current-bg);
+ font-weight: 600;
+}
+
+.marker {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ border: 1.5px solid var(--tut-border-strong);
+ border-radius: 50%;
+ color: transparent;
+}
+
+.markerDone {
+ border-color: var(--ifm-color-primary);
+ background: var(--ifm-color-primary);
+ color: #fff;
+}
+
+.icon {
+ display: block;
+}
+
+.pageTitle {
+ min-width: 0;
+}
+
+.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-300);
+ --tut-track: var(--ifm-color-emphasis-200);
+ --tut-current-bg: rgba(0, 114, 198, 0.1);
+ --tut-muted: var(--ifm-color-emphasis-700);
+}
+
+: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-current-bg: rgba(2, 148, 255, 0.16);
+ --tut-muted: var(--ifm-color-emphasis-600);
+}
diff --git a/src/theme/DocItem/Layout/index.js b/src/theme/DocItem/Layout/index.js
new file mode 100644
index 00000000..1d77fa69
--- /dev/null
+++ b/src/theme/DocItem/Layout/index.js
@@ -0,0 +1,38 @@
+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';
+
+/**
+ * Renders the tutorial progress tracker above the content on /tutorial pages, so tutorial
+ * pages do not each have to import it.
+ *
+ * DocItem/Layout is the right injection point: it renders exactly once per doc page and sits
+ * inside DocProvider, so useDoc() gives us the current page id.
+ *
+ * The guard uses the plugin instance id rather than the pathname, which would break under a
+ * non-root baseUrl or a different trailing-slash setting.
+ *
+ * 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 } = 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/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..83af2e78
--- /dev/null
+++ b/src/tutorial/tutorialData.js
@@ -0,0 +1,66 @@
+/**
+ * Single source of truth for the tutorial structure.
+ *
+ * Every `page.id` MUST match the Docusaurus doc id of the corresponding file in /tutorial
+ * (which is the path relative to the tutorial folder, without the extension, unless the
+ * page overrides it with an `id` in its frontmatter). A mismatch silently untracks the
+ * page — TutorialTracker warns about this in development.
+ *
+ * Modules marked `upcoming: true` are rendered in the tracker but are not linkable and
+ * do not count toward progress. Add their pages here as they are written.
+ */
+
+export const TUTORIAL_PLUGIN_ID = 'tutorial';
+export const TUTORIAL_ROUTE_BASE = '/tutorial';
+
+export const modules = [
+ {
+ id: 'introduction',
+ label: 'Introduction',
+ pages: [
+ { id: 'introduction/welcome', title: 'Welcome' },
+ { id: 'introduction/prerequisites', title: 'Prerequisites' },
+ ],
+ },
+ {
+ id: 'testing-our-module',
+ label: 'Testing our module',
+ pages: [
+ { id: 'testing-our-module/setup', title: 'Creating the sample module' },
+ { id: 'testing-our-module/first-test', title: 'Running your first test' },
+ { id: 'testing-our-module/public-functions', title: 'Testing public functions' },
+ { id: 'testing-our-module/private-functions', title: 'Testing private functions' },
+ { id: 'testing-our-module/output', title: 'Output' },
+ ],
+ },
+ { id: 'mocking', label: 'Mocking', upcoming: true, pages: [] },
+ { id: 'testdrive', label: 'Working with files', upcoming: true, pages: [] },
+ { id: 'code-coverage', label: 'Code coverage', upcoming: true, pages: [] },
+ { id: 'ci', label: 'Setting up CI', upcoming: true, pages: [] },
+];
+
+/** Pages that count toward progress, i.e. everything in a module that is not `upcoming`. */
+export const trackedModules = modules.filter((m) => !m.upcoming);
+
+let pageIndex;
+
+/**
+ * Lazily built `Map` for locating the current page.
+ * Built once — the data above is static.
+ */
+export function getPageIndex() {
+ if (!pageIndex) {
+ pageIndex = new Map();
+ modules.forEach((module, moduleIndex) => {
+ module.pages.forEach((page, pageIdx) => {
+ pageIndex.set(page.id, { module, moduleIndex, pageIndex: pageIdx });
+ });
+ });
+ }
+ return pageIndex;
+}
+
+/** Flat list of every tracked page id, in tutorial order. */
+export function getAllTrackedPageIds() {
+ return trackedModules.flatMap((m) => m.pages.map((p) => p.id));
+}
diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js
new file mode 100644
index 00000000..005f4a38
--- /dev/null
+++ b/src/tutorial/useTutorialOutline.js
@@ -0,0 +1,52 @@
+import { useMemo } from 'react';
+import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
+import { modules, TUTORIAL_ROUTE_BASE } from './tutorialData';
+import { isPageComplete, useTutorialProgress } from './TutorialProgressContext';
+
+/**
+ * Combines the static tutorial structure with stored progress into everything the tracker
+ * needs to render.
+ *
+ * Totals are derived here rather than persisted, so editing tutorialData.js can never leave
+ * stored progress inconsistent. Upcoming modules have no pages and are excluded from totals.
+ */
+export function useTutorialOutline(currentPageId) {
+ const { hydrated, pages } = useTutorialProgress();
+ // useBaseUrlUtils rather than useBaseUrl, since we need to build many URLs in a loop and
+ // hooks cannot be called inside one.
+ const { withBaseUrl } = useBaseUrlUtils();
+
+ return useMemo(() => {
+ const outline = modules.map((module) => {
+ const modulePages = module.pages.map((page) => ({
+ ...page,
+ permalink: withBaseUrl(`${TUTORIAL_ROUTE_BASE}/${page.id}`),
+ done: isPageComplete(pages, page.id),
+ isCurrent: page.id === currentPageId,
+ }));
+
+ return {
+ id: module.id,
+ label: module.label,
+ upcoming: Boolean(module.upcoming),
+ pages: modulePages,
+ total: modulePages.length,
+ done: modulePages.filter((p) => p.done).length,
+ isCurrent: modulePages.some((p) => p.isCurrent),
+ };
+ });
+
+ const tracked = outline.filter((m) => !m.upcoming);
+ const totalPages = tracked.reduce((sum, m) => sum + m.total, 0);
+ const totalDone = tracked.reduce((sum, m) => sum + m.done, 0);
+
+ return {
+ hydrated,
+ modules: outline,
+ currentModule: outline.find((m) => m.isCurrent) ?? null,
+ totalPages,
+ totalDone,
+ percent: totalPages === 0 ? 0 : Math.round((totalDone / totalPages) * 100),
+ };
+ }, [hydrated, pages, currentPageId, withBaseUrl]);
+}
diff --git a/tutorial/introduction/prerequisites.mdx b/tutorial/introduction/prerequisites.mdx
new file mode 100644
index 00000000..4ac1abfe
--- /dev/null
+++ b/tutorial/introduction/prerequisites.mdx
@@ -0,0 +1,79 @@
+---
+id: prerequisites
+title: Prerequisites
+sidebar_label: 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
+
+This is the step worth doing carefully. Importing the module and asking it what it is beats trusting what you think you installed:
+
+```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
+
+Any editor will do, but [Visual Studio Code](https://code.visualstudio.com/) with the [PowerShell extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell) gives you the best experience here: tests get a green or red gutter icon next to them, and you can run a single test without leaving the file.
+
+The [VS Code page](../../docs/usage/vscode) has the setup details. It is genuinely optional — everything in this tutorial is done from the terminal and works the same way regardless of what you type it in.
+
+## 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/introduction/welcome.mdx b/tutorial/introduction/welcome.mdx
new file mode 100644
index 00000000..5cd51c6a
--- /dev/null
+++ b/tutorial/introduction/welcome.mdx
@@ -0,0 +1,70 @@
+---
+id: welcome
+title: Welcome to the Pester tutorial
+sidebar_label: 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.
+
+You will type every command yourself. Nothing is hidden behind a "download the finished sample" link.
+
+## 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
+├── Public/
+│ ├── Get-Planet.ps1
+│ ├── Get-PlanetDistance.ps1
+│ └── Export-PlanetReport.ps1
+└── Private/
+ ├── ConvertTo-AstronomicalUnit.ps1
+ └── Test-PlanetName.ps1
+```
+
+It has public functions that users call, private helpers that they should not, a function that writes files to disk, and a dependency worth faking. Each of those is a testing problem with its own chapter.
+
+## 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 our module** | Building Planetarium and testing it end to end |
+| Mocking | Replacing dependencies your code calls |
+| Working with files | Isolating file operations with `TestDrive` |
+| Code coverage | Finding out what your tests never touch |
+| Setting up CI | Running the whole thing on every push |
+
+The first two modules are written. The rest are on their way — you will see them greyed out in the progress panel.
+
+## 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 panel 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. Nothing is sent anywhere and there is no account to create — but that also means your progress will not follow you to another browser or another machine, and clearing site 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 have used Pester v4 and are coming back, be aware this tutorial teaches Pester v6 and a fair amount has changed. The [migration guide](../../docs/migrations/v5-to-v6) is the faster read for that.
+
+
+
+Next up: getting Pester installed and confirming the version you are running.
diff --git a/tutorial/testing-our-module/first-test.mdx b/tutorial/testing-our-module/first-test.mdx
new file mode 100644
index 00000000..cd159ad6
--- /dev/null
+++ b/tutorial/testing-our-module/first-test.mdx
@@ -0,0 +1,169 @@
+---
+id: first-test
+title: Running your first test
+sidebar_label: 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. Red to green.
+
+## 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
+```
+
+Pester reports the types as well as the values — `[int] 9` rather than just `9`. That detail earns its keep the day you assert `Should-Be 8` against the *string* `'8'` and cannot see why a test comparing two things that look identical is failing.
+
+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/testing-our-module/output.mdx b/tutorial/testing-our-module/output.mdx
new file mode 100644
index 00000000..5b02522e
--- /dev/null
+++ b/tutorial/testing-our-module/output.mdx
@@ -0,0 +1,176 @@
+---
+id: output
+title: Output
+sidebar_label: Output
+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. The v4-era parameters — `-Script`, `-OutputFile`, `-OutputFormat`, `-EnableExit`, `-CodeCoverage` — were removed, and everything they did now lives on this object. `-Path` survives as a convenience for quick runs.
+
+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:
+
+```
+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`:
+
+```
+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 need the numbers in code — 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 |
+ Where-Object Result -eq 'Failed' |
+ Select-Object Name, @{ 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. CI systems do not read console output — they read a test results file, and use it to render the run and annotate failures.
+
+```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.
+
+Add the file to your `.gitignore` — it is a build artifact:
+
+```
+testResults.xml
+```
+
+## Putting it together
+
+A single configuration usually covers a whole project. This is a reasonable one to keep as `build.ps1` at the root of the tutorial folder:
+
+```powershell title="build.ps1"
+$config = New-PesterConfiguration
+$config.Run.Path = './Planetarium'
+$config.Run.PassThru = $true
+$config.Output.Verbosity = 'Detailed'
+$config.TestResult.Enabled = $true
+$config.TestResult.OutputPath = './testResults.xml'
+
+$result = Invoke-Pester -Configuration $config
+
+if ($result.FailedCount -gt 0) {
+ throw "$($result.FailedCount) test(s) failed."
+}
+```
+
+```powershell
+./build.ps1
+```
+
+That last block is the part people forget. A failing test does not, on its own, make `Invoke-Pester` fail your build — so a pipeline without an explicit check will happily report success over a suite full of red. Throwing on `FailedCount` is what turns a test run into a gate, and it is exactly what the CI module later in this tutorial builds on.
+
+
+
+## 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 build script that gates on failures and emits a results file.
+
+The remaining tutorial modules pick up from exactly here. `Get-PlanetDistance` will get a real external dependency worth mocking, `Export-PlanetReport` will write files that need isolating with `TestDrive`, code coverage will show you the branches these tests never touch, and the CI module will run the whole thing on every push.
+
+Those pages are being written. In the meantime the reference documentation covers all four: [Mocking](../../docs/usage/mocking), [TestDrive](../../docs/usage/testdrive), [Code coverage](../../docs/usage/code-coverage) and [Test results](../../docs/usage/test-results).
diff --git a/tutorial/testing-our-module/private-functions.mdx b/tutorial/testing-our-module/private-functions.mdx
new file mode 100644
index 00000000..b2216e28
--- /dev/null
+++ b/tutorial/testing-our-module/private-functions.mdx
@@ -0,0 +1,130 @@
+---
+id: private-functions
+title: Testing private functions
+sidebar_label: 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'},
+]} />
+
+The module is covered. Last page of this chapter: making Pester tell you what you want to know.
diff --git a/tutorial/testing-our-module/public-functions.mdx b/tutorial/testing-our-module/public-functions.mdx
new file mode 100644
index 00000000..311de1f2
--- /dev/null
+++ b/tutorial/testing-our-module/public-functions.mdx
@@ -0,0 +1,187 @@
+---
+id: public-functions
+title: Testing public functions
+sidebar_label: 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 {
+ 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 **script block**, 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/testing-our-module/setup.mdx b/tutorial/testing-our-module/setup.mdx
new file mode 100644
index 00000000..350bc856
--- /dev/null
+++ b/tutorial/testing-our-module/setup.mdx
@@ -0,0 +1,104 @@
+---
+id: setup
+title: Creating the sample module
+sidebar_label: Setup
+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 two folders that matter:
+
+- **`Public/`** — functions your users call. These get exported.
+- **`Private/`** — helpers that only the module itself uses. These do not.
+
+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. Ours does not define any functions itself; it finds the `.ps1` files 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` matters because of where we are about to put our tests. Pester's convention is that test files sit next to the code they test, so `Public/` will shortly contain both `Get-Planet.ps1` and `Get-Planet.Tests.ps1`. 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 When a function you exported is not there
+`FunctionsToExport` is the first thing to check. If it is set to an explicit list — or worse, to `@()` — then adding a function to `Public/` will not make it appear, no matter what `Export-ModuleMember` says.
+:::
+
+## Checking it imports
+
+Nothing is in the module yet, but it should still load cleanly:
+
+```powershell
+Import-Module ./Planetarium/Planetarium.psd1 -Force -PassThru
+```
+
+```
+ModuleType Version PreRelease Name
+---------- ------- ---------- ----
+Script 0.1.0 Planetarium
+```
+
+An empty module that imports without error is exactly what you want right now. If instead you get an error, it is almost certainly a typo in the `.psm1` path handling — fix it before moving on, because every page from here imports this module.
+
+:::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.
From 2563bc51fa53c2ad8524cb73c70e74c2924e60bd Mon Sep 17 00:00:00 2001
From: Frode Flaten <3436158+fflaten@users.noreply.github.com>
Date: Sun, 19 Jul 2026 20:48:02 +0000
Subject: [PATCH 02/27] Update mobile design
---
docusaurus.config.js | 3 +
src/components/TutorialTracker/index.js | 152 +++++++++---
.../TutorialTracker/styles.module.css | 229 ++++++++++++------
src/theme/DocItem/Layout/index.js | 19 +-
src/theme/DocItem/TOC/Desktop/index.js | 25 ++
src/theme/DocItem/TOC/Mobile/index.js | 25 ++
6 files changed, 335 insertions(+), 118 deletions(-)
create mode 100644 src/theme/DocItem/TOC/Desktop/index.js
create mode 100644 src/theme/DocItem/TOC/Mobile/index.js
diff --git a/docusaurus.config.js b/docusaurus.config.js
index fc0a1fcb..2034fe9c 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -172,6 +172,9 @@ const config = {
routeBasePath: 'tutorial',
sidebarPath: './sidebarsTutorial.js',
editUrl: 'https://github.com/pester/docs/edit/main',
+ // The progress tracker already shows the module, the page and where both sit in the
+ // tutorial, so breadcrumbs would only repeat it.
+ breadcrumbs: false,
// Do not set disableVersioning here. It throws for an instance with no versions file.
// Omitting it gives a single implicit version and no version dropdown, which is what we want.
}),
diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js
index 668fde5b..bf176757 100644
--- a/src/components/TutorialTracker/index.js
+++ b/src/components/TutorialTracker/index.js
@@ -1,18 +1,21 @@
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 { getPageIndex } from '@site/src/tutorial/tutorialData';
import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline';
import styles from './styles.module.css';
function CheckIcon() {
return (
-