You can’t test every input. A quantity field that takes a string has infinitely many possible values, and even a field limited to 1 through 99 has more cases than anyone wants to write. Equivalence partitioning is the standard way out: sort the inputs into groups that the rules treat identically, then test one representative from each group. If the code handles 7 correctly, there’s no reason to think it treats 8 differently, because nothing in the rules distinguishes them.
The technique is easy to describe and easy to get subtly wrong. The partitions you draw from the requirements are a claim about how the code behaves. The code may have different ideas, especially when it hands parsing off to a library. A good partitioning exercise therefore has two halves: derive the groups from the spec, then go looking for groups the implementation introduced on its own.
Drawing partitions from the spec
Take an API endpoint with a query parameter qty. The spec says: it’s required, it must be a whole number written in plain digits, and it must be between 1 and 99. Responses distinguish three errors so the client can show a useful message: missing, malformed and out_of_range.
Reading that spec, the input space splits into these groups:
| Partition | Example | Expected |
|---|---|---|
| Absent parameter | None |
missing |
| Present but blank | " " |
missing |
| Valid, in range | "7" |
returns 7 |
| Digits, below range | "0" |
out_of_range |
| Digits, above range | "250" |
out_of_range |
| Not a number | "abc" |
malformed |
| Number, but not whole | "2.5" |
malformed |
A few judgement calls are hiding in that table. Blank is grouped with absent because, in this spec, a blank form field should say “required”, not “invalid”. Negative numbers like "-3" don’t get their own row: the spec says plain digits, so a minus sign makes the input malformed, the same group as "abc". If the spec had said “an integer between 1 and 99”, "-3" would move into the below-range partition. Partitions follow the rules, and the rules decide which differences matter.
The values chosen as representatives are deliberately ordinary. "250" stands for “too large”, and nothing about it is special. Edges like "99" and "100" belong to boundary-value testing, which works hand in hand with this; mixing the two in one test makes it harder to say what a failure means.
A first implementation, and tests that pass
The obvious Python implementation leans on int() to do the parsing:
class QuantityError(ValueError):
def __init__(self, code: str, raw):
super().__init__(f"{code}: {raw!r}")
self.code = code
def parse_quantity(raw: str | None) -> int:
if raw is None or raw.strip() == "":
raise QuantityError("missing", raw)
try:
value = int(raw)
except ValueError:
raise QuantityError("malformed", raw) from None
if not 1 <= value <= 99:
raise QuantityError("out_of_range", raw)
return valueOne test per row of the table:
class SpecPartitions(unittest.TestCase):
def assertRejected(self, raw, code):
with self.assertRaises(QuantityError) as ctx:
parse_quantity(raw)
self.assertEqual(ctx.exception.code, code)
def test_valid_quantity_is_returned_as_int(self):
self.assertEqual(parse_quantity("7"), 7)
def test_absent_parameter_is_missing(self):
self.assertRejected(None, "missing")
def test_blank_value_is_missing(self):
self.assertRejected(" ", "missing")
def test_zero_is_out_of_range(self):
self.assertRejected("0", "out_of_range")
def test_large_number_is_out_of_range(self):
self.assertRejected("250", "out_of_range")
def test_text_is_malformed(self):
self.assertRejected("abc", "malformed")
def test_decimal_is_malformed(self):
self.assertRejected("2.5", "malformed")All seven pass. Each test checks the error code, not just that some exception happened, because the three error types are part of the contract. A test that only asserted assertRaises(QuantityError) would pass even if every bad input were reported as missing, and the client’s error messages would all be wrong.
Partitions the implementation added on its own
Here’s the half people skip. int() has its own idea of what a valid integer string is, and it’s much broader than “plain digits”. Probing it directly in Python 3.14 gives these results (annotations added):
' 7 ' -> 7 surrounding whitespace is stripped
'+5' -> 5 a leading plus sign is accepted
'1_0' -> 10 underscores are digit separators (since Python 3.6)
'٣' -> 3 Arabic-Indic digit three
'07' -> 7 full-width digits
'2.5' -> ValueError: invalid literal for int() with base 10: '2.5'So the implementation has a partition the spec never mentioned: “strings that aren’t plain digits but int() accepts anyway”. From the spec’s point of view all of these are malformed. From the code’s point of view they’re valid quantities. Your seven tests can’t see the difference, because none of the representatives fall in that group.
A test aimed at the new partition makes it visible:
class ImplementationPartitions(unittest.TestCase):
def test_inputs_int_accepts_but_spec_rejects(self):
for raw in [" 7 ", "+5", "1_0", "٣"]:
with self.subTest(raw=raw):
with self.assertRaises(QuantityError) as ctx:
parse_quantity(raw)
self.assertEqual(ctx.exception.code, "malformed") ... (raw=' 7 ') ... FAIL
... (raw='+5') ... FAIL
... (raw='1_0') ... FAIL
... (raw='٣') ... FAIL
AssertionError: QuantityError not raised
Ran 8 tests in 0.001s
FAILED (failures=4)Is this a real problem or pedantry? It depends on what reads the value next. If the same raw string is stored, logged or forwarded to another service that parses differently, "1_0" might mean 10 in one place and an error in another. Validation layers that disagree with each other are a classic source of security bugs. At minimum the spec and the code should agree, and either one can change. Here the spec wins, so the parser checks the format explicitly:
import re
ASCII_DIGITS = re.compile(r"[0-9]+")
def parse_quantity(raw: str | None) -> int:
if raw is None or raw.strip() == "":
raise QuantityError("missing", raw)
if not ASCII_DIGITS.fullmatch(raw):
raise QuantityError("malformed", raw)
value = int(raw)
if not 1 <= value <= 99:
raise QuantityError("out_of_range", raw)
return valueAll eight tests pass. The rewrite also moves "-3" into the right group: the first version parsed it to -3 and reported out_of_range, while the spec calls it malformed. Two details in that regex are themselves partition bugs waiting to happen. In Python, \d on a str pattern matches any Unicode decimal digit, so re.fullmatch(r"\d+", "٣") matches; writing [0-9] keeps it ASCII. And fullmatch matters: re.match(r"^[0-9]+$", "7\n") succeeds, because $ also matches just before a trailing newline.
The same kind of trap lives in str.isdigit(). The superscript "²" reports isdigit() as True, yet int("²") raises ValueError. Code that validates with isdigit() and then converts with int() turns that input into an unhandled exception, which an API typically reports as a 500.
How to find hidden partitions
You won’t spot every library quirk by reading docs, so build the hunt into the habit:
- Read what the code delegates to. Every call to a parser, a regex, a date library or a framework validator brings its own partitions. Ask what it accepts that you don’t.
- Look for branches. Each
ifin the implementation is a place where inputs get split. If the tests don’t have a representative on both sides, there’s a partition nobody chose deliberately. - Try the input categories that usually split behavior: empty, whitespace, signs, separators, non-ASCII, very long, and values that are valid in a different locale (
"1,000","1.000"). - Use a property-based or fuzzing tool to throw many strings at the parser and compare it with a simple reference rule. Generated inputs regularly land in groups nobody wrote down.
Interview practice
A signup form has an age field. Users must be 13 or older; anyone 13 to 17 needs a parent’s email as well; 18 and over signs up normally. The field is a free-text box. List the partitions you’d test and say how many tests that is.
Answer
From the rules there are three valid partitions and a handful of invalid ones. Valid: under 13 (rejected with a “too young” message, which is a rule outcome rather than a format error), 13 to 17 (accepted, parent email required), and 18 or over (accepted normally). Invalid input: empty, non-numeric text like "twelve", a decimal like "15.5", and a negative number. I’d also ask whether there’s an upper limit, since "200" is numerically valid and almost certainly a typo, and whether the form or the server enforces it.
That’s seven or eight tests, one representative per group, for example "9", "15", "30", "", "twelve", "15.5", "-4" and possibly "200". Then I’d add boundary tests at 12/13 and 17/18, because those are the edges where an off-by-one would put a 13-year-old in the wrong group. Finally I’d check what the parsing code accepts beyond the spec: if it uses something like int() or Number(), inputs such as " 15 " or "1_5" may sneak into the 13 to 17 group without anyone deciding they should.