{"course":{"slug":"how-software-changed","title":"How Software Development Changed","url":"https://addaly.com/learn/how-software-changed","level":"intermediate","track":"work"},"lesson":{"slug":"properties-not-examples","title":"Properties, not examples","url":"https://addaly.com/learn/how-software-changed/properties-not-examples","position":21,"of":87,"minutes":10,"answer":"A property states what must be true of every input and lets the machine hunt for a counterexample, which is cheap for you to write and expensive for pattern-matched code to satisfy by accident.","keyIdea":"A property states what must be true of every input and lets the machine hunt for a counterexample, which is cheap for you to write and expensive for pattern-matched code to satisfy by accident."},"body":{"format":"markdown","text":"## Why examples stopped being enough\n\nAn example test pins one input to one output. `total([100, 250]) == 350`. It is easy to write, easy to read, and easy to satisfy, including by code that is wrong everywhere except that point.\n\nThat last property used to be a minor theoretical objection. It is now the main event, because code that satisfies the visible examples and fails elsewhere is exactly what cheap generation produces. An implementation pattern-matched from a thousand similar functions will handle the shapes those functions handled and diverge at the edges nobody wrote down.\n\nA property test says something that must be true of **every** input, and then the machine searches for a counterexample. That reverses the economics: cheap for you to state, expensive for wrong code to satisfy by accident.\n\n> Figure: One point, or every input. An example test: Pins one input to one output; Satisfied by code that is wrong everywhere else; Easy to write, easy to read, easy to pass by accident; Silent about the input nobody thought to imagine. A property: States what must be true of every input, then the machine hunts; Round trip: decoding what you encoded returns the original; Idempotence: applying it twice equals applying it once; Invariants: money is conserved, the queue never loses an item; Shrinks a failure to the smallest input that still breaks. Cheap for you to state and expensive for pattern-matched code to satisfy by accident. That reversal is what makes the technique worth its setup cost now.\n\n## The four families, with examples\n\n**Round-trip.** Encoding and decoding must return you to where you started.\n\n```python\nfrom hypothesis import given, strategies as st\n\n@given(st.text())\ndef test_slug_roundtrip(s):\n    assert unslugify(slugify(s)) == s.strip()\n```\n\nThis one finds Unicode problems within about four seconds, every time. Serialisation, compression, parsing and formatting, URL encoding, currency formatting: all round-trips.\n\n**Idempotence.** Applying twice equals applying once.\n\n```python\n@given(st.text())\ndef test_normalise_is_idempotent(s):\n    assert normalise(normalise(s)) == normalise(s)\n```\n\nThis is the property behind every retry-safe operation, every migration you might have to re-run, and every reconciliation job.\n\n**Invariants.** Something true before and after, regardless of the operation.\n\n```python\n@given(st.lists(st.integers(min_value=0, max_value=10**9)), st.integers())\ndef test_transfer_conserves_total(balances, amount):\n    before = sum(balances)\n    after = sum(apply_transfer(balances, amount))\n    assert before == after\n```\n\nMoney is conserved. A sorted list stays the same length. A queue never loses an item. Invariants are where the domain knowledge goes and they are the most valuable family.\n\n**Comparison against a reference.** A slow, obviously-correct implementation versus the fast one.\n\n```python\n@given(st.lists(st.integers()))\ndef test_fast_median_matches_naive(xs):\n    assume(xs)\n    assert fast_median(xs) == sorted(xs)[len(xs)//2] if len(xs) % 2 else True\n```\n\nThis is the strongest position available for optimisation work, and it is the same idea as differential testing in the next lesson.\n\n## Shrinking is the part that makes it usable\n\nWhen a property fails, the framework does not hand you the 400-element list that broke it. It repeatedly simplifies the failing input until it cannot be simplified further, and gives you the minimal case.\n\nSo instead of debugging `[8123, -4, 0, 991, ...]` you get `[0, 0]`, and the bug is obvious. Shrinking is what turns a random-input generator into a debugging tool, and it is the reason property testing is worth the setup cost.\n\n## The free tooling, all of it\n\nHypothesis (Python), fast-check (JavaScript and TypeScript), jqwik (Java), PropCheck and StreamData (Elixir), QuickCheck (Haskell, the original), proptest and quickcheck (Rust), gopter and the built-in `testing/quick` plus Go's native fuzzing. Every one is free and open source. Installation is one line and the first useful property takes about ten minutes.\n\n## Where properties are hard, stated honestly\n\nThey are not a universal replacement, and pretending otherwise wastes people's afternoons.\n\n- **Stateful systems.** Properties over a sequence of operations against a database or a service are possible (Hypothesis has `RuleBasedStateMachine` for exactly this) and they are considerably more work than a single-function property.\n- **Things with no clean invariant.** \"The email looks right\" is not a property. Rendering, layout, natural language output and anything judged by a human resist this technique.\n- **Slow functions.** A property runs a hundred times by default. If one call takes two seconds, that is a three-minute test and it will get deleted.\n- **Finding the property is the work.** Writing `@given` is trivial. Working out what must always be true about your discount engine is the hard, valuable part, and it is exactly the specification thinking that the next module is about.\n\n## The habit worth building\n\nWhen you add an example test, ask one question: **is this example an instance of something more general?**\n\nOften it is not, and an example is the right answer. When it is (when the example is \"a discount never makes the total negative\" or \"the parser accepts everything the formatter produces\") write the general form instead. It takes two more minutes, and it is the version that catches the input neither you nor the generator thought of."},"check":{"question":"A team replaces example tests with a property asserting that `parse(format(x)) == x` for all valid records. The property passes. What class of bug does this specifically fail to catch?","options":[{"text":"Nothing meaningful; a passing round-trip property over the whole input space is strictly stronger than any example suite.","correct":false,"tempting":"Treats one property as complete coverage, when a round-trip only constrains parse and format relative to each other."},{"text":"Performance regressions, since property tests measure correctness rather than speed and will pass on an implementation that became far slower.","correct":false,"tempting":"True but trivially so of all correctness testing; it is not a class of correctness bug the property misses."},{"text":"A shared wrong convention, where both sides agree on a format that is wrong.","correct":true},{"text":"Bugs on inputs the generator never produces, because random generation cannot reach rare structural shapes without explicit strategies.","correct":false,"tempting":"A real limitation of generation strategy, but shrinking and biased generators reach edge shapes routinely; the deeper gap is the shared convention."}]},"prev":{"slug":"verification-is-the-bottleneck","title":"Testing when generating is free","url":"https://addaly.com/learn/how-software-changed/verification-is-the-bottleneck"},"next":{"slug":"differential-testing","title":"The old version is your best oracle","url":"https://addaly.com/learn/how-software-changed/differential-testing"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Properties, not examples', in How Software Development Changed. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/how-software-changed/properties-not-examples (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Properties, not examples","courseTitle":"How Software Development Changed","module":{"index":3,"slug":"oracles-and-verification","title":"Building oracles that stay ahead"},"answer":null,"keyIdea":"A property states what must be true of every input and lets the machine hunt for a counterexample, which is cheap for you to write and expensive for pattern-matched code to satisfy by accident.","hero":{"kind":"figure","figure":{"t":"figure","label":"Figure","figure":{"left":{"title":"An example test","items":["Pins one input to one output","Satisfied by code that is wrong everywhere else","Easy to write, easy to read, easy to pass by accident","Silent about the input nobody thought to imagine"]},"type":"compare","right":{"title":"A property","items":["States what must be true of every input, then the machine hunts","Round trip: decoding what you encoded returns the original","Idempotence: applying it twice equals applying it once","Invariants: money is conserved, the queue never loses an item","Shrinks a failure to the smallest input that still breaks"]},"title":"One point, or every input","caption":"Cheap for you to state and expensive for pattern-matched code to satisfy by accident. That reversal is what makes the technique worth its setup cost now."},"alt":"Figure: One point, or every input. An example test: Pins one input to one output; Satisfied by code that is wrong everywhere else; Easy to write, easy to read, easy to pass by accident; Silent about the input nobody thought to imagine. A property: States what must be true of every input, then the machine hunts; Round trip: decoding what you encoded returns the original; Idempotence: applying it twice equals applying it once; Invariants: money is conserved, the queue never loses an item; Shrinks a failure to the smallest input that still breaks. Cheap for you to state and expensive for pattern-matched code to satisfy by accident. That reversal is what makes the technique worth its setup cost now."}},"blocks":[{"t":"h2","id":"why-examples-stopped-being-enough","runs":[{"text":"Why examples stopped being enough"}]},{"t":"p","runs":[{"text":"An example test pins one input to one output. "},{"text":"total([100, 250]) == 350","code":true},{"text":". It is easy to write, easy to read, and easy to satisfy, including by code that is wrong everywhere except that point."}]},{"t":"p","runs":[{"text":"That last property used to be a minor theoretical objection. It is now the main event, because code that satisfies the visible examples and fails elsewhere is exactly what cheap generation produces. An implementation pattern-matched from a thousand similar functions will handle the shapes those functions handled and diverge at the edges nobody wrote down."}]},{"t":"p","runs":[{"text":"A property test says something that must be true of "},{"text":"every","b":true},{"text":" input, and then the machine searches for a counterexample. That reverses the economics: cheap for you to state, expensive for wrong code to satisfy by accident."}]},{"t":"h2","id":"the-four-families-with-examples","runs":[{"text":"The four families, with examples"}]},{"t":"p","runs":[{"text":"Round-trip.","b":true},{"text":" Encoding and decoding must return you to where you started."}]},{"t":"code","lang":"python","label":"Python","text":"from hypothesis import given, strategies as st\n\n@given(st.text())\ndef test_slug_roundtrip(s):\n    assert unslugify(slugify(s)) == s.strip()"},{"t":"p","runs":[{"text":"This one finds Unicode problems within about four seconds, every time. Serialisation, compression, parsing and formatting, URL encoding, currency formatting: all round-trips."}]},{"t":"p","runs":[{"text":"Idempotence.","b":true},{"text":" Applying twice equals applying once."}]},{"t":"code","lang":"python","label":"Python","text":"@given(st.text())\ndef test_normalise_is_idempotent(s):\n    assert normalise(normalise(s)) == normalise(s)"},{"t":"p","runs":[{"text":"This is the property behind every retry-safe operation, every migration you might have to re-run, and every reconciliation job."}]},{"t":"p","runs":[{"text":"Invariants.","b":true},{"text":" Something true before and after, regardless of the operation."}]},{"t":"code","lang":"python","label":"Python","text":"@given(st.lists(st.integers(min_value=0, max_value=10**9)), st.integers())\ndef test_transfer_conserves_total(balances, amount):\n    before = sum(balances)\n    after = sum(apply_transfer(balances, amount))\n    assert before == after"},{"t":"p","runs":[{"text":"Money is conserved. A sorted list stays the same length. A queue never loses an item. Invariants are where the domain knowledge goes and they are the most valuable family."}]},{"t":"p","runs":[{"text":"Comparison against a reference.","b":true},{"text":" A slow, obviously-correct implementation versus the fast one."}]},{"t":"code","lang":"python","label":"Python","text":"@given(st.lists(st.integers()))\ndef test_fast_median_matches_naive(xs):\n    assume(xs)\n    assert fast_median(xs) == sorted(xs)[len(xs)//2] if len(xs) % 2 else True"},{"t":"p","runs":[{"text":"This is the strongest position available for optimisation work, and it is the same idea as differential testing in the next lesson."}]},{"t":"h2","id":"shrinking-is-the-part-that-makes-it-usable","runs":[{"text":"Shrinking is the part that makes it usable"}]},{"t":"p","runs":[{"text":"When a property fails, the framework does not hand you the 400-element list that broke it. It repeatedly simplifies the failing input until it cannot be simplified further, and gives you the minimal case."}]},{"t":"p","runs":[{"text":"So instead of debugging "},{"text":"[8123, -4, 0, 991, ...]","code":true},{"text":" you get "},{"text":"[0, 0]","code":true},{"text":", and the bug is obvious. Shrinking is what turns a random-input generator into a debugging tool, and it is the reason property testing is worth the setup cost."}]},{"t":"h2","id":"the-free-tooling-all-of-it","runs":[{"text":"The free tooling, all of it"}]},{"t":"p","runs":[{"text":"Hypothesis (Python), fast-check (JavaScript and TypeScript), jqwik (Java), PropCheck and StreamData (Elixir), QuickCheck (Haskell, the original), proptest and quickcheck (Rust), gopter and the built-in "},{"text":"testing/quick","code":true},{"text":" plus Go’s native fuzzing. Every one is free and open source. Installation is one line and the first useful property takes about ten minutes."}]},{"t":"h2","id":"where-properties-are-hard-stated-honestly","runs":[{"text":"Where properties are hard, stated honestly"}]},{"t":"p","runs":[{"text":"They are not a universal replacement, and pretending otherwise wastes people’s afternoons."}]},{"t":"ul","items":[[{"text":"Stateful systems.","b":true},{"text":" Properties over a sequence of operations against a database or a service are possible (Hypothesis has "},{"text":"RuleBasedStateMachine","code":true},{"text":" for exactly this) and they are considerably more work than a single-function property."}],[{"text":"Things with no clean invariant.","b":true},{"text":" “The email looks right” is not a property. Rendering, layout, natural language output and anything judged by a human resist this technique."}],[{"text":"Slow functions.","b":true},{"text":" A property runs a hundred times by default. If one call takes two seconds, that is a three-minute test and it will get deleted."}],[{"text":"Finding the property is the work.","b":true},{"text":" Writing "},{"text":"@given","code":true},{"text":" is trivial. Working out what must always be true about your discount engine is the hard, valuable part, and it is exactly the specification thinking that the next module is about."}]]},{"t":"h2","id":"the-habit-worth-building","runs":[{"text":"The habit worth building"}]},{"t":"p","runs":[{"text":"When you add an example test, ask one question: "},{"text":"is this example an instance of something more general?","b":true}]},{"t":"p","runs":[{"text":"Often it is not, and an example is the right answer. When it is (when the example is “a discount never makes the total negative” or “the parser accepts everything the formatter produces”) write the general form instead. It takes two more minutes, and it is the version that catches the input neither you nor the generator thought of."}]}],"check":{"question":[{"text":"A team replaces example tests with a property asserting that "},{"text":"parse(format(x)) == x","code":true},{"text":" for all valid records. The property passes. What class of bug does this specifically fail to catch?"}],"options":[{"text":[{"text":"Nothing meaningful; a passing round-trip property over the whole input space is strictly stronger than any example suite."}],"correct":false,"tempting":[{"text":"Treats one property as complete coverage, when a round-trip only constrains parse and format relative to each other."}]},{"text":[{"text":"Performance regressions, since property tests measure correctness rather than speed and will pass on an implementation that became far slower."}],"correct":false,"tempting":[{"text":"True but trivially so of all correctness testing; it is not a class of correctness bug the property misses."}]},{"text":[{"text":"A shared wrong convention, where both sides agree on a format that is wrong."}],"correct":true,"tempting":null},{"text":[{"text":"Bugs on inputs the generator never produces, because random generation cannot reach rare structural shapes without explicit strategies."}],"correct":false,"tempting":[{"text":"A real limitation of generation strategy, but shrinking and biased generators reach edge shapes routinely; the deeper gap is the shared convention."}]}],"order":[1,2,3,0],"why":null},"prev":{"slug":"verification-is-the-bottleneck","title":"Testing when generating is free","minutes":9},"next":{"slug":"differential-testing","title":"The old version is your best oracle","minutes":10}}}