01 / The idea
Two examples can pass while the rule is still broken.
A title normalizer is a good small boundary. Callers want a URL-safe slug, not a title with punctuation and whitespace decisions scattered through links, database keys, and pages. The starting implementation trims, lowercases, and replaces whitespace. It works for the examples the first caller wrote.
Then a generated title arrives as !!!. The function returns !!!.
Nothing in the two examples asked whether the output uses an allowed alphabet, whether the
operation is idempotent, or whether separators repeat.
A property-based test checks a rule against many generated inputs, then shrinks the first failure into an explanation.
Read the starting sluggerTypeScript · fair first answer
// The first version handles the two examples everyone started with.
export function directSlug(title: string): string {
return title.trim().toLowerCase().replace(/\s+/g, '-');
} The direct function is not a straw man. Its two examples are useful evidence. The missing claim is what the output is allowed to contain for every non-empty title.
02 / See the shape
Separate the function, the property, and the search.
The direct function is the system under test. A property is a predicate over its input and output. A generator supplies inputs, a runner stops at the first failure, and a shrinker preserves that failure while removing irrelevant detail.
Switch through the forms to see the mechanism grow: the properties and fixed implementation, the generator, shrinker, and runner, then the generated call site. TypeScript and Go use the same title cases and the same failure.
Three properties state what every slug must satisfy, beside the fixed slugger that meets them.
export const properties = [
{
name: 'output uses slug characters',
check: (slugger: Slugger, input: string) => {
const output = slugger(input);
if (input.length === 0) return output === '' || output === 'untitled';
return output === 'untitled' || /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(output);
}
},
{
name: 'slugging is idempotent',
check: (slugger: Slugger, input: string) => slugger(slugger(input)) === slugger(input)
},
{
name: 'output has no repeated separators',
check: (slugger: Slugger, input: string) => !slugger(input).includes('--')
}
] as const;
export function slugify(title: string): string {
let output = '';
let needsSeparator = false;
for (const character of title.toLowerCase()) {
if (/^[a-z0-9]$/.test(character)) {
if (needsSeparator && output) output += '-';
output += character;
needsSeparator = false;
} else if (output) {
needsSeparator = true;
}
}
return output || 'untitled';
} type Property struct {
Name string
Check func(Slugger, string) bool
}
var properties = []Property{
{
Name: "output uses slug characters",
Check: func(slugger Slugger, input string) bool {
output := slugger(input)
if input == "" {
return output == "" || output == "untitled"
}
if output == "untitled" {
return true
}
if output == "" {
return false
}
for index, character := range output {
if character == '-' {
if index == 0 || index == len(output)-1 || output[index-1] == '-' {
return false
}
continue
}
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') {
return false
}
}
return true
},
},
{
Name: "slugging is idempotent",
Check: func(slugger Slugger, input string) bool { return slugger(slugger(input)) == slugger(input) },
},
{
Name: "output has no repeated separators",
Check: func(slugger Slugger, input string) bool { return !strings.Contains(slugger(input), "--") },
},
}
func Slugify(title string) string {
var builder strings.Builder
pendingSeparator := false
for _, character := range strings.ToLower(title) {
ascii := (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
if ascii {
if pendingSeparator && builder.Len() > 0 {
builder.WriteByte('-')
}
builder.WriteRune(character)
pendingSeparator = false
} else if builder.Len() > 0 {
pendingSeparator = true
}
}
if builder.Len() == 0 {
return "untitled"
}
return builder.String()
} Reading the TypeScriptFunctions as properties and generators
Each property receives a Slugger and an input. runPropertySuite keeps the first failing property and input, then calls shrinkString with the
same predicate. The runner is deterministic so a reader can reproduce the failure.
Reading the GoFunction values and explicit failure
type Property struct {
Name string
Check func(Slugger, string) bool
}
var properties = []Property{
{
Name: "output uses slug characters",
Check: func(slugger Slugger, input string) bool {
output := slugger(input)
if input == "" {
return output == "" || output == "untitled"
}
if output == "untitled" {
return true
}
if output == "" {
return false
}
for index, character := range output {
if character == '-' {
if index == 0 || index == len(output)-1 || output[index-1] == '-' {
return false
}
continue
}
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') {
return false
}
}
return true
},
},
{
Name: "slugging is idempotent",
Check: func(slugger Slugger, input string) bool { return slugger(slugger(input)) == slugger(input) },
},
{
Name: "output has no repeated separators",
Check: func(slugger Slugger, input string) bool { return !strings.Contains(slugger(input), "--") },
},
}
func Slugify(title string) string {
var builder strings.Builder
pendingSeparator := false
for _, character := range strings.ToLower(title) {
ascii := (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
if ascii {
if pendingSeparator && builder.Len() > 0 {
builder.WriteByte('-')
}
builder.WriteRune(character)
pendingSeparator = false
} else if builder.Len() > 0 {
pendingSeparator = true
}
}
if builder.Len() == 0 {
return "untitled"
}
return builder.String()
} Go represents the same idea with a Slugger function and a slice of Property values. The implementation is native to Go, but the property names and minimized ! case stay the same.
03 / Follow the failure
Watch the test earn a smaller explanation.
The frames come from the slugger and property runner above. First the named examples pass.
Then a deterministic generator finds !!!. The shrinker tries smaller strings
while the property remains false and stops at !. Finally the corrected slugger
passes the same generated inputs.
A generated input becomes a useful clue.
output uses slug characterschecking
named examples: passes.
Examples pass.
Hello world and API v2 produce plausible slugs. The direct function has earned a first example suite.
Reduced motion: choose a scene to see its completed state.
Read this scene
Hello world and API v2 produce plausible slugs. The direct function has earned a first example suite.
named examples. Checked 2 inputs against output uses slug characters. Every input passed.
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh property run.
What generation buys you
Property-based testing adds a search strategy to the claim you already named.
- Input breadth
- The generator explores punctuation, spacing, casing, and combinations a short list would miss.
- Counterexamples
- The first failing input gives the test suite an observable witness instead of a vague “some edge case.”
- Smaller clues
- Shrinking turns
!!!or a long generated title into!, which points directly at the invalid-character rule. - Regression examples
- Once the boundary is understood, keep the minimized input as a named example and improve the property or generator too.
The search is not free. Section 08 names the cost of generators that are random, weak, slow, or difficult to diagnose.
04 / Try a decision
Choose what to do with the surprising input.
A generated failure is evidence, not an automatic verdict. Decide whether it exposes the rule, the generator, or a property that was too strong.
05 / Give it a real job
Keep the claim independent from the test data.
The slugger can be pure and cheap to run, so its properties belong close to the transformation. The application still owns the URL policy and the UI receives a canonical projection. A generated suite should have a reproducible seed, a bounded run, useful failure output, and a path for promoting durable failures into examples.
Do not confuse many generated inputs with good coverage. Review what the generator can produce, what it cannot produce, and whether shrinking preserves the meaning of the failure. If the property says “safe URL slug,” the test should make that rule visible in both the assertion and the failure report.
Owns the properties
The alphabet, idempotence, and separator properties live beside slugify, so a
change to the slugger meets them first.
Owns the search
A fixed seed, a bounded number of titles, and a report that prints the input before and after shrinking.
Own the lessons learned
! becomes a named regression example once everyone agrees what it means.
Build UIs?Every link built from a slug trusts a property, and a loose API response makes you own it.
Where it already is in your components
A link to `/projects/${project.slug}` assumes the slug is already URL-safe.
In the textbook version the component gets the slug from the application, where the property
suite runs. The component doesn’t normalize anything, so there is nothing generated to test
in it.
When you have to own it
Now the API sometimes leaves slug out, and the wild version rebuilds it from
the title: lowercase, then whitespace to hyphens. That is the starting slugger again, one
copy away from its tests. A project called !!! links to /projects/!!!, and no example in the component’s tests will ever ask.
Owning it means one slugger. Import the tested slugify instead of writing a second
one in the link. If the UI truly needs its own normalization, say a URL preview in a new-project
form, point the same three properties at it.
The UI receives a canonical project projection from the application boundary.
type Project = { title: string; slug: string };
type ProjectApp = { getProject(projectId: string): Project | null };
export function ProjectLink({ app, projectId }: { app: ProjectApp; projectId: string }) {
const project = app.getProject(projectId);
if (!project) return <span>Project not found</span>;
return <a href={`/projects/${project.slug}`}>{project.title}</a>;
}
06 / Recognize it elsewhere
Many everyday operations have properties worth exploring.
Look for a relation that should hold across inputs, not just one expected output.
| Operation | Property | Useful generated edge |
|---|---|---|
| Sort | Output is ordered and is a permutation of the input. | Duplicates, empty arrays, already-sorted and reverse-sorted data. |
| Parser / formatter | Valid values round-trip, or invalid values are rejected consistently. | Whitespace, delimiters, escaped characters, and truncation. |
| Encoder / decoder | Decode(encode(value)) preserves the intended value. | Empty payloads, boundary integers, Unicode, and unknown fields. |
| Collection command | Accepted state remains valid; rejected state is unchanged. | Command sequences, repeated operations, and the first over-capacity input. |
07 / Already in your toolbox
Test libraries provide the loop; you provide the model.
Vitest’s assertions can check each generated result, Go’s testing package can
report the seed and input, and a small generator can be an ordinary function. Go also ships
two property tools: testing/quick’s quick.Check calls a property
with random arguments, and native fuzzing (testing.F with f.Fuzz,
run by go test -fuzz) mutates inputs and minimizes a failing one. In TypeScript, a
property-testing library supplies the same generator and shrinker roles. None of them knows
whether your property is meaningful.
Input strategy
Choose a domain-aware source rather than random bytes that never resemble real titles.
Behavioral claim
Describe the relation that should hold for every generated input.
Failure diagnosis
Preserve the failure while removing detail until the remaining input explains the issue.
08 / The parts to watch
A generator can create noise as easily as insight.
Unreproducible randomnessA failure that disappears is not evidence you can use
Record the seed, generator version, and minimized input. A deterministic rerun should reach the same property failure.
Weak propertiesMany green checks can still say little
“The function returns a string” is too weak for a URL slugger. Name allowed characters, idempotence, and separator rules.
Shrinking away the meaningSmall is useful only when it still explains
Do not shrink structured input into a value outside the domain just because it is shorter. A minimal counterexample should remain interpretable.
Slow or oversized runsSearch has a budget
Bound the number and size of generated inputs, then use a focused seed or regression example for a fixed bug. More cases are not automatically more confidence.
09 / Make the call
Use generation when the space is broad and the rule is stable.
| Situation | Examples win when… | Properties win when… |
|---|---|---|
| A public API response has one exact shape | the named fields and status meanings are the contract readers need. | the response also has a relation across many values or pagination cases. |
| A pure transformation has many possible inputs | a few examples explain the user-facing vocabulary. | generated inputs can explore combinations the examples cannot cover. |
| A bug has a known durable boundary | the minimized case belongs as a readable regression example. | the property and generator should keep searching for related failures. |
| The generator is opaque or expensive | small examples and a focused integration test may be clearer and cheaper. | use it only after its seed, budget, and failure report are trustworthy. |
Reach for examples when the named scenario is the clearest specification.
Reach for properties when a stable rule should hold across a wide, deliberate input space.
Keep this questionAsk it before adding a generator.
What rule am I checking, what can the generator reach, and will the smallest failure tell the next person what to fix?
10 / Take the idea with you
Explain the slug bug without saying “property-based.”
“Across a dozen titles, most of them generated, we checked that every slug uses only
letters, digits, and single hyphens, and that slugging a slug changes nothing. One title
came back as !!!, and the smallest title that still failed was !.” That tells a reviewer what was checked and what broke. When they want the
words, they are property, generator, and shrinking.
Before moving on, jot down the three properties the slugger has to keep, why ! is a better clue than !!!, and one function in your own code with a rule you
could state for every input. A date formatter or a price parser counts.
Connections to follow nextRelated lessons
- Invariants and example tests supplies the rule a property runner explores.
- Contract and integration testing carries a claim across a boundary.
- Fakes, stubs, and mocks asks whether replacing a collaborator preserves the behavior you mean to test.
- Pure functions & side effects explains why a deterministic transformation is an especially good property target.