Testing, Linting and Style
Checking scripts before they run in production: ShellCheck and bats for Bash, PSScriptAnalyzer and Pester for PowerShell, and a consistent style.
Why check scripts at all
Scripts are code, and they fail like code: a missing quote that only
matters when a file name has a space, a cd that fails
on one server, a function that returns "OK" for 100% disk usage. The
difference is that scripts often run with admin rights on production
machines, so the bug is found by the system rather than by a user.
Two kinds of check catch most of these problems early:
- A linter does static analysis: it reads the script without running it and recognizes patterns that are usually mistakes. It costs nothing to run and needs no test data.
- A unit test runs one function with known input and checks the result. It catches wrong logic, which no linter can see.
| Job | Bash | PowerShell |
|---|---|---|
| Lint | ShellCheck (0.11.0) | PSScriptAnalyzer (1.25.0) |
| Test | bats-core (1.14.0) | Pester (6.2.0) |
| Format | shfmt |
Invoke-Formatter (in PSScriptAnalyzer) |
| Style guide | Google Shell Style Guide | PowerShell Practice and Style (PoshCode) |
Latest versions as of October 2026.
ShellCheck
ShellCheck is a free, open-source
linter for shell scripts (Bash, sh, dash and ksh). It knows hundreds
of common mistakes, each with a code such as SC2086, a
severity (error, warning, info, style) and often a suggested fix. It
is in the package repositories of all major Linux distributions and
Homebrew, runs in editors such as VS Code through an extension, and
has a web version at shellcheck.net. Here is a short rotation script
with typical problems:
cat > rotate-exports.sh <<'END'
#!/bin/bash
dir=$1
cd $dir
export STAMP=$(date +%F)
for f in $(ls *.csv); do
mv $f archive/$STAMP-$f
done
END
shellcheck rotate-exports.sh
In rotate-exports.sh line 3:
cd $dir
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cd "$dir" || exit
In rotate-exports.sh line 4:
export STAMP=$(date +%F)
^---^ SC2155 (warning): Declare and assign separately to avoid masking return values.
In rotate-exports.sh line 5:
for f in $(ls *.csv); do
^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
In rotate-exports.sh line 6:
mv $f archive/$STAMP-$f
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^----^ SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
mv "$f" archive/"$STAMP"-"$f"
For more information:
https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ...
https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
Every finding is a real bug waiting for the right input:
-
SC2164: if
cdfails, the script carries on in the wrong folder and moves the wrong files. -
SC2086: an unquoted variable is split into words
and glob-expanded; a file called
meter readings.csvbecomes two arguments (see Variables, Quoting and Expansion). -
SC2155: in
export STAMP=$(…)the exit status is that ofexport, which always succeeds, so a failingdategoes unnoticed. -
SC2045 and SC2035: looping over
lsoutput breaks on spaces, and a file named-rf.csvwould be read as options. Loop over a glob instead.
ShellCheck exits with status 1 when it finds something and 0 when the script is clean, which makes it easy to use as a gate. The corrected script:
cat > rotate-exports.sh <<'END'
#!/bin/bash
set -euo pipefail
dir=${1:?usage: rotate-exports.sh DIR}
cd "$dir" || exit 1
stamp=$(date +%F)
for f in ./*.csv; do
[[ -e $f ]] || continue
mv -- "$f" "archive/$stamp-${f#./}"
done
END
shellcheck rotate-exports.sh && echo "shellcheck: no findings"
shellcheck: no findings
Useful options and directives
-f gcc prints one line per finding (file:line:column: severity: message [code]), the format editors and CI systems understand. In that format the
info level is called note:
printf '#!/bin/bash\ncd $1\nfor f in $(ls); do echo $f; done\n' > tidy.sh
shellcheck -f gcc tidy.sh
echo "exit status: $?"
tidy.sh:2:1: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164] tidy.sh:2:4: note: Double quote to prevent globbing and word splitting. [SC2086] tidy.sh:3:10: error: Iterating over ls output is fragile. Use globs. [SC2045] tidy.sh:3:25: note: Double quote to prevent globbing and word splitting. [SC2086] exit status: 1
-
-S warningreports only warnings and errors (leaving out info and style); useful when you introduce ShellCheck on a large existing collection of scripts. -
-s sh, or a#!/bin/shshebang, checks for POSIX sh: then[[ ]], arrays and other Bash features are reported as SC3xxx warnings (see Cross-Platform Scripting). Without any shebang ShellCheck reports SC2148, because it cannot tell which shell you mean. -
-xfollows files loaded withsource. -
A
.shellcheckrcfile in the script's folder (or a parent folder, or your home folder) sets options for a whole project, for exampledisable=SC2034.
When you really mean what ShellCheck warns about, silence that one finding with a directive comment on the line before, and say why:
cat > report.sh <<'END'
#!/bin/bash
# The options are deliberately split into separate words:
opts="-n -t"
# shellcheck disable=SC2086
sort $opts readings.txt
END
shellcheck report.sh && echo "shellcheck: no findings"
shellcheck: no findings
(An array, opts=(-n -t) with "${opts[@]}",
would avoid the warning altogether; see
Arrays, Hash Tables and Objects.)
PSScriptAnalyzer
PSScriptAnalyzer is
Microsoft's linter for PowerShell scripts and modules. It checks a
set of rules for errors, warnings and information findings: aliases
in scripts, unapproved verbs, plain-text password parameters,
state-changing functions without ShouldProcess, unused
variables and more. The VS Code PowerShell extension runs it as you
type. Unlike ShellCheck it is a PowerShell module, installed from
the PowerShell Gallery (version 1.25.0 as of October 2026):
Install-PSResource -Name PSScriptAnalyzer # or: Install-Module -Name PSScriptAnalyzer
Invoke-ScriptAnalyzer -Path ./Export-Readings.ps1
RuleName Severity ScriptName Line Message
-------- -------- ---------- ---- -------
PSUseApprovedVerbs Warning Export-Rea 3 The cmdlet 'Delete-OldExport' uses an
dings.ps1 unapproved verb.
PSAvoidUsingPlainTextForPassword Warning Export-Rea 5 Parameter '$Password' should not use
dings.ps1 String type but either SecureString or
PSCredential, otherwise it increases
the chance to expose this sensitive
information.
PSAvoidUsingCmdletAliases Warning Export-Rea 12 'gci' is an alias of 'Get-ChildItem'.
dings.ps1 Alias can introduce possible problems
and make code less readable.
Useful parameters: -Recurse to check a folder,
-Severity Error, Warning to filter, and
-Settings with a settings file to choose rules for a
project. Invoke-Formatter from the same module
reformats code to a consistent layout. To silence one rule for one
function, put a
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('RuleName',
'')]
attribute on it, again with a comment explaining why.
Writing testable scripts
A script that is one long list of commands can only be tested by running all of it, against real servers. Split it instead: put the logic in functions that take input as parameters and return a result, and keep the part that talks to the outside world (files, network, the clock) thin. A test can then load the functions and call them directly.
In Bash, the last lines of the script decide whether to run
main: ${BASH_SOURCE[0]} equals
$0 only when the file is executed, not when another
script loads it with source. Even without a test
framework, a few lines of comparison make a usable test:
cat > lib.sh <<'END'
#!/bin/bash
# Usage percentage → status word. Pure function: easy to test.
disk_status() {
local pct=$1 limit=${2:-90}
if (( pct >= limit )); then echo "WARNING"; else echo "OK"; fi
}
main() {
echo "/var: $(disk_status "$1")"
}
# Run main only when executed, not when sourced by a test
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi
END
cat > test-lib.sh <<'END'
#!/bin/bash
source ./lib.sh
fails=0
check() { # check DESCRIPTION EXPECTED ACTUAL
if [[ $2 == "$3" ]]; then echo "ok - $1"; else echo "FAIL - $1: expected '$2', got '$3'"; fails=$((fails + 1)); fi
}
check "below the limit" OK "$(disk_status 89)"
check "at the limit" WARNING "$(disk_status 90)"
check "custom limit" OK "$(disk_status 91 95)"
exit $(( fails > 0 ))
END
bash lib.sh 91
bash test-lib.sh
echo "tests exit status: $?"
/var: WARNING ok - below the limit ok - at the limit ok - custom limit tests exit status: 0
PowerShell does the same with dot sourcing:
. ./DiskTools.ps1
runs the file in the current scope, so its functions become
available to the test (see
Running Scripts). The test cases are a list of hash tables, the same table-driven
style that Pester uses:
Set-Content -Path ./DiskTools.ps1 -Value @'
function Get-DiskStatus {
param([int] $Percent, [int] $Limit = 90)
if ($Percent -ge $Limit) { 'WARNING' } else { 'OK' }
}
'@
. ./DiskTools.ps1 # dot-source: load the functions, run nothing else
$cases = @(
@{ Name = 'below the limit'; Percent = 89; Limit = 90; Expected = 'OK' }
@{ Name = 'at the limit'; Percent = 90; Limit = 90; Expected = 'WARNING' }
@{ Name = 'custom limit'; Percent = 91; Limit = 95; Expected = 'OK' }
)
foreach ($c in $cases) {
$actual = Get-DiskStatus -Percent $c.Percent -Limit $c.Limit
$result = if ($actual -eq $c.Expected) { 'ok ' } else { 'FAIL' }
"$result - $($c.Name)"
}
ok - below the limit ok - at the limit ok - custom limit
Home-made checks like these are a fine start. Test frameworks add what they lack: clear reports, isolation between tests, setup and teardown, and test doubles that replace real commands.
bats for Bash
bats (Bash Automated Testing System; the
maintained version is bats-core, 1.14.0 as of October 2026) is a
test framework for Bash and other command-line programs. A test file
contains @test blocks; inside, ordinary commands and
[ … ] tests either succeed or fail the test. The
run helper runs a command and stores its exit status in
$status and its combined output in
$output, so you can check both without the test itself
failing on a non-zero exit. bats is installed from the
distribution's packages, npm, or its Git repository.
# test/check-disk.bats
setup() {
source ./lib.sh
}
@test "warns at the limit" {
run disk_status 90
[ "$status" -eq 0 ]
[ "$output" = "WARNING" ]
}
@test "script prints the status for /var" {
run bash ./lib.sh 91
[ "$output" = "/var: WARNING" ]
}
@test "custom limit" {
[ "$(disk_status 91 95)" = "OK" ]
}
$ bats test/
check-disk.bats ✓ warns at the limit ✓ script prints the status for /var ✓ custom limit 3 tests, 0 failures
The optional helper libraries bats-support and bats-assert add
readable assertions such as assert_success and
assert_output "WARNING".
Pester for PowerShell
Pester is the standard test framework
for PowerShell, used by Microsoft and most module authors. Tests are
PowerShell scripts named *.Tests.ps1, with
Describe and Context blocks to group them,
It blocks for single tests, BeforeAll for
setup, and Mock to replace a command with a
test double. As of October 2026 the current version is Pester 6.2.0 (Pester 6
appeared in July 2026; the 5.x line still gets updates). Pester 6
recommends new, type-aware assertions such as
Should-Be, while the classic
Should -Be syntax still works. It needs Windows
PowerShell 5.1 or PowerShell 7.4 or later.
# DiskTools.Tests.ps1
BeforeAll {
. $PSScriptRoot/DiskTools.ps1
}
Describe 'Get-DiskStatus' {
It 'returns OK below the limit' {
Get-DiskStatus -Percent 89 | Should -Be 'OK'
}
It 'returns WARNING at <Percent>% with limit <Limit>' -ForEach @(
@{ Percent = 90; Limit = 90 }
@{ Percent = 99; Limit = 95 }
) {
Get-DiskStatus -Percent $Percent -Limit $Limit | Should -Be 'WARNING'
}
}
# DiskTools.ps1 here also contains Send-DiskReport, which calls Send-NocAlert.
Describe 'Send-DiskReport' {
It 'alerts the NOC only when a disk is above the limit' {
Mock Send-NocAlert { }
Send-DiskReport -Percent 91
Should -Invoke Send-NocAlert -Times 1 -Exactly
}
}
PS> Invoke-Pester -Path ./DiskTools.Tests.ps1
Starting discovery in 1 files. Discovery found 4 tests in 41ms. Running tests. [+] /home/amara/scripts/DiskTools.Tests.ps1 287ms (98ms|151ms) Tests completed in 291ms Tests Passed: 4, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
Style
A style guide matters more for scripts than it seems: the next person to read your script is often reading it during an incident. Pick one guide per language, write it down, and let tools enforce what they can.
Bash: the Google Shell Style Guide
The most cited guide for Bash is Google's. Its main points: indent
with 2 spaces and keep lines under 80 characters; use
$(…), never backticks; prefer [[ … ]] over
[ … ]; quote variables; use lowercase names for local
variables and UPPERCASE for constants and exported variables;
declare function variables with local, separately from
an assignment that runs a command (SC2155); and put the main code in
a main function called at the end with
main "$@". It also draws a line: a script of more than
about 100 lines, or with complicated control flow, should be
rewritten in a more structured language (see
Cross-Platform Scripting
for choosing one).
PowerShell: verbs, full names, help
PowerShell commands are named Verb-Noun, and the verb
should be an approved verb: one
from the fixed list Microsoft publishes, grouped as Common, Data,
Lifecycle, Diagnostic, Communications, Security and Other. Approved
verbs make commands predictable: whoever knows
Remove-Item guesses Remove-OldExport,
never Delete-OldExport. Get-Verb shows the
list, and importing a module with unapproved verbs prints a warning:
'Approved: ' + ((Get-Verb -Verb Get, Remove, Delete, Fetch).Verb -join ', ')
(Get-Verb -Verb Remove).Description
Set-Content -Path ./KestrelTools.psm1 -Value @'
function Delete-OldExport { 'deleted' }
function Remove-OldExport { 'removed' }
'@
Import-Module ./KestrelTools.psm1
Approved: Get, Remove Deletes a resource from a container WARNING: The names of some imported commands from the module 'KestrelTools' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.
The other common rules, from the community PowerShell Practice and
Style guide: PascalCase for function, parameter and public variable
names; full command and parameter names in scripts, no
aliases and no
positional parameters (Get-ChildItem -Path C:\Logs, not
gci C:\Logs), because aliases differ between platforms
and are unknown to readers; 4-space indentation with the opening
brace on the same line (One True Brace Style); and comment-based
help at the top of every script and function, so
Get-Help works on it:
function Remove-OldExport {
<#
.SYNOPSIS
Deletes billing exports older than a number of days.
.EXAMPLE
Remove-OldExport -Path D:\Exports -Days 30 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $Path,
[ValidateRange(1, 3650)] [int] $Days = 90
)
# ...
}
| Topic | Bash (Google) | PowerShell (PoshCode) |
|---|---|---|
| Indentation | 2 spaces | 4 spaces |
| Names |
snake_case, UPPER for constants
|
Verb-Noun, PascalCase |
| Short forms | No backticks; $(…) |
No aliases, no positional parameters |
| Structure | Functions, main "$@" at the end |
Advanced functions, param() block |
| Documentation | Header comment, comment per function |
Comment-based help (.SYNOPSIS,
.EXAMPLE)
|
Running the checks automatically
Checks only help when they run every time. Store scripts in Git and let a CI pipeline run the linters and tests on every push and pull request, so a change that fails cannot be merged. A minimal GitHub Actions workflow (see your first workflow) for a repository with both kinds of scripts:
name: script-checks
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: ShellCheck
run: shellcheck --severity=warning scripts/*.sh
- name: PSScriptAnalyzer and Pester
shell: pwsh
run: |
Install-PSResource -Name PSScriptAnalyzer, Pester -TrustRepository
$findings = Invoke-ScriptAnalyzer -Path ./scripts -Recurse -Severity Error, Warning
$findings | Format-Table -AutoSize
if ($findings) { exit 1 }
Invoke-Pester -Path ./tests -CI
Invoke-Pester -CI sets a non-zero exit code when a test
fails and writes a test result file the CI system can display.
Running the same commands locally before you push (or in a Git
pre-commit hook) gives you the answer in seconds instead of minutes.