1. Home
  2. Blog
  3. Test Automation

Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Instead of writing a few example inputs, you describe a rule that should always hold, and a library generates hundreds of inputs trying to break it. We ran Hypothesis against small functions that passed their example tests, and it found three real bugs.

10 min read
Property-based testing in Python: Hypothesis finds the bugs your examples miss

Key takeaways

  • A property is a rule that must hold for every valid input, such as “splitting a bill must add up to the total”.
  • Hypothesis generates many inputs, including awkward edge cases, and reports a failure.
  • Shrinking reduces a failing input to the simplest one, such as total=1, people=2.
  • Our functions passed their example tests, yet Hypothesis found three bugs, including one involving a Unicode superscript digit.

You write a test with three carefully chosen examples. They pass, and the code ships. A week later, an input you never thought of breaks it. The problem is not laziness; nobody can imagine every edge case. Property-based testing hands that job to a computer.

We wrote three small functions, each with passing example tests, then added property tests using Hypothesis 6.170 with pytest 9.2 on Python 3.13. The failures shown are copied from that run.

What is property-based testing?

In an example-based test, you choose the input and the expected output: split_bill(1000, 4) == [250, 250, 250, 250]. In a property-based test, you describe what kind of inputs are valid and a property that must hold for all of them. The library then generates many inputs, by default 100 per test in Hypothesis, and checks the property each time.

The idea comes from QuickCheck, a Haskell library from 1999. Hypothesis brought it to Python, and similar tools exist for most languages, such as fast-check for JavaScript, jqwik for Java and proptest for Rust.

Our functions and the example tests that passed

def split_bill(total_paise, people):
    share = total_paise // people
    return [share] * people

def encode(s):   # run-length encoding: "aaab" -> "3a1b"
    ...

def decode(s):   # "3a1b" -> "aaab"
    ...

def test_example_based():
    assert split_bill(1000, 4) == [250, 250, 250, 250]
    assert encode("aaab") == "3a1b"

The example test passed. Both functions look correct for the obvious inputs, and that is exactly the trap.

A first property

What should always be true about splitting a bill? The shares must add up to the total, whatever the amount and number of people:

from hypothesis import given, strategies as st

@given(st.integers(min_value=0, max_value=10**9),
       st.integers(min_value=1, max_value=50))
def test_split_bill_adds_up(total, people):
    assert sum(split_bill(total, people)) == total

Hypothesis reported:

E       assert 0 == 1
E        +  where 0 = sum([0, 0])
E        +    where [0, 0] = split_bill(1, 2)
E       Failing test case: test_split_bill_adds_up(
E           total=1,
E           people=2,
E       )

Splitting 1 paisa between 2 people gave each person 0, and the paisa disappeared. In general, whenever the total does not divide evenly, the remainder is lost. The fix is to hand out the remainder, for example one extra paisa to the first few people. Money rounding is a classic source of real bugs, and our example of 1000 between 4 was simply too neat to reveal it.

Shrinking: the simplest failing case

While searching, Hypothesis probably first failed on something like total=839201, people=17. Rather than report that, it shrinks the input: it tries smaller and simpler values that still fail, until it cannot simplify further. total=1, people=2 is the smallest possible case of the bug, and it makes the cause obvious at a glance. Shrinking is what turns random testing from noisy to genuinely useful.

Hypothesis also saves failing examples in a local database and tries them first on the next run, so a bug, once found, is reproduced immediately until it is fixed.

Round-trip properties

For any pair of functions that convert data and convert it back, such as encode and decode, serialise and parse, or compress and decompress, there is a powerful property: the round trip should return the original.

@given(st.text())
def test_roundtrip(s):
    assert decode(encode(s)) == s

Hypothesis found two distinct failures in one run:

Failing test case: test_roundtrip(s='0')
AssertionError: assert '' == '0'

The input “0” encodes to “10”, meaning one zero, but the decoder sees two digits and reads “10” as a count with no character to repeat. Our format cannot tell digits in the data apart from digits in the counts. No example test we wrote included a digit, because run-length encoding examples are always letters like “aaab”.

The Unicode surprise

The second failure was stranger:

Failing test case: test_roundtrip(s='\xb9:')
ValueError: invalid literal for int() with base 10: '1\xb91'

The character \xb9 is “ยน”, a superscript one. Python’s str.isdigit() returns True for it, so our decoder treated it as part of a number, but int() cannot convert it. We would never have written this test case by hand. Hypothesis’s text strategy deliberately includes unusual Unicode, because that is where real software breaks. Using isdecimal(), or better, a format that cannot confuse data with counts, would fix both bugs.

Tip: When a property test fails, add the shrunk input as an ordinary example test too, with @example(...) or a plain test. It documents the bug and guarantees the case is always checked, even if Hypothesis’s search changes.

Strategies: describing valid inputs

A strategy tells Hypothesis how to generate data. Common ones:

Strategy Generates
st.integers(min_value=1) Integers in a range
st.floats(allow_nan=False) Floats, optionally without NaN or infinity
st.text() Unicode strings, including empty and unusual characters
st.lists(st.integers()) Lists of integers of varying length
st.dictionaries(st.text(), st.integers()) Dictionaries
st.sampled_from(["paid", "pending"]) One of a fixed set
st.builds(Order, ...) Objects built from other strategies
st.emails(), st.datetimes() Domain-specific values

Strategies can be combined and filtered, and st.from_type() can even build values from type hints or dataclasses. Prefer constraining a strategy, such as min_value=1, over filtering out invalid values afterwards, which wastes generated examples.

Property patterns that work

The hardest part is thinking of properties. These patterns cover most real cases:

  • Round trip: decode(encode(x)) == x. Ideal for parsers, serialisers and converters.
  • Invariants: something that never changes, such as the total in our bill split, or the length of a list after sorting.
  • Idempotence: doing it twice equals doing it once, as in normalise(normalise(x)) == normalise(x).
  • Oracle: compare against a simpler, slower implementation that is obviously correct.
  • Metamorphic: related inputs give related outputs, such as adding a filter never increasing the number of search results.
  • No crash: the function never raises an unexpected exception for valid input. Simple, and surprisingly effective.

Properties that passed

Not every property fails, and a passing property is still valuable evidence. These passed in our run:

@given(st.text())
def test_slug_has_no_spaces(t):
    assert " " not in slugify(t)

@given(st.text())
def test_slug_idempotent(t):
    assert slugify(slugify(t)) == slugify(t)

@given(st.lists(st.integers()))
def test_sorted_properties(xs):
    ys = sorted(xs)
    assert len(ys) == len(xs)
    assert all(a <= b for a, b in zip(ys, ys[1:]))
    assert sorted(ys) == ys

The final result was 2 failed, 4 passed in 5.27s. Notice the cost: property tests run many examples, so they take longer than ordinary unit tests. Five seconds for six tests is fine locally, but it shapes how you run them in CI.

Using it in practice

  • Start with one function that parses, converts or calculates, where edge cases matter most.
  • Use settings profiles: fewer examples on every commit, many more in a nightly job, using @settings(max_examples=...) or registered profiles.
  • Combine with fixtures: Hypothesis works inside pytest, although function-scoped fixtures are not reset between generated examples, so keep shared state out. See our pytest fixtures guide.
  • Keep the examples: property tests complement, rather than replace, readable example tests that document expected behaviour.
  • Try stateful testing for APIs and data structures: Hypothesis can generate whole sequences of operations and check the system stays consistent.

When to use it

Property-based testing shines for pure functions with clear rules: parsing, validation, maths, money, encoding, sorting and data transformations. It is less useful for UI flows or code whose correct output is hard to describe. It fits naturally into the base of the test pyramid, and it complements techniques like boundary value analysis by exploring boundaries you did not think to list.

Fixing the bill split, and strengthening the property

We fixed split_bill by handing the remainder out one paisa at a time, then made the property stricter at the same time:

def split_bill(total_paise, people):
    share, rem = divmod(total_paise, people)
    return [share + 1 if i < rem else share for i in range(people)]

@given(st.integers(min_value=0, max_value=10**9),
       st.integers(min_value=1, max_value=50))
def test_split_bill_adds_up(total, people):
    parts = split_bill(total, people)
    assert sum(parts) == total
    assert max(parts) - min(parts) <= 1     # nobody pays more than 1 paisa extra

def test_examples():
    assert split_bill(1, 2) == [1, 0]
    assert split_bill(1000, 3) == [334, 333, 333]

All tests passed. The second assertion matters: without it, a “fix” that gave the whole remainder to one person would also have passed. Good properties describe what “correct” means as completely as you can, and the shrunk failing case, split_bill(1, 2), now lives on as a plain example test.

Final thoughts

Example tests check the cases you imagined; property tests search for the ones you did not. In a few lines, Hypothesis found a lost-paisa rounding bug, a digit-handling bug and a Unicode bug in code that passed its examples. Pick one function with tricky inputs, write a round-trip or invariant property, and see what it finds.

Frequently asked questions

What is property-based testing?

It is a testing approach where you state a rule that should hold for all valid inputs, and a library generates many inputs to try to break it.

What is Hypothesis in Python?

Hypothesis is the main property-based testing library for Python. It generates test data from strategies, works with pytest and shrinks failures to minimal examples.

What is shrinking?

After finding a failing input, the library simplifies it step by step while it still fails, producing the smallest example, such as total=1 and people=2 in our test.

Does property-based testing replace unit tests?

No. It complements example tests. Keep readable examples that document behaviour, and add properties to explore edge cases automatically.

How many examples does Hypothesis run?

By default 100 per test. You can raise or lower this with @settings(max_examples=…) or settings profiles for different environments.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →