Тесты PowerShell на Pester — практическая схема, которая делает эксплуатационные скрипты устойчивее к поломкам

· Обновлено: · · PowerShell, Pester, Windows, Тестирование, Автоматизация, CI, существующий код

История изменений (4 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
В начало статьи добавлена секция «Карта знаний этой статьи». Понятия из текста и связи между ними собраны в краткое описание, схему и ссылку на страницу сведений. Утверждения в основном тексте не менялись.
Текст обновлён по итогам внешнего ревью (1283 замечания). Содержание отдельных правок смотрите в записях ниже.
Добавлены полные примеры настройки GitHub Actions и Azure Pipelines. Вместе с ними — причины, почему Install-Module спотыкается в корпоративной среде, и что с этим делать; таблица, что смотреть модульными тестами по типам скриптов; пример настройки покрытия.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619879)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Тесты PowerShell на Pester — практическая схема, которая делает эксплуатационные скрипты устойчивее к поломкам. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/06/08/000-pester-powershell-test-maintenance/

DOI (зарегистрированный архив)
10.5281/zenodo.21619879
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619880

1. Что стоит зафиксировать в первую очередь

PowerShell-скрипты обычно начинаются как автоматизация мелких задач.

Собрать файлы. Найти строки в логах. Собрать CSV. Переместить старые файлы. Проверить состояние службы.

Пока это несколько десятков строк, достаточно прогнать глазами. Но пока скрипт живёт в реальной эксплуатации, в него постепенно входят такие изменения:

  • появляются новые целевые папки;
  • добавляются исключения;
  • меняются столбцы CSV;
  • перед удалением появляется архивирование;
  • запуск уходит в Планировщик заданий или CI;
  • при ошибке добавляется уведомление.

На этом этапе «один раз сработало у меня на машине» уже недостаточно.

У PowerShell пугающая сторона — оборот удобства. Чтение можно пробовать спокойно. Удаление, перемещение, перезапись, перезапуск служб, смена прав — здесь небольшая ошибка в условии уже оборачивается инцидентом.

Именно здесь нужен Pester, тестовый фреймворк для PowerShell. Статья не перечисляет все возможности Pester. Она собирает практический способ выстроить тесты так, чтобы уже существующие PowerShell-скрипты было сложнее сломать в работе.

Тесты PowerShell нужны не только затем, чтобы код выглядел аккуратно. Это инструмент, который снижает тревогу перед правкой и даёт основание проверить результат после правки.

Код из статьи выложен на GitHub полным набором примеров, которые запускаются через Invoke-Pester (тестируемый скрипт, тесты Pester и скрипт запуска для CI).

pester-powershell-test-maintenance - komurasoft-blog-samples (GitHub)

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 18, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что именно защищает Pester

Подключить Pester — ещё не значит, что всё само станет безопасным. Сначала нужно решить, «что тесты должны охранять».

В эксплуатационных скриптах PowerShell быстрее всего окупаются вот эти четыре зоны.

Что охраняем Что смотрят тесты
Условия отбора Какие файлы, строки, пользователи, службы попадают в работу
Форма вывода Имена столбцов CSV, свойства возвращаемого объекта, число записей
Шаг перед опасной операцией Совпадает ли набор на удаление, перемещение, остановку с замыслом
Внешние зависимости Как скрипт обращается к файловой системе, API, запуску команд, дате и времени, переменным окружения

Первым делом тестируйте не само удаление, а логику, которая выбирает, что удалять.

Например, в скрипте очистки старых логов не начинайте с теста на Remove-Item. Сначала проверьте, «какие логи вообще попадают в выборку».

Такое разделение сразу упрощает тесты.

Функция, которая собирает объекты
  ↓
Шаг, который сверяет объекты и пишет их в журнал
  ↓
Изменяющий шаг: перемещение, удаление, уведомление и т. п.

Выстроить тесты PowerShell — это не сразу переписать существующие скрипты. Сначала вынесите в функцию суждение, которое стоит перед опасным шагом, и проверьте его возвращаемое значение через Pester.

3. Приведите версии к одному уровню

Эта статья исходит из Pester v5.

На старых Windows Pester уже может стоять — но ветки v3. Не берите то, что «уже есть в среде», пока не посмотрите версию.

Get-Module Pester -ListAvailable |
  Sort-Object Version -Descending |
  Select-Object Name, Version, Path

Если ставите заново, берите из PowerShell Gallery.

Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck
Import-Module Pester
Get-Module Pester

-SkipPublisherCheck здесь потому, что старый Pester, который уже лежит в Windows, подписан Microsoft. Без этого флага установка останавливается: «издатель другой».

На корпоративных машинах эта команда часто не проходит как есть. Типичные причины — прокси, TLS 1.2 и NuGet-провайдер. Что делать, собрано в главе 17.

Если Pester использует команда, проверьте, что версия не разъехалась между машиной разработчика, сборочным сервером и средой, где крутятся задачи.

Путаница в тестах PowerShell чаще растёт не из кода, а из разных версий тестового раннера.

Особенно в старых статьях и внутренних заметках ещё живёт синтаксис Pester v4 и раньше. Если выстраиваете тесты заново, держитесь стиля v5 — потом читать проще.

4. Зафиксируйте, где лежат файлы

В Pester файлы тестов принято называть *.Tests.ps1.

Минимальная раскладка выглядит так.

scripts/
  Get-OldLogFile.ps1
  Get-OldLogFile.Tests.ps1

Если проект чуть больше, разведите src и tests.

src/
  public/
    Get-OldLogFile.ps1
    Remove-OldLogFile.ps1

tests/
  public/
    Get-OldLogFile.Tests.ps1
    Remove-OldLogFile.Tests.ps1

Подойдёт любой вариант. Важно само правило.

  • на одну функцию — один файл тестов;
  • в имени файла тестов есть .Tests.ps1;
  • способ загрузки тестируемого кода один и тот же;
  • модульные и интеграционные тесты не смешивают слишком свободно.

На старте достаточно положить целевой .ps1 рядом с его .Tests.ps1.

5. Запустите минимальный тест

Сначала подготовим простую функцию Get-OldLogFile.ps1.

function Get-OldLogFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [int] $Days = 30,

        [string] $Filter = '*.log',

        [datetime] $Now = (Get-Date)
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
        throw "Folder not found: $Path"
    }

    $limit = $Now.AddDays(-1 * $Days)

    Get-ChildItem -LiteralPath $Path -Filter $Filter -File |
        Where-Object { $_.LastWriteTime -lt $limit } |
        Sort-Object -Property LastWriteTime |
        Select-Object FullName, Name, Length, LastWriteTime
}

Здесь $Now можно передать аргументом — так функцию проще тестировать.

Если внутри каждый раз напрямую вызывать Get-Date, результат зависит от дня запуска. Когда дата — параметр, условие можно зафиксировать: «файлы старше 30 дней на 1 июня 2026 года» — и тестировать именно его.

Дальше тесты пишем в Get-OldLogFile.Tests.ps1.

BeforeAll {
    . $PSScriptRoot\Get-OldLogFile.ps1
}

Describe 'Get-OldLogFile' {
    BeforeEach {
        $script:Root = Join-Path $TestDrive 'logs'
        New-Item -ItemType Directory -Path $script:Root -Force | Out-Null

        $oldLog = Join-Path $script:Root 'old.log'
        $newLog = Join-Path $script:Root 'new.log'
        $oldTxt = Join-Path $script:Root 'old.txt'

        Set-Content -LiteralPath $oldLog -Value 'old log' -Encoding UTF8
        Set-Content -LiteralPath $newLog -Value 'new log' -Encoding UTF8
        Set-Content -LiteralPath $oldTxt -Value 'old text' -Encoding UTF8

        (Get-Item -LiteralPath $oldLog).LastWriteTime = [datetime]'2026-05-01T00:00:00'
        (Get-Item -LiteralPath $newLog).LastWriteTime = [datetime]'2026-05-31T00:00:00'
        (Get-Item -LiteralPath $oldTxt).LastWriteTime = [datetime]'2026-05-01T00:00:00'
    }

    It 'возвращает только .log-файлы старше заданного числа дней' {
        $result = Get-OldLogFile `
            -Path $script:Root `
            -Days 30 `
            -Now ([datetime]'2026-06-01T00:00:00')

        $result | Should -HaveCount 1
        $result[0].Name | Should -Be 'old.log'
    }

    It 'завершается ошибкой, если папки нет' {
        { Get-OldLogFile -Path (Join-Path $TestDrive 'missing') } |
            Should -Throw
    }
}

Запускаем.

Invoke-Pester -Output Detailed .\Get-OldLogFile.Tests.ps1

$TestDrive здесь — временная область, которую Pester выделяет под тесты. Вместо настоящего C:\Logs или общего ресурса можно работать только с файлами, созданными внутри теста. Для PowerShell-скриптов с файловыми операциями привычка сначала брать $TestDrive как раз и держит тесты в безопасной зоне.

6. Имена тестов пишите как спецификацию

Строка в It у Pester — не подпись «для красоты». Для того, кто прочитает её позже, это короткая спецификация.

Такое имя слабое.

It 'works' {
    # ...
}

Непонятно, что именно должно работать.

На практике имена читаются лучше, если в них есть условие и ожидаемый результат.

It 'возвращает только .log-файлы старше заданного числа дней' {
    # ...
}

It 'не включает файл ровно на граничной дате' {
    # ...
}

It 'завершается ошибкой, если папки нет' {
    # ...
}

Хорошее имя окупается в момент падения. Когда в логе CI видна такая строка, сразу ясно, что сломалось.

[-] Get-OldLogFile.не включает файл ровно на граничной дате

Имя теста — заметка себе же в будущем.

7. Добавьте одно граничное условие

Приведённая выше Get-OldLogFile считает файл старым по такому условию.

$_.LastWriteTime -lt $limit

Из‑за -lt файл с меткой ровно на граничной дате в выборку не попадает.

Решение маленькое, на практике важное. «Старше 30 дней» и «включая день 30 дней назад» — разные условия, и от них меняется число выбранных файлов.

Добавим граничное условие в тесты.

It 'не включает файл ровно на граничной дате' {
    $border = Join-Path $script:Root 'border.log'
    Set-Content -LiteralPath $border -Value 'border log' -Encoding UTF8
    (Get-Item -LiteralPath $border).LastWriteTime = [datetime]'2026-05-02T00:00:00'

    $result = Get-OldLogFile `
        -Path $script:Root `
        -Days 30 `
        -Now ([datetime]'2026-06-01T00:00:00')

    $result.Name | Should -Not -Contain 'border.log'
}

Дело не в том, чтобы писать как можно больше тестов. Но там, где есть граница — даты, числа, количество, права, шаблоны имён файлов — тесты дают максимум пользы.

8. Зафиксируйте форму возвращаемого значения

В PowerShell-скриптах форма возвращаемого значения иногда меняется незаметно.

Сначала функция отдавала FileInfo как есть. Потом появился Select-Object. Потом имена столбцов подкрутили под CSV.

Такие правки бьют по следующему шагу, поэтому свойства возвращаемого объекта стоит покрыть тестом — тогда неожиданное изменение сразу видно.

It 'возвращает свойства, которые использует следующий шаг' {
    $result = Get-OldLogFile `
        -Path $script:Root `
        -Days 30 `
        -Now ([datetime]'2026-06-01T00:00:00')

    $propertyNames = $result[0].PSObject.Properties.Name

    $propertyNames | Should -Contain 'FullName'
    $propertyNames | Should -Contain 'Name'
    $propertyNames | Should -Contain 'Length'
    $propertyNames | Should -Contain 'LastWriteTime'
}

Для функций, которые кормят CSV или отчёт, имена столбцов — часть спецификации, а не только значения.

Проверяйте не только «сработало», но и «вернулось в той форме, которую ждёт следующий шаг».

9. Отделите удаление от выбора объектов

Теперь про удаление. Сначала плохой пример.

Get-ChildItem C:\Logs -Filter *.log -File |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
    Remove-Item -Force

Коротко и удобно, но тестировать тяжело. Выбор объектов и удаление склеены в одном конвейере, и непонятно, что именно проверять.

На практике разделяем так.

function Remove-OldLogFile {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [int] $Days = 30,

        [datetime] $Now = (Get-Date)
    )

    $targets = Get-OldLogFile -Path $Path -Days $Days -Now $Now

    foreach ($target in $targets) {
        if ($PSCmdlet.ShouldProcess($target.FullName, 'Remove old log file')) {
            Remove-Item -LiteralPath $target.FullName -Force
        }
    }
}

Здесь стоит SupportsShouldProcess, чтобы функция принимала -WhatIf.

Remove-OldLogFile -Path C:\Logs -Days 30 -WhatIf

У PowerShell-функций, которые удаляют, безопаснее по возможности оставить пробный прогон через -WhatIf.

10. Опасные операции подменяйте через Mock

В Pester Mock подменяет реальное выполнение команды.

В тесте логики удаления по-настоящему вызывать Remove-Item не нужно.

Вызвалась ли команда там, где должна? Не вызвалась ли там, где не должна?

Этого достаточно.

Пример Remove-OldLogFile.Tests.ps1.

BeforeAll {
    . $PSScriptRoot\Get-OldLogFile.ps1
    . $PSScriptRoot\Remove-OldLogFile.ps1
}

Describe 'Remove-OldLogFile' {
    It 'вызывает Remove-Item для старых лог-файлов' {
        Mock Get-OldLogFile {
            [pscustomobject]@{
                FullName      = 'C:\Logs\old.log'
                Name          = 'old.log'
                Length        = 10
                LastWriteTime = [datetime]'2026-05-01'
            }
        }

        Mock Remove-Item {}

        Remove-OldLogFile `
            -Path 'C:\Logs' `
            -Days 30 `
            -Now ([datetime]'2026-06-01')

        Should -Invoke Remove-Item `
            -Times 1 `
            -Exactly `
            -ParameterFilter { $LiteralPath -eq 'C:\Logs\old.log' }
    }

    It 'при WhatIf не вызывает Remove-Item' {
        Mock Get-OldLogFile {
            [pscustomobject]@{
                FullName      = 'C:\Logs\old.log'
                Name          = 'old.log'
                Length        = 10
                LastWriteTime = [datetime]'2026-05-01'
            }
        }

        Mock Remove-Item {}

        Remove-OldLogFile `
            -Path 'C:\Logs' `
            -Days 30 `
            -Now ([datetime]'2026-06-01') `
            -WhatIf

        Should -Invoke Remove-Item -Times 0
    }
}

В этих тестах замоканы и Get-OldLogFile, и Remove-Item, поэтому настоящего C:\Logs\old.log может не быть. Смотрим именно решение Remove-OldLogFile.

  • если объекты есть, вызвать Remove-Item;
  • при -WhatIf не вызывать Remove-Item;
  • при вызове передать задуманный путь.

Чем опаснее операция, тем безопаснее тестировать условия вызова, а не само выполнение.

11. Не злоупотребляйте Mock

Mock удобен, но если мокать слишком много, ценность тестов падает. Замокать вообще всё — значит слишком далеко уйти от реального поведения PowerShell.

Ориентир примерно такой.

Операция Что лучше
Дата Зафиксировать аргументом
Создание файлов Использовать $TestDrive
Удаление и перемещение Проверять через Mock и -WhatIf
Вызовы веб-API Мокать Invoke-RestMethod и подобное
Отправка почты и уведомлений Мокать команду отправки
Чтение и запись CSV Класть небольшие настоящие файлы в $TestDrive

Если замокать даже чтение и запись файлов, можно пропустить реальные проблемы с кодировкой, переносами строк и именами столбцов.

С другой стороны, удаление, уведомления, внешние API, остановку служб по-настоящему лучше не выполнять.

Разделяйте, где работать с настоящим, а где мокать.

12. Подправьте существующие скрипты так, чтобы их было проще тестировать

Когда появляется Pester, стиль существующих скриптов чуть меняется. Большой пересмотр архитектуры с порога не нужен — для старта хватает такой правки.

До правки

$limit = (Get-Date).AddDays(-30)

Get-ChildItem C:\Logs -Filter *.log -File |
    Where-Object { $_.LastWriteTime -lt $limit } |
    Remove-Item -Force

После правки

function Get-OldLogFile {
    param(
        [string] $Path,
        [int] $Days = 30,
        [datetime] $Now = (Get-Date)
    )

    $limit = $Now.AddDays(-1 * $Days)

    Get-ChildItem -LiteralPath $Path -Filter *.log -File |
        Where-Object { $_.LastWriteTime -lt $limit }
}

function Remove-OldLogFile {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string] $Path,
        [int] $Days = 30,
        [datetime] $Now = (Get-Date)
    )

    Get-OldLogFile -Path $Path -Days $Days -Now $Now |
        ForEach-Object {
            if ($PSCmdlet.ShouldProcess($_.FullName, 'Remove old log file')) {
                Remove-Item -LiteralPath $_.FullName -Force
            }
        }
}

Изменения небольшие.

  • дату сделали аргументом;
  • выбор объектов вынесли в функцию;
  • удаление вынесли в отдельную функцию;
  • добавили SupportsShouldProcess.

Уже этого достаточно, чтобы тестировать стало заметно проще.

В тестах PowerShell эффективнее не начинать с теории проектирования, а сделать «дату», «путь», «внешнюю команду» и «изменяющую операцию» подставляемыми снаружи.

13. Разведите категории тестов

В Pester теги можно вешать на Describe, Context и It.

Например, отделим быстрые модульные тесты от интеграционных, которые трогают реальную среду.

Describe 'Get-OldLogFile' -Tag 'Unit' {
    It 'возвращает только .log-файлы старше заданного числа дней' {
        # Быстрый тест на TestDrive
    }
}

Describe 'Log maintenance smoke test' -Tag 'Smoke' {
    It 'может прочитать реальную папку логов' {
        Test-Path -LiteralPath 'C:\Logs' | Should -BeTrue
    }
}

Запускаем только модульные тесты.

Invoke-Pester -TagFilter Unit

Исключаем медленные или зависящие от среды.

Invoke-Pester -ExcludeTagFilter Slow, RequiresAdmin, Network

На площадке попытка гонять все тесты каждый раз часто не приживается.

Сначала стандартом сделайте быстрые тесты без побочных эффектов. Тесты, завязанные на среду, отделите тегами и гоняйте, когда это действительно нужно.

Таблица по типам скриптов

Где кончается модульный тест и начинается интеграционный, и чем там пользоваться, в целом задаёт тип скрипта.

Тип скрипта Что смотреть модульным тестом Чем пользоваться Что смотреть интеграционным тестом
Сбор файлов и выбор объектов Границы условия. До какого дня включительно, режет ли расширение Небольшие настоящие файлы в $TestDrive На настоящей папке число файлов совпадает с ожиданием
Ввод и вывод CSV и JSON Имена столбцов, порядок столбцов, кодировка, переносы строк Читать и писать настоящие файлы в $TestDrive Принимающая система реально открывает файл
Удаление, перемещение, перезапись Результат выбора объектов и пробный прогон через -WhatIf Mock Remove-Item / Mock Move-Item Один прогон в проверочной среде и сверка результата
Службы и процессы Логика, которая по состоянию выбирает следующий шаг Состояние через Mock Get-Service и подобное Реальный старт и останов на проверочной машине
Внешний API, уведомления, почта Собранный запрос или тело сообщения Mock Invoke-RestMethod / Mock Send-MailMessage Один раз отправить на staging
Решения по дате и периоду Граничные даты. Сегодня, вчера, високосный день Зафиксировать дату аргументом (не через Mock)
Чтение реестра и системных настроек Как интерпретируется прочитанное значение Mock Get-ItemProperty Сверить настоящее значение на машине

Смотреть можно с двух сторон.

  1. Модульный тест смотрит «наше суждение». Что стандартные команды работают правильно, мы не подтверждаем (глава 19).
  2. Опасную операцию в модульном тесте не выполняют. Удаление, перемещение, отправку, уведомление подменяют через Mock. Само выполнение проверяют интеграционным тестом, и редко.

Отдельно: дату фиксируйте аргументом, а не Mock. Если замокать Get-Date, в ту же подмену попадёт и другое получение даты в том же скрипте.

14. Запускайте в CI

Pester полезен и при локальном запуске. Если скрипты ведёт команда, возможность гонять его в CI даёт спокойствие.

Например, заведите файл вроде tools/Invoke-ProjectTests.ps1.

$ErrorActionPreference = 'Stop'

# В среде может остаться старый Pester, поэтому явно загружаем v5 и выше
Import-Module Pester -MinimumVersion 5.0.0

$config = New-PesterConfiguration

$config.Run.Path = @(
    Join-Path $PSScriptRoot '..\tests'
)

$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'

$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = 'JUnitXml'
$config.TestResult.OutputPath = Join-Path $PSScriptRoot '..\test-results.xml'

$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = @(
    Join-Path $PSScriptRoot '..\src'
)
$config.CodeCoverage.OutputPath = Join-Path $PSScriptRoot '..\coverage.xml'

Invoke-Pester -Configuration $config

На стороне CI запускаете этот скрипт.

pwsh -NoProfile -File .\tools\Invoke-ProjectTests.ps1

Главное — не растаскивать настройки, специфичные для CI, по файлам тестов.

Файл теста — место, где пишется спецификация. Формат вывода для CI, покрытие, код завершения удобнее держать в скрипте запуска.

Если скрипт запуска уже есть, настройки со стороны CI на любом сервисе остаются короткими.

GitHub Actions

Кладите .github/workflows/pester.yml.

name: pester

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: windows-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install Pester v5
        shell: pwsh
        run: |
          Install-Module -Name Pester -MinimumVersion 5.0.0 `
            -Scope CurrentUser -Force -SkipPublisherCheck

      - name: Run Pester
        shell: pwsh
        run: ./tools/Invoke-ProjectTests.ps1

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pester-results
          path: |
            test-results.xml
            coverage.xml

Имеет смысл держать в голове следующее.

  • Pester ставьте явно. В Windows уже лежит Pester ветки v3, и без переустановки синтаксис v5 не заработает.
  • Добавляйте -SkipPublisherCheck. Входящий в поставку Pester подписан Microsoft; без флага установка останавливается из‑за другого издателя.
  • Явно пишите shell: pwsh. На GitHub-hosted Windows-раннере по умолчанию уже pwsh, но на self-hosted, если нет PowerShell 7, будет откат на Windows PowerShell. Явное указание снимает разницу сред.
  • Результаты забирайте при if: always(). Смотреть отчёт хочется как раз когда тесты упали, поэтому шаг должен выполняться и при сбое.

Джоб валит $config.Run.Exit = $true. С ним при падении тестов Invoke-Pester завершается ненулевым кодом, и шаг тоже падает. Если это забыть, тесты красные, а джоб зелёный.

Azure Pipelines

azure-pipelines.yml выглядит так.

trigger:
  - main

pool:
  vmImage: 'windows-latest'

steps:
  - task: PowerShell@2
    displayName: 'Install Pester v5'
    inputs:
      pwsh: true
      targetType: 'inline'
      script: |
        Install-Module -Name Pester -MinimumVersion 5.0.0 `
          -Scope CurrentUser -Force -SkipPublisherCheck

  - task: PowerShell@2
    displayName: 'Run Pester'
    inputs:
      pwsh: true
      filePath: 'tools/Invoke-ProjectTests.ps1'

  - task: PublishTestResults@2
    displayName: 'Publish test results'
    condition: always()
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: 'test-results.xml'
      failTaskOnFailedTests: true

PublishTestResults@2 принимает форматы JUnit, NUnit, VSTest, XUnit, CTest. В скрипте запуска стоит $config.TestResult.OutputFormat = 'JUnitXml', поэтому здесь выбираем JUnit. Если меняете формат, меняйте оба места согласованно.

failTaskOnFailedTests: true валит задачу, как только в файле результатов есть падения. По умолчанию стоит false, и тогда задача только показывает результаты и проходит.

15. Покрытие — карта, а не цель

Pester умеет отдавать и покрытие кода. Только с самого начала не стоит слишком гнаться за цифрой: покрытие само по себе не есть качество тестов.

Например, один вызов функции отбора объектов удаления уже помечает эти строки как пройденные. Если граничные условия и исключения не проверены, практической уверенности это не даёт.

Покрытие используют так:

  • находят функции, которые вообще не выполнялись;
  • находят важные ветки, для которых нет тестов;
  • расставляют приоритеты, начиная со скриптов, которые меняют чаще;
  • оставляют в CI свидетельство, что тесты реально гонялись.

Смотрите не на рост цифры, а на то, «покрыты ли важные решения».

Снимают это через CodeCoverage в New-PesterConfiguration.

$config = New-PesterConfiguration

$config.Run.Path = @('.\tests')

$config.CodeCoverage.Enabled = $true

# Что измеряем. Указываем тестируемые скрипты, а не файлы тестов
$config.CodeCoverage.Path = @('.\src')

# Формат вывода. По умолчанию JaCoCo — его удобно подхватывать в отчёте покрытия CI
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = '.\coverage.xml'

# Целевой процент. Даже если ниже, тесты не падают — это ориентир, не порог
$config.CodeCoverage.CoveragePercentTarget = 75

Invoke-Pester -Configuration $config

В CodeCoverage.Path должны быть тестируемые скрипты, а не файлы тестов. Если указать .\tests, вы измерите покрытие самих тестов: цифра высокая, содержания нет.

Формат по умолчанию — JaCoCo. CoverageGutters даёт формат, в котором в редакторе видно прохождение по строкам. Для CI достаточно оставить JaCoCo.

CoveragePercentTarget — ориентир. Если цифра ниже, прогон из‑за этого не падает. Когда захочется сделать покрытие критерием «красный / зелёный», вернитесь к началу этой главы. Тесты, написанные ради цифры, в момент поломки обычно ничего не объясняют.

16. Порядок внедрения в существующие скрипты

Когда Pester ставят на уже накопленные PowerShell-скрипты, проще не объявлять всё тестовым контуром сразу. Ниже удобный порядок.

1. Выберите скрипты, чей сбой будет болезненным

На старте хорошо заходят такие.

  • есть удаление, перемещение, перезапись;
  • крутятся каждый день или каждый месяц;
  • описаны в инструкции, но понятны только одному человеку;
  • раньше уже ошибались в условиях;
  • выходной CSV уходит в другую работу.

Начинайте с того, что удобно, но больно терять при сбое.

2. Вынесите в функцию только читающую часть

Первым делом тестируйте не изменяющий шаг, а чтение.

Прочитать логи
Сузить выборку
Посчитать число
Привести к форме для CSV

Эту часть удобно гонять на $TestDrive, и инцидентов здесь почти нет.

3. Дату и путь передавайте снаружи

Жёстко зашитые дата и путь мешают тестам.

# Так лучше не делать
$root = 'C:\Logs'
$limit = (Get-Date).AddDays(-30)

Тестируемый вид выглядит так.

param(
    [string] $Path,
    [datetime] $Now = (Get-Date)
)

Уже сама возможность передать значения снаружи сильно поднимает стабильность тестов.

4. Опасные операции оставьте на конец

Удаление и перемещение собирайте в конце.

Собрать список объектов
  ↓
Записать объекты в журнал
  ↓
Проверить через -WhatIf
  ↓
Выполнить

В тестах проверяйте в том же порядке.

17. Типичные спотыкания

Симптом Причина Что делать
Локально проходит, в CI падает Другой текущий каталог Строить пути от $PSScriptRoot
Результат меняется день ото дня Прямой вызов Get-Date Завести параметр вроде -Now
Тест едва не сносит настоящие файлы Берут настоящие папки Использовать $TestDrive и Mock
Mock не срабатывает Другая граница модуля или область видимости Проверить -ModuleName и способ загрузки
Неясно, до какого предела тестировать Логика не разнесена по функциям Разделить выбор объектов, приведение формы и изменяющий шаг
Тесты медленные Трогают внешние сервисы или сеть В модульных тестах мокать внешние зависимости
По имени теста ничего не понятно Имена вроде It 'works' Класть в имя условие и ожидаемый результат

То, что выглядит как проблема Pester, часто на деле следствие структуры самого скрипта.

Участки, которые трудно тестировать, обычно и ломаются в эксплуатации.

Install-Module спотыкается (часто на корпоративных машинах)

Install-Module из главы 3 проходит как есть, если машина ходит в интернет напрямую. На корпоративных ПК под управлением ИТ на этом шаге часто останавливаются.

Симптом Причина Что делать
Нет связи с PowerShell Gallery Не идут через корпоративный прокси Задать -Proxy и -ProxyCredential
Падает с сообщением вроде «базовое соединение было закрыто» В протоколах по умолчанию Windows PowerShell 5.1 нет TLS 1.2 В сеансе включить TLS 1.2 и повторить
Останавливается на вопросе про NuGet-провайдер Остался PowerShellGet 1.0.0.1 из поставки Windows PowerShell Сначала поставить NuGet-провайдер
Вообще нет выхода наружу Изолированная сеть На другой машине сделать Save-Module и принести, либо зарегистрировать внутренний репозиторий через Register-PSRepository

Первые три пункта чаще всего проходят, если выполнить в таком порядке.

# 1. Включить TLS 1.2 (нужно в Windows PowerShell 5.1; в PowerShell 7 не требуется)
[Net.ServicePointManager]::SecurityProtocol =
    [Net.ServicePointManager]::SecurityProtocol -bor
    [Net.SecurityProtocolType]::Tls12

# 2. Поставить NuGet-провайдер заранее, чтобы не встать на интерактивном запросе
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force

# 3. Поставить Pester через прокси
$proxyUri = 'http://proxy.example.local:8080'
$proxyCredential = Get-Credential -Message 'Учётные данные прокси'

Install-Module -Name Pester -MinimumVersion 5.0.0 `
    -Scope CurrentUser -Force -SkipPublisherCheck `
    -Proxy $proxyUri -ProxyCredential $proxyCredential

Настройка TLS 1.2 действует только в этом сеансе. Если вводить каждый раз неудобно, положите её в профиль.

Если прокси без аутентификации, -ProxyCredential можно опустить. Если URL прокси неизвестен, спросите у ИТ «настройки прокси для выхода на PowerShell Gallery (www.powershellgallery.com)».

Если сеть изолирована и наружу не выйти, на машине с интернетом выполните следующее и принесите получившуюся папку как есть.

# Сторона, с которой выносите модуль
Save-Module -Name Pester -MinimumVersion 5.0.0 -Path 'D:\modules'

На принимающей стороне положите D:\modules\Pester в путь поиска модулей, например $env:USERPROFILE\Documents\WindowsPowerShell\Modules. Куда именно класть, смотрите в $env:PSModulePath.

Если этот порядок сразу задокументировать, новый человек поднимается быстрее. «Тесты не двигаются, потому что Pester не ставится» — на практике встречается часто.

18. Правила, которые стоит заранее зафиксировать

Если PowerShell-скрипты ведёт команда, правила стоит согласовать раньше, чем спорить о мелочах стиля.

Например такие.

  • файлы тестов называются *.Tests.ps1;
  • тестируемый код загружается от $PSScriptRoot;
  • тесты файловых операций идут через $TestDrive;
  • удаление, перемещение, уведомления, вызовы API по умолчанию мокаются;
  • дату можно зафиксировать через аргумент;
  • на Describe или It вешают теги вроде Unit, Smoke, RequiresAdmin;
  • в CI по умолчанию гоняют Unit;
  • изменяющие функции по возможности получают SupportsShouldProcess;
  • прошлые дефекты остаются регрессионными тестами.

Слишком много правил — и их перестают соблюдать. На старте хватает этих трёх.

Использовать TestDrive
Фиксировать дату
Мокать опасные операции

Если держать только эти три, тесты PowerShell становятся заметно стабильнее.

19. Решите также, чего не тестировать

При выстраивании тестов «чего не тестировать» так же важно, как «что тестировать».

Вот это в модульных тестах лучше не подтверждать через силу.

  • что собственный Get-ChildItem Windows работает правильно;
  • что Remove-Item действительно удаляет файлы;
  • внутреннее устройство стандартных команд PowerShell;
  • что внешний API всегда отвечает;
  • что сетевой ресурс всегда доступен.

Тестировать нужно наши собственные решения:

  • при каких условиях объект попадает в работу;
  • какой путь передаётся;
  • какие столбцы выводятся;
  • как обрабатывается сбой;
  • можно ли прогнать опасную операцию вхолостую.

Разделяйте места, где вы доверяете стандартным командам, и места, где защищаете свою логику.

20. Итог

PowerShell удобен, когда нужно быстро автоматизировать повседневную работу. Но скрипт, который живёт в эксплуатации долго, постепенно набирает ответственность. То, что начиналось как однострочник «только для себя», со временем становится ежедневным эксплуатационным процессом и уже затрагивает чужую работу и рабочие данные.

Выстроить тесты на Pester — это работа по защите скриптов по мере этого сдвига.

Коротко по пунктам.

  • Сначала тестируйте выбор объектов.
  • Дату и путь сделайте передаваемыми снаружи.
  • Файловые операции держите внутри $TestDrive.
  • Удаление, перемещение, уведомления, вызовы API мокайте.
  • Изменяющие функции оставляйте с пробным прогоном через -WhatIf.
  • Имена тестов пишите так, чтобы их можно было читать как спецификацию.
  • В CI начинайте с быстрых тестов без побочных эффектов.

Безопасная эксплуатация PowerShell не появляется от того, что сразу ставят большой механизм.

Дробите на маленькие функции. Пишите маленькие тесты. Сделайте так, чтобы перед опасным шагом можно было проверить.

Таким накоплением PowerShell сдвигается от «удобного, но слегка пугающего скрипта» к «рабочему инструменту, который можно проверить после каждой правки».

Справочные ссылки

Похожие статьи

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

С чего в Pester начинать тестирование?
Не с самого удаления, а с логики, которая выбирает, что удалять. В эксплуатационных скриптах быстрее всего окупаются четыре зоны: условия отбора, форма вывода, шаг перед опасной операцией и внешние зависимости. Существующие скрипты не нужно сразу переписывать целиком: вынесите в функцию суждение, которое стоит перед опасным шагом, и проверьте его возвращаемое значение через Pester.
Что такое TestDrive в Pester?
$TestDrive — временная область, которую Pester выделяет под тесты. Файловые операции можно проверять на файлах, созданных только внутри теста, не трогая настоящий C:\Logs или общий ресурс. В тестах PowerShell-скриптов с файловыми операциями привычка сначала брать $TestDrive как раз и не даёт случайно снести настоящие файлы.
До какого предела стоит использовать Mock в Pester?
Операции, которые нельзя выполнять по-настоящему — удаление и перемещение, вызовы веб-API, отправку почты и уведомлений — подменяйте через Mock. Дату фиксируйте аргументом. Для создания файлов и чтения/записи CSV лучше положить небольшие настоящие файлы в $TestDrive. Если замокать вообще всё, тесты слишком далеко уходят от реального поведения PowerShell, и можно пропустить проблемы с кодировкой, переносами строк и именами столбцов. Поэтому важно разделить, где работать с настоящим, а где мокать.
Почему тест Pester проходит локально, а в CI падает?
Частая причина — другой текущий каталог; загрузку тестируемого кода стройте от $PSScriptRoot. Если результат меняется день ото дня, виноват прямой Get-Date: дату нужно зафиксировать параметром вроде -Now. Если Mock не срабатывает, подозревайте границу модуля или область видимости и проверьте -ModuleName и способ загрузки. То, что выглядит как проблема Pester, часто на деле следствие структуры самого скрипта.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог