ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A number line for a 1 to 90 day booking window folding at day 90, the exact point where an off-by-one boundary condition breaks
TestingBoundary Value AnalysisTest Design Techniques+1

How Boundary Value Analysis Finds a 90-Day Off-by-One

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Boundary value analysis is a test design technique that targets the edges of an input range instead of its middle, because off-by-one defects live at the boundary and nowhere else. The two-value variant tests only the boundary itself and its nearest invalid neighbor, one pair per boundary. The three-value variant adds a second value moving inward, six inputs total across two boundaries instead of four, catching the exact class of < versus <= mistake a mid-range input never touches.

Somewhere in an interview, or a study guide, or a Slack thread where an auditor asked which test design techniques your team actually applies, you got asked to name boundary value analysis and back it with more than "test the edges." Two-value or three-value, someone follows up, and which one did you use, and on what input. This is that answer, worked all the way through on one range with a real bug hiding in it, not a QA lead deciding how much of a suite should target edges versus the middle, and not a team wondering whether their generated tests assert anything real about those edges. Just the vocabulary, backed by arithmetic and a test you can run.

The technique, with the values enumerated

A booking window has to accept exactly 1 to 90 days. Below 1 and above 90 are invalid. That single sentence contains two boundaries: a minimum boundary at 1 and a maximum boundary at 90, and every value in the technique below is chosen relative to one of those two lines, never from anywhere else in the range.

Two-value boundary value analysis (BVA) tests only the boundary itself paired with its nearest invalid neighbor: 0 and 1 for the minimum, 90 and 91 for the maximum. Four inputs, one pair per boundary, no interior value included at all. Three-value boundary value analysis adds one more value moving inward from each boundary, valid side: 2 next to the minimum, 89 next to the maximum. Six inputs total, and every one of them still traces back to a boundary rather than to the middle of the range.

Input (days)PositionTwo-value pickThree-value pickExpected result
0Below minimumYesYesInvalid
1Minimum boundaryYesYesValid
2Just inside minimumNoYesValid
89Just inside maximumNoYesValid
90Maximum boundaryYesYesValid, off-by-one bug here
91Above maximumYesYesInvalid

Equivalence partitioning would describe this same range differently: every day count from 1 to 90 belongs to one valid class, and everything outside it belongs to two invalid classes, one below and one above. Boundary value analysis does not pick a representative from that class at random, it picks specifically at the seam between classes, because that is where a class-membership check is most likely to be implemented wrong. The equivalence partitioning piece covers how those classes get derived from a validation function's actual branches rather than guessed from a requirements document.

A number line from 0 to 91 for a 1 to 90 day booking window, with the four two-value boundary picks at 0, 1, 90, and 91 marked as filled circles, and the two additional three-value picks at 2 and 89 marked as hollow circles sitting one step inward from each boundary
Two-value picks sit exactly on the seam. Three-value picks add one step inward at each boundary, six inputs total.

A boundary bug you can run

Here's the implementation this range gets checked against, one function, written for this booking window rather than lifted from a tutorial:

"""Booking window validation.

A booking window is considered valid when the requested number of
days falls within the inclusive range 1 to 90 days.
"""


def is_valid_booking_window(days):
    """Return True if `days` is a valid booking window length.

    Valid bookings are 1 to 90 days, inclusive.
    """
    return days >= 1 and days < 90

The spec says 1 to 90 inclusive. The function's minimum check reads that correctly. Its maximum check does not: it compares with < where the spec requires <=, so a 90-day booking, which the spec explicitly allows, gets rejected. That is an invalid boundary silently swallowing a valid one, and it is invisible from anywhere except day 90 itself. Spotting it required reading the comparison operator directly, the same source-level access white box testing is defined by; nothing about this bug shows up from the interface alone.

Here's the parameterised test that walks every input from the table above, plus one interior value at day 45 as a control, seven cases in total, framework-native, not seven copy-pasted assertions. The interior value is exactly the case a boundary-blind test would have picked, and it passes against both the buggy and the fixed implementation, which is the point the closing section later makes about day 45:

import pytest

from booking_window import is_valid_booking_window


@pytest.mark.parametrize(
    "days, expected",
    [
        (0, False),   # below minimum
        (1, True),    # minimum boundary
        (2, True),    # just inside minimum
        (45, True),   # interior value
        (89, True),   # just inside maximum
        (90, True),   # maximum boundary -- this is where the off-by-one bug lives
        (91, False),  # above maximum
    ],
)
def test_is_valid_booking_window(days, expected):
    assert is_valid_booking_window(days) is expected
The seven test inputs 0, 1, 2, 45, 89, 90 and 91 laid out in columns with two verdict rows, what the specification requires and what the shipped comparison operator returns, agreeing on every input except day 90 where the specification says valid and the function says invalid, marking the single failing case
Two rules that differ by one character produce identical answers for six of the seven inputs. Day 45, the comfortable middle value, agrees with both. Only the input sitting exactly on the boundary can tell them apart.

Running that parameterised test against the buggy implementation above gives six passes and one failure. The failure is exactly the 90-day case: the function's upper check evaluates 90 < 90, gets False, and rejects a booking the spec requires it to accept. The 0, 1, 2, 45, 89, and 91 cases all pass, because none of them touch the line where the mistake lives. Change that one comparison from < to <= and rerun the same seven cases: all seven pass. Nothing else in the test changed, only the implementation did, which is the point of enumerating boundary inputs explicitly rather than trusting a function to "handle date ranges" in the abstract.

This is the same off-by-one shape as the $500 discount boundary worked through in assertion coverage vs line coverage, just on a range instead of a single threshold: a comparison operator one character away from correct, invisible to any input that isn't the boundary itself. And each case in the table above asserts the specific boolean expected for that input, not merely that the function returned without an exception, which is the distinction how to write good test assertions covers at length.

Robust BVA and when the invalid side matters

The six three-value inputs above, one boundary value, one step inward, and one step outward at both the minimum and the maximum, are what most references call robust or worst-case boundary value analysis once you're deliberately including both directions around every boundary rather than just the line itself. That much most pages that mention BVA at all will tell you.

What they skip is the harder question underneath the invalid boundary: what should actually happen at 0 or 91. "Invalid" is not self-defining. A function can reject with a validation error naming the field, return a bare boolean false, throw an exception, or silently clamp the input to the nearest valid day, and those are four observably different behaviors behind one word. A test that only asserts the function "handled" an invalid boundary without specifying which of those four it expects has not tested the invalid boundary at all, it has tested that the function didn't crash, which is a much weaker claim. Robust BVA earns its name by insisting the invalid side gets the same explicit treatment as the valid side: not just which inputs to include, but which output each one should produce.

This is not a question of how much of a suite's budget should go to boundary cases versus everything else; that's a strategy allocation decision made elsewhere. It's narrower than that: given that you're testing this boundary at all, what exactly does "invalid" mean here, stated plainly enough that whoever writes the test doesn't have to guess.

How Autonoma finds boundaries in your code

That distinction, a boundary the spec states versus a boundary a person has to notice, is the actual difficulty in applying boundary value analysis at scale. The booking window above is one function with two boundaries and a two-character bug. A real codebase has hundreds of comparison operators buried in validation branches across dozens of files, and nothing marks which of them sit at a boundary someone actually cares about versus which are incidental. A person doing this by hand has to read a requirements document, guess which numeric limits it implies, then go find the corresponding comparisons in the code, and the guessing step is where boundaries quietly go untested.

We built our agents to read the codebase before they plan a single test, the same way the worked example above starts from the function rather than from a requirements paragraph. A validation branch with a comparison operator inside it, days < 90 sitting next to a docstring that says "1 to 90 inclusive," is a boundary Autonoma can see directly in the source rather than one a person has to infer from a spec that may never mention the number 90 at all. That is an architectural fact about where the boundary gets found, not a claim about how many of them get caught or how fast. Once a boundary is identified this way, the test cases that exercise it get planned the same way the table above was built by hand: the value at the line, and the values immediately on either side of it.

Why the technique changed jobs

Boundary value analysis was invented for a world where running a test case cost a person's time, so you had to pick a handful of inputs out of an entire range and make every one of them count. That constraint is gone. Running all ninety valid inputs, from day 1 to day 90, costs the same machine-seconds as running one. What survives from the technique is not the selection rule, which stops mattering once selection is nearly free, it's the part of it that states what the correct answer is at each edge, because that statement never became free just because execution did.

That reframing changes what a boundary value analysis exercise is actually for. It stops being a rationing decision, four tests instead of ninety because that's what a sprint allowed, and becomes a documentation exercise: writing down, for each boundary a system exposes, exactly what the correct output is on either side of the line. Once that's written down, whether it gets checked by two inputs, six, or all ninety costs nothing extra to decide.

Here's the sharp version of that gap. Suppose a test generator, human or automated, picks one convenient value from the middle of the range, say day 45, to represent "a valid booking window." Both the buggy implementation above and the fixed one return the same answer for day 45: valid. A test built around that single value passes against either implementation, catches nothing, and reports full confidence. The boundary at day 90 is exactly the place a generator, or a person moving fast, has nothing to go on unless the expected behavior at that specific edge was written down somewhere it could find it. This is the same failure mode AI-generated tests that pass but don't assert anything documents from a different angle: a test that runs and passes is not evidence the thing that matters was ever checked.

The same 1 to 90 day range drawn twice: the top row shows four sampled test cases with gaps between them and a convenient middle value at day 45, the bottom row shows all ninety relevant inputs run end to end, with a single marker at day 90 as the only point where the sampled row's answer and the full range's answer disagree
Four sampled cases agree with the whole range everywhere except one input. That one input is the boundary.

None of this changes what the three-word technique means when someone asks you to name it in a review or an interview. It changes what's worth remembering about it: not "pick two or three values," but "state, explicitly, what the answer should be at the line and one step past it," because that statement is the only part a cheap test run can't supply on its own. Autonoma reads the code to find where those lines actually sit and runs the cases against them on every pull request, which turns the finding-the-boundary problem into a solved step rather than a manual guess repeated every time the range changes. It is the E2E layer next to whatever unit tests already cover the function in isolation, not a replacement for either the test design techniques that decide what gets tested or the unit tooling that runs closest to the code.

Frequently Asked Questions

Boundary value analysis is a test design technique that selects test inputs at the edges of a valid range rather than from the middle of it. It exists because defects that involve comparison operators, like using less-than where less-than-or-equal-to was required, only show up when a test input sits exactly on or next to the boundary the operator checks. A test built from values in the interior of the range will pass regardless of whether the boundary check is correct.

Equivalence partitioning groups inputs into classes that are supposed to behave identically and picks one representative value from each class. Boundary value analysis is a refinement of that idea: instead of picking any representative from a class, it picks values specifically at the seams between classes, because that is where a class-membership check is most likely to be implemented incorrectly. The two techniques are usually applied together, with equivalence partitioning defining the classes and boundary value analysis choosing where inside and around them to test.

Three-value boundary value analysis tests three inputs around each boundary: the boundary value itself, one value immediately outside the valid range, and one value immediately inside it. For a range of 1 to 90, that means testing 0, 1, and 2 at the minimum boundary and 89, 90, and 91 at the maximum boundary, six inputs total. This is one more input per boundary than two-value boundary value analysis, which tests only the boundary and its nearest invalid neighbor.

It is important because off-by-one and comparison-operator defects cluster specifically at range boundaries and are effectively invisible to any test built from an interior value. A validation function can pass every test built around convenient, round, or randomly chosen inputs and still reject or accept the wrong value at the exact edge the specification cares about most. Boundary value analysis is the technique that deliberately targets that failure mode instead of leaving it to chance.

The mechanical part, generating the boundary and near-boundary values for a given range, is straightforward to automate once the range itself is known. The harder part is finding the range in the first place: knowing that a particular comparison operator in the code corresponds to a boundary a specification actually cares about. Autonoma's agents read the codebase before planning tests, so a comparison operator inside a validation branch is a boundary the system can see directly in the source rather than one that has to be guessed from a requirements document. That is an architectural difference in where the boundary gets identified, not a claim about test-generation speed or coverage percentages.

Related articles

The seven test design techniques in software testing split into two groups: sampling rules that changed job once execution got cheap, and specifications that stayed the same

The 7 Test Design Techniques in Software Testing

All seven test design techniques in software testing, defined with one worked example each, plus a verdict on which ones survived cheap test execution.

Quara sits at the centre of a vast dark field of thousands of identical configuration tiles, with a single small lime-lit stack of tiles in front, illustrating pairwise testing compressing a full combinatorial matrix into a verified minimal set

Pairwise Testing Cuts 4,096 Configs to 22 Rows

Pairwise testing cuts 4,096 configurations to a verified 22 rows covering all 240 pairs. Here is the decision rule for when that reduction is actually worth it.

An isometric miniature workshop where a dark charcoal toy frog sorts input categories into a long row of trays, with a lime cable running from a notched source-code slab into the trays while a blank printed rule card sits unconnected beside it, and one extra tray at the end of the row lit in lime

Equivalence Partitioning: The Rule That Became a Spec

What equivalence partitioning is, with a worked example: six equivalence classes read from a spec, and a seventh only the validator's own code reveals.

A shipping-cost decision table with twelve rule columns collapsing into eight, the don't-care cells shaded, alongside the same eight rules rendered as a parameterised test

What Is Decision Table Testing? 12 Rules to 8

Decision table testing worked end to end: a full 12-rule shipping-cost table collapsed to 8, condition stub to action entries, proven as a passing test.