11 — Testing Bicep offline, and the bug it caught here¶
Assertions that run in under a second with no subscription attached, what they can and cannot cover, and a real defect in this repository that they found before Azure did.
Time: 25 minutes · Cost: none
Files: tests/main.tests.bicep ·
tests/naming-assertions.bicep
11.1 The four things that can go wrong, and what catches each¶
| Failure | Caught by | Needs Azure? | Speed |
|---|---|---|---|
| Syntax, types, missing parameters | bicep build |
no | instant |
| Style and safety rules | bicep lint |
no | instant |
| Wrong logic in a function or expression | bicep test |
no | ~1 second |
| Architecture and best practice | PSRule for Azure | no | seconds |
| Invalid values, quota, RBAC, policy | az deployment … what-if / validate |
yes | ~30 seconds |
| Anything about runtime behaviour | deploy and verify (ch. 10) | yes | minutes |
The third row is the one most IaC repositories leave empty, and it is the row that covers the logic people actually write themselves.
11.2 The test framework¶
Two files. Assertions live in the file whose expressions they test:
// infra/tests/naming-assertions.bicep
import { regionCode, resourceName, globalName, dnsLabel } from '../naming.bicep'
param location string
param workload string
param environment string
param seed string
assert regionIsMapped = regionCode(location) == 'weu'
assert unknownRegionDegrades = regionCode('antarcticacentral') == 'ant'
assert hyphenatedNameShape = resourceName('rg', workload, 'network', environment, location) == 'rg-ztwp-network-dev-weu'
assert emptyPurposeDropsSegment = resourceName('app', workload, '', environment, location) == 'app-ztwp-dev-weu'
assert globalNameFitsStorageLimit = length(globalName('st', workload, environment, seed)) <= 24
And a test file supplies the parameters:
// infra/tests/main.tests.bicep
test namingConventions 'naming-assertions.bicep' = {
params: {
location: 'westeurope'
workload: 'ztwp'
environment: 'dev'
seed: '/subscriptions/00000000-0000-0000-0000-000000000000'
}
}
Both features are experimental and enabled in bicepconfig.json:
Every compilation then prints an experimental-features warning. That is the trade for having any offline test at all, and it is worth it.
az bicephas notestsubcommand — only the standalone binary does.scripts/validate.ps1resolves the path for you.
11.3 The bug¶
The first version of globalName was:
func globalName(abbreviation string, workload string, environment string, seed string) string =>
toLower(substring('${abbreviation}${workload}${environment}${uniqueString(seed)}', 0, 23))
Storage account names are capped at 24 characters, so trimming looks sensible. The first test run said otherwise:
[-] Evaluation namingConventions Skipped!
Reason: Evaluating template failed: Unable to evaluate the template language function
'substring'. The index and length parameters must refer to a location within the string.
The index parameter: '0', the length parameter: '23', the length of the string parameter: '22'.
st + ztwp + dev + a 13-character uniqueString is 22 characters. substring
throws when asked for more than the string holds — it does not clamp.
The fix:
func globalName(abbreviation string, workload string, environment string, seed string) string =>
toLower(take('${abbreviation}${workload}${environment}${uniqueString(seed)}', 24))
take returns what exists and never throws.
What matters is where this would otherwise have surfaced. The function is only evaluated
when the template is deployed, so bicep build was perfectly happy. The failure would have
arrived partway through a real deployment, after the resource groups and the network were
already created, as a substring error with no resource name attached — and only for short
workload names, so workload = 'zerotrust' would have hidden it completely.
One assertion, one second, no subscription.
11.4 What assertions cannot do¶
Be clear about the boundary. bicep test evaluates the template locally, so it sees
parameters, variables, functions and expressions. It does not:
- know whether a SKU exists in your region
- know whether you have quota
- evaluate policy
- resolve
reference()or anything about a deployed resource - know whether a private endpoint group ID is valid
All of those need what-if or validate, which need a subscription. The offline tests are
the fast inner loop, not a replacement for the outer one.
11.5 The full local loop¶
- compiles every template
- compiles both parameter files
- lints against the elevated rules
- runs the assertions
Then, with a subscription:
az deployment sub validate --location westeurope --template-file infra/main.bicep --parameters infra/main.dev.bicepparam
validate submits the template to ARM, which checks values, quota, RBAC and policy without
creating anything. what-if goes one step further and diffs against what exists.
Exercises¶
-
Write an assertion for the address plan — that the Bastion subnet is inside the hub VNet's range, for instance. You will discover Bicep has no CIDR-containment function, which is itself the lesson: assertions test your logic, not Azure's.
-
Make a test fail on purpose. Change
regionIsMappedto expect'weu2', run, and read the failure output. Knowing what a failure looks like is half of trusting a green run. -
Add the region table assertion. Every code in
regionCodeshould be three or four characters; write one assertion that checks the whole map at once with a lambda overitems(). -
Wire it into a pre-commit hook so
bicep buildandbicep testrun before every commit. It costs a second and removes a class of pull request entirely.
Checkpoint¶
-
bicep test infra/tests/main.tests.biceppasses - You have made it fail and read the output
- You can name three failure classes assertions cannot catch
-
./scripts/validate.ps1is part of your routine before committing