← Concepts & practices
Concept Testing, debugging, and measurement

Property-based testing

Generate the edge you did not name.

A project-title slugger passes two examples: Hello world becomes hello-world, and API v2 becomes api-v2. That is a fair start, but it says nothing about punctuation, repeated separators, empty input, or the next string a user pastes. A property-based test generates those inputs, reports a counterexample, then shrinks it until the broken rule is easy to see.

TypeScriptGo One slugger · three properties · one small failure.

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.

examples2 passHello world · API v2
→
generated!!! failsthe output alphabet property · shrinks to !
Generation finds the input a short example list never named.
Read the starting sluggerTypeScript · fair first answer
slugs.ts · direct slugger
// 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.

TypeScriptReading
slugs.ts
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';
}
GoAlongside
slugs.go
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
slugs.go · properties
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.

Property testing

A generated input becomes a useful clue.

named examples2 inputs
propertyoutput uses slug characters
counterexample…
→
result…

checking

named examples: passes.

01/ 04
Run two examples

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.

A generated run finds a 40-character failing title. What should happen next?
Which property fits a slugger?
When should a shrunk failure become a named example?
Feedback stays on this page; it is not saved.

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.

Slug module

Owns the properties

The alphabet, idempotence, and separator properties live beside slugify, so a change to the slugger meets them first.

Test run

Owns the search

A fixed seed, a bounded number of titles, and a report that prints the input before and after shrinking.

Example tests

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.

ReactAlready in your code
textbook.tsx · canonical projection
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.

Where generated inputs can help
OperationPropertyUseful generated edge
SortOutput is ordered and is a permutation of the input.Duplicates, empty arrays, already-sorted and reverse-sorted data.
Parser / formatterValid values round-trip, or invalid values are rejected consistently.Whitespace, delimiters, escaped characters, and truncation.
Encoder / decoderDecode(encode(value)) preserves the intended value.Empty payloads, boundary integers, Unicode, and unknown fields.
Collection commandAccepted 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.

Generator

Input strategy

Choose a domain-aware source rather than random bytes that never resemble real titles.

Property

Behavioral claim

Describe the relation that should hold for every generated input.

Shrinker

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.

Examples and properties work together
SituationExamples win when…Properties win when…
A public API response has one exact shapethe 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 inputsa few examples explain the user-facing vocabulary.generated inputs can explore combinations the examples cannot cover.
A bug has a known durable boundarythe minimized case belongs as a readable regression example.the property and generator should keep searching for related failures.
The generator is opaque or expensivesmall 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

Take the slugger into your editor. Add é to the generator’s alphabet, write a property that no letter disappears, and see which title breaks slugify first.

Back to Concepts & practices →