Equivalence Partitioning & Boundary Value Analysis
Equivalence partitioning and boundary value analysis are two of the oldest and most effective black-box test design techniques. They are taught in every software testing curriculum, appear on every certification exam, and yet are frequently applied mechanically without real understanding — resulting in test suites that look thorough but miss the bugs they were designed to catch.
This guide covers both techniques in depth: what they are, how to apply them correctly, how they differ, when to use each, and how to integrate them into modern testing workflows with Jest, pytest, and JUnit.
Equivalence Partitioning
The Core Idea
Equivalence partitioning (EP) divides the input domain into groups — partitions — where all values within a group are expected to behave identically with respect to the software under test. The key insight: if one value in a partition reveals a bug, all values in that partition would reveal the same bug. Therefore, you only need to test one representative value per partition.
This transforms a potentially infinite test space into a finite set of partitions, each requiring one test.
Identifying Partitions
For any input, identify:
- Valid partitions — values the system should accept and process correctly
- Invalid partitions — values the system should reject
For a function that accepts ages for a membership system with rules:
- Age must be a positive integer
- Age must be between 18 and 120 inclusive
Input domain analysis:
Invalid partition 1: non-integer values (e.g., "abc", 3.7, null)
Invalid partition 2: integers below 18 (e.g., -100, 0, 17)
Valid partition: integers 18 to 120 (e.g., 18, 50, 120)
Invalid partition 3: integers above 120 (e.g., 121, 999)Each partition gets exactly one representative test. Which representative value? Any value within the partition — the choice does not matter for EP (boundary value analysis addresses that separately).
Worked Example: Discount Calculator
A discount function applies these rules:
- Purchase amount < 0: error
- Purchase amount 0–99: no discount (0%)
- Purchase amount 100–499: 10% discount
- Purchase amount 500–999: 20% discount
- Purchase amount ≥ 1000: 30% discount
EP partition analysis:
| Partition | Representative | Expected Result |
|---|---|---|
| Invalid: negative | -50 | Error / exception |
| Valid: 0–99 | 45 | 0% (pay $45.00) |
| Valid: 100–499 | 250 | 10% (pay $225.00) |
| Valid: 500–999 | 750 | 20% (pay $600.00) |
| Valid: ≥ 1000 | 1500 | 30% (pay $1050.00) |
Five partitions → five test cases, regardless of how large the input domain is.
EP in Python (pytest)
import pytest
def calculate_discount(amount: float) -> float:
"""Calculate discounted price."""
if amount < 0:
raise ValueError("Amount cannot be negative")
if amount < 100:
return amount
elif amount < 500:
return amount * 0.90
elif amount < 1000:
return amount * 0.80
else:
return amount * 0.70
class TestCalculateDiscountEP:
"""Equivalence partition tests — one representative per partition."""
def test_negative_amount_raises(self):
"""Invalid partition: negative values."""
with pytest.raises(ValueError, match="cannot be negative"):
calculate_discount(-50)
def test_no_discount_partition(self):
"""Valid partition: 0–99."""
assert calculate_discount(45) == pytest.approx(45.00)
def test_ten_percent_discount_partition(self):
"""Valid partition: 100–499."""
assert calculate_discount(250) == pytest.approx(225.00)
def test_twenty_percent_discount_partition(self):
"""Valid partition: 500–999."""
assert calculate_discount(750) == pytest.approx(600.00)
def test_thirty_percent_discount_partition(self):
"""Valid partition: ≥ 1000."""
assert calculate_discount(1500) == pytest.approx(1050.00)EP in JavaScript (Jest)
const { calculateDiscount } = require('./discount');
describe('calculateDiscount — equivalence partitions', () => {
test('throws for negative amount (invalid partition)', () => {
expect(() => calculateDiscount(-50)).toThrow('cannot be negative');
});
test('no discount for 0–99 (valid partition)', () => {
expect(calculateDiscount(45)).toBeCloseTo(45.00);
});
test('10% discount for 100–499 (valid partition)', () => {
expect(calculateDiscount(250)).toBeCloseTo(225.00);
});
test('20% discount for 500–999 (valid partition)', () => {
expect(calculateDiscount(750)).toBeCloseTo(600.00);
});
test('30% discount for ≥ 1000 (valid partition)', () => {
expect(calculateDiscount(1500)).toBeCloseTo(1050.00);
});
});Boundary Value Analysis
Why Boundaries Matter
EP tells you to test one value per partition. Boundary value analysis (BVA) tells you which values to test — the ones at and near the edges between partitions. Decades of defect data show that off-by-one errors, fence-post errors, and edge-case mishandlings cluster at partition boundaries, not in the middle.
Classic BVA: 2-Value and 3-Value
2-value BVA tests each boundary point and one point just beyond it:
- At the boundary: the last valid value
- Just outside the boundary: the first invalid value (or first value of the next partition)
3-value BVA adds the value just inside the boundary:
- Just inside: one step before the boundary
- At the boundary: the exact boundary value
- Just outside: one step beyond the boundary
For a boundary at 100 (inclusive):
2-value BVA: test 100 (boundary), test 99 (just below)
test 100 (boundary), test 101 (just above — if 100 is a max)
3-value BVA: test 99, 100, 101Worked Example: Age Validation (18–120)
Boundaries to test:
Lower boundary at 18:
17 (just below — invalid)
18 (boundary — valid)
19 (just above — valid)
Upper boundary at 120:
119 (just below — valid)
120 (boundary — valid)
121 (just above — invalid)This gives 6 boundary tests. Combined with EP representatives (e.g., age 50 for the interior), you have 7 tests total — far fewer than testing the full range, and far more targeted at the likely defect locations.
EP + BVA Combined: Discount Calculator
| Type | Value | Expected |
|---|---|---|
| EP (mid-partition) | -50 | Error |
| BVA | -1 | Error |
| BVA (lower) | 0 | 0% ($0.00) |
| BVA | 1 | 0% ($1.00) |
| EP (mid-partition) | 45 | 0% ($45.00) |
| BVA | 99 | 0% ($99.00) |
| BVA (boundary) | 100 | 10% ($90.00) |
| BVA | 101 | 10% ($90.90) |
| EP (mid-partition) | 250 | 10% ($225.00) |
| BVA | 499 | 10% ($449.10) |
| BVA (boundary) | 500 | 20% ($400.00) |
| BVA | 501 | 20% ($400.80) |
| EP (mid-partition) | 750 | 20% ($600.00) |
| BVA | 999 | 20% ($799.20) |
| BVA (boundary) | 1000 | 30% ($700.00) |
| BVA | 1001 | 30% ($700.70) |
| EP (mid-partition) | 1500 | 30% ($1050.00) |
BVA in pytest with parametrize
import pytest
@pytest.mark.parametrize("amount,expected", [
# Invalid partition boundaries
(-1, None), # just below zero
# Lower valid boundary
(0, 0.00),
(1, 1.00),
# Boundary at 100
(99, 99.00),
(100, 90.00),
(101, 90.90),
# Boundary at 500
(499, 449.10),
(500, 400.00),
(501, 400.80),
# Boundary at 1000
(999, 799.20),
(1000, 700.00),
(1001, 700.70),
])
def test_discount_boundaries(amount, expected):
if expected is None:
with pytest.raises(ValueError):
calculate_discount(amount)
else:
assert calculate_discount(amount) == pytest.approx(expected, rel=1e-2)BVA in JUnit 5
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
class DiscountCalculatorBVATest {
@ParameterizedTest(name = "amount={0} → expected={1}")
@CsvSource({
// Just below zero (invalid)
"-1, -1", // -1 signals expected exception
// Lower boundary
"0, 0.00",
"1, 1.00",
// Boundary at 100
"99, 99.00",
"100, 90.00",
"101, 90.90",
// Boundary at 500
"499, 449.10",
"500, 400.00",
"501, 400.80",
// Boundary at 1000
"999, 799.20",
"1000, 700.00",
"1001, 700.70",
})
void testDiscountBoundaryValues(double amount, double expected) {
if (expected == -1) {
assertThrows(IllegalArgumentException.class,
() -> DiscountCalculator.calculate(amount));
} else {
assertEquals(expected, DiscountCalculator.calculate(amount), 0.01,
"Unexpected discount for amount=" + amount);
}
}
}Comparison: EP vs. BVA
| Aspect | Equivalence Partitioning | Boundary Value Analysis |
|---|---|---|
| Purpose | Reduce test cases by grouping equivalent inputs | Focus tests on high-defect-density boundary points |
| Defect type caught | Logic errors, missing branches | Off-by-one errors, fence-post bugs |
| Test count | One per partition | 2–3 per boundary (2× number of boundaries) |
| Typical usage | Used first to define partitions | Applied after EP to select which values to test |
| Works without the other | Yes (weak but complete) | No — needs partitions to identify boundaries |
| Best for | Systems with clear input categories | Numeric ranges, length limits, date ranges |
EP and BVA are complementary. In practice, you always apply them together: EP to identify the partitions, BVA to select the test values at the partition edges.
When to Use Each
Use equivalence partitioning when:
- The input domain has distinct categories (e.g., account types: Free, Pro, Enterprise)
- You need to reduce a large input space to a manageable test set
- The system has different processing logic for different input categories
Use boundary value analysis when:
- Inputs have numeric ranges, string length limits, date constraints, or collection size limits
- You have found off-by-one bugs in this codebase before
- The system performs comparisons with
<,<=,>,>=operators - Range boundaries are defined in specifications or business rules
Real-world scenarios:
Password validation (8–64 characters):
EP partitions: empty, 1–7 chars (too short), 8–64 chars (valid), 65+ chars (too long)
BVA values: 0, 7, 8, 9, 63, 64, 65
File upload (max 5 MB):
EP partitions: empty file, 1 byte – 5 MB (valid), over 5 MB (invalid)
BVA values: 0 bytes, 1 byte, 5,242,879 bytes (5MB - 1), 5,242,880 bytes (5MB), 5,242,881 bytes (5MB + 1)
Date range filter (must be within last 90 days):
EP partitions: future dates, within last 90 days, older than 90 days
BVA values: today, yesterday, 89 days ago, 90 days ago, 91 days agoCommon Mistakes
Mistake 1: Testing only mid-partition values
The most common error is applying EP correctly (one value per partition) but choosing the midpoint, ignoring BVA entirely. This is how off-by-one bugs survive.
# Wrong — only tests mid-partition, misses boundary bug
def test_discount_ten_percent():
assert calculate_discount(250) == 225.00 # Only tests the middle
# Right — tests the boundary too
def test_boundary_at_100():
assert calculate_discount(99) == 99.00 # Last value with no discount
assert calculate_discount(100) == 90.00 # First value with 10% discountMistake 2: Wrong partition boundaries
If the spec says "greater than 100" but you test calculate_discount(100) as belonging to the 10%-discount partition, you may have the boundary backwards. Always read whether boundaries are inclusive or exclusive.
"Amount must be greater than 100 to qualify for discount"
→ 100 is in the NO-discount partition
→ 101 is the first value IN the discount partition
"Amount must be 100 or more to qualify for discount"
→ 100 is in the discount partition
→ 99 is the last value with NO discountMistake 3: Ignoring invalid partitions
Many test suites test only valid inputs. Invalid partitions (negative amounts, null values, wrong types) reveal a different class of bugs — error handling failures.
Mistake 4: Applying BVA to non-ordered inputs
BVA only makes sense for inputs with a natural ordering. Applying it to categorical inputs (browser type, payment method) is meaningless. Use EP alone for those.
Mistake 5: Over-testing within a partition
Once you have chosen a representative value and the boundary values, testing additional mid-partition values adds no coverage value. Time spent on extra mid-partition tests is better spent on new partitions or integration testing.
Combining EP and BVA with Parametrized Tests
Modern testing frameworks make it easy to express EP+BVA test sets as parametrized cases:
# Complete EP+BVA test set for the discount calculator
@pytest.mark.parametrize("amount,expected,description", [
# Invalid partition
(-1, "error", "EP: negative (invalid)"),
# BVA at zero boundary
(0, 0.00, "BVA: zero (lower bound of valid)"),
(1, 1.00, "BVA: one (just inside valid)"),
# EP representative
(45, 45.00, "EP: mid no-discount partition"),
# BVA at 100 boundary
(99, 99.00, "BVA: 99 (just below 10% threshold)"),
(100, 90.00, "BVA: 100 (first 10% discount)"),
(101, 90.90, "BVA: 101 (just inside 10% partition)"),
# EP representative
(250, 225.00, "EP: mid 10%-discount partition"),
# Add remaining BVA and EP entries...
])
def test_discount_ep_and_bva(amount, expected, description):
if expected == "error":
with pytest.raises(ValueError):
calculate_discount(amount)
else:
result = calculate_discount(amount)
assert result == pytest.approx(expected, rel=1e-2), descriptionConclusion
Equivalence partitioning and boundary value analysis are foundational because they directly target the input space structure that bugs exploit. EP reduces the infinite test space to a manageable partition count. BVA focuses your tests on the partition edges where most defects hide.
Used together, they produce test suites that are small, well-justified, and highly effective. They scale to any input type — numeric ranges, string lengths, date constraints, collection sizes — and integrate naturally with parametrized testing in pytest, Jest, and JUnit.
The techniques are not sophisticated, but applying them carefully and consistently — especially remembering to test both sides of every boundary — is how you build test suites that actually catch the bugs that reach production.