ProductHow it worksPricingBlogDocsLoginFind Your First Bug
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
TestingEquivalence PartitioningTest Design

Equivalence Partitioning: The Rule That Became a Spec

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Equivalence partitioning is a test design technique that groups a system's possible inputs into equivalence classes, sets of inputs the system is supposed to treat identically, then tests one representative value from each class instead of every value in it. Every class is either a valid partition, inputs the system should accept, or an invalid partition, inputs it should reject for a specific, identifiable reason. The technique's real value isn't the sampling. It's the claim buried inside each class boundary.

What follows tests that claim against a real validation function: six branches, one partition per branch, until a seventh partition turns up that no requirements document ever mentioned. It's built for whoever needs to name equivalence partitioning correctly rather than decide whether to use it, an interview question, a QA hire who needs "equivalence class" defined once and properly, an auditor who wants a paragraph they can quote back correctly. (A QA lead drafting a testing strategy, or a team asking whether an AI-generated test actually asserts anything real, will want a different page.)

Equivalence partitioning is one of seven techniques covered in our test design techniques guide; this page stays on just this one.

Worth saying where the angle comes from. Deriving partitions by reading a validator's branches, rather than by reading a document about them, is the same move Autonoma makes before it plans a single test case, so the worked example below is the by-hand version of something we run against real codebases. The technique stands on its own whether or not you ever automate it, and the definitions here are the standard ones.

How equivalence partitioning works, with the partitions enumerated

Start with the requirements document: a specification turned into a small number of classes. Say the feature is username registration, and the document states four rules: three to twenty characters, letters, digits, and underscores only, must start with a letter, and must not already belong to another account.

Unlike decision table testing, which encodes a full specification as rules rather than sampling representative values, equivalence partitioning samples first. Four rules produce five invalid partitions and one valid partition, six total: too short, too long, disallowed characters, invalid start, already taken, and valid. The claim underneath it: every member of one class is interchangeable for testing, so a two-character string is as informative as any other for the too-short partition.

The table below is the prescribed test set: one input per partition, six cases standing in for an unbounded input space.

PartitionValid or invalidExample input
Too shortInvalidab
Too longInvalid21-character string
Disallowed charactersInvalidab-cd
Invalid startInvalid1alice
Already takenInvalidalice99
ValidValidbob_2024

The username input space drawn as a bounded region divided into six labelled partitions, five invalid rejection reasons and one valid class, each holding a single sampled example username

Each dot is one sampled input standing in for an unbounded class of strings, and the count is six because the requirements document stated four rules.

Six cases instead of an unbounded input space, and the justification for stopping at six is entirely about cost: a person testing by hand can afford six cases, not every string of disallowed length. That justification is doing real work, and the next section takes it away.

A partition is a claim, not a shortcut

Every explanation of equivalence partitioning gets to the same place: pick one input per class, because testing every member of a class already declared "behaves the same" is wasted effort. That's true, and it's not the interesting part. The interesting part is the sentence that had to be true before the shortcut made sense: these inputs are supposed to behave the same. That isn't an efficiency observation, it's a claim about what the specification requires. It says the valid partition, three to twenty characters, starting with a letter, made of letters, digits and underscores, not already registered, is one behavior, not a pile of individually verified accidents. It says invalid start and disallowed characters are two different failures, not one blurry "bad input" bucket, because the specification cares about the difference: one message tells a user to start with a letter, the other tells them which characters are off limits.

The textbook only ever cashes that claim one way: run one member of each class, skip the rest, because a person at a keyboard could only run so many cases a day. That was a sampling economy that made sense while the alternative was exhausting a tester's afternoon. It stops making sense once running every member of a class costs about the same as running one; the class boundary was never saving time, it was the only informative part of the exercise. This technique picked a few test cases when running them was expensive. Running them is no longer expensive. What survives is the part that says what the answer should be.

"No longer expensive" is doing enough work in that sentence to deserve a specific answer about where the cost went. For a pure function like the validator below, it went to a unit test runner years ago, and it has been cheap to run a hundred usernames through one for as long as most readers have been working. For the registration form a real person actually types into, the part where a partition either does or doesn't reach the user, it went somewhere newer: Autonoma plans cases against the running application in a live preview environment and reports back which ones are genuine failures, so exercising a whole class rather than one representative of it is a scheduling question now instead of a staffing one. The selection rule got cheap. The claim about which inputs are supposed to behave alike did not.

A partition isn't a shortcut you take because testing is slow. It's a specification you'd still want written down even if testing were instant.

If you're settling a definition for an interview or a study guide, that reframe beats the sampling explanation. It's also the more defensible answer for an auditor: not "so we don't have to test everything," but "so we've stated, in writing, which differences between inputs are supposed to be irrelevant." Grey box testing makes the same move at the level of an entire testing posture; the validator above makes it concrete at the level of one function's branches.

How to identify equivalence classes from the code

The section above enumerated partitions the way most teams do it: read four rules in the requirements document, derive six partitions from four sentences. That's the correct exercise, and it's incomplete in a way careful reading can't fix. The document doesn't contain the whole specification. The code does.

Here's the validator behind the partition table above. Six branches, checked in a fixed order, each one a partition waiting to be read directly off the code:

// Username registration validator.
//
// Six branches, checked in the exact order below. The order is load-bearing: an
// input that breaks two rules at once reports the reason belonging to the earlier
// branch and never reaches the later one. Each branch defines one equivalence
// partition, so the partitions are the six rejection reasons plus the valid class,
// seven in total.

// Reserved names are withheld for reasons none of the length or character rules
// mention: routing needs words like "support" and "api" to resolve to a real
// destination rather than to whoever registered them first, and a name like
// "admin" would let an account impersonate the product itself.
const RESERVED_USERNAMES = new Set(["admin", "root", "support", "help", "api"]);

const MIN_LENGTH = 3;
const MAX_LENGTH = 20;
const ALLOWED_CHARACTERS = /^[A-Za-z0-9_]+$/;
const STARTS_WITH_LETTER = /^[A-Za-z]/;

/**
 * Validate a candidate username.
 *
 * @param {string} username - the candidate, as typed by the user.
 * @param {Set<string>} existingUsernames - lowercased usernames already registered.
 * @returns {{valid: true} | {valid: false, reason: string}}
 */
export function checkUsername(username, existingUsernames = new Set()) {
    // 1. Too short.
    if (username.length < MIN_LENGTH) {
        return { valid: false, reason: "too_short" };
    }

    // 2. Too long.
    if (username.length > MAX_LENGTH) {
        return { valid: false, reason: "too_long" };
    }

    // 3. Disallowed characters. This runs BEFORE the invalid-start check, so an
    //    input like "$bob" reports disallowed_characters and never reaches
    //    branch 4, even though its first character is also not a letter.
    if (!ALLOWED_CHARACTERS.test(username)) {
        return { valid: false, reason: "disallowed_characters" };
    }

    // 4. Must start with a letter.
    if (!STARTS_WITH_LETTER.test(username)) {
        return { valid: false, reason: "invalid_start" };
    }

    const normalized = username.toLowerCase();

    // 5. Reserved word. This runs BEFORE the already-taken lookup, so "admin"
    //    reports reserved_word whether or not it also appears in
    //    existingUsernames. Rejecting it by name costs nothing, and the lookup
    //    would only confirm what the name check already ruled out.
    if (RESERVED_USERNAMES.has(normalized)) {
        return { valid: false, reason: "reserved_word" };
    }

    // 6. Already registered.
    if (existingUsernames.has(normalized)) {
        return { valid: false, reason: "already_taken" };
    }

    return { valid: true };
}

export { RESERVED_USERNAMES, MIN_LENGTH, MAX_LENGTH };

Read the branches in order and a seventh partition falls out that no sentence in the document produced: a check against a short reserved list, admin, root, support, help, api, rejecting a username that is structurally valid, unclaimed, and would pass every rule the document stated. Reserved names exist for reasons unrelated to those four rules: routing needs certain words to resolve to a human, not the fifth person to register them, and impersonation risk means nobody should be able to claim "admin" and have it look official. A tester working only from the document would never write a case for it. A tester, or an agent, reading the function finds it in the time it takes to read one more condition.

That single step is the whole of what Autonoma does differently here, and it's worth separating from anything cleverer sounding. There's no inference about what the product manager meant and no guess at an unwritten rule; there's a branch sitting in the file returning reserved_word, and a partition follows from it the same way the other six do. The techniques on this page don't change when an agent applies them. What changes is which document the classes get derived from.

Two derivations side by side, six partitions read from the requirements document on the left and seven read from the validator's branches on the right, with the extra reserved-word partition highlighted

The seventh partition is a reserved-word check against admin, root, support, help, and api, and reading the validator's branches rather than the requirements document is what surfaced it.

Branch in the sourcePartition it derivesReason returned
Length below 3Too shorttoo_short
Length above 20Too longtoo_long
Character outside letters, digits, underscoreDisallowed charactersdisallowed_characters
First character not a letterInvalid startinvalid_start
Matches reserved listReserved wordreserved_word
Already in registered setAlready takenalready_taken

Order matters here, worth stating plainly. An input violating two rules at once, a leading symbol like "$bob", reports disallowed characters and never reaches the invalid-start check; the earlier branch always wins. And the reserved-word check runs before the already-taken lookup on purpose: rejecting "admin" by name costs nothing, so there's no reason for a database round trip confirming what the name check already ruled out.

The validator's six branches stacked in evaluation order with three example inputs descending the list, dollar-sign bob stopping at branch three for disallowed characters, admin falling past four branches to the reserved-word check at branch five, and bob underscore 2024 falling through every branch to the valid class

Each input stops at the first branch that matches it. "$bob" breaks two rules and only ever reports the earlier one; "admin" is the right length, well formed, and unclaimed, and only branch 5 rejects it.

That ordering is the part a partition table drawn from a requirements document cannot represent. The document says "must start with a letter" and "letters, digits, and underscores only" as two independent rules, and a table derived from it will happily list two separate partitions for an input that breaks both. The code decides which one an actual user sees.

Here's the partition test file that turns that table into something that runs: one case per partition, seven total, each asserting the exact reason string returned rather than a bare pass or fail, the distinction our guide to writing good test assertions argues matters more than the pass count:

import { describe, it } from "node:test";
import assert from "node:assert/strict";

import { checkUsername } from "../src/checkUsername.js";

// One username already registered, so the already_taken partition has something
// to collide with.
const existingUsernames = new Set(["alice99"]);

// One case per equivalence partition. Each case asserts the exact reason string
// rather than a bare pass or fail, because the reason is what identifies which
// partition the input landed in.
describe("one case per partition, seven total", () => {
    it("too_short: shorter than the three-character minimum", () => {
        assert.deepEqual(checkUsername("ab", existingUsernames), {
            valid: false,
            reason: "too_short",
        });
    });

    it("too_long: longer than the twenty-character maximum", () => {
        assert.deepEqual(checkUsername("a".repeat(21), existingUsernames), {
            valid: false,
            reason: "too_long",
        });
    });

    it("disallowed_characters: contains a character outside letters, digits, underscore", () => {
        assert.deepEqual(checkUsername("ab-cd", existingUsernames), {
            valid: false,
            reason: "disallowed_characters",
        });
    });

    it("invalid_start: first character is not a letter", () => {
        assert.deepEqual(checkUsername("1alice", existingUsernames), {
            valid: false,
            reason: "invalid_start",
        });
    });

    it("reserved_word: matches the reserved list, the partition no requirements document stated", () => {
        assert.deepEqual(checkUsername("admin", existingUsernames), {
            valid: false,
            reason: "reserved_word",
        });
    });

    it("already_taken: matches a username already registered", () => {
        assert.deepEqual(checkUsername("alice99", existingUsernames), {
            valid: false,
            reason: "already_taken",
        });
    });

    it("valid: satisfies every rule", () => {
        assert.deepEqual(checkUsername("bob_2024", existingUsernames), {
            valid: true,
        });
    });
});

// The branch order is part of the specification, not an implementation detail,
// so it gets assertions of its own.
describe("branch order", () => {
    it("reports disallowed_characters before invalid_start", () => {
        // "$bob" breaks both rules. The earlier branch wins.
        assert.deepEqual(checkUsername("$bob", existingUsernames), {
            valid: false,
            reason: "disallowed_characters",
        });
    });

    it("reports reserved_word before already_taken", () => {
        // "admin" is reserved and registered. The reserved check runs first, so
        // no lookup is needed to reject it.
        assert.deepEqual(checkUsername("admin", new Set(["admin"])), {
            valid: false,
            reason: "reserved_word",
        });
    });
});

Every count here agrees with what that file proves: six partitions from the document, seven once the reserved-word branch is read from the code, and every example above lands in exactly the partition it's assigned to, checked rather than assumed.

How Autonoma derives partitions from your code

Everything above, reading four sentences into six partitions, then reading six branches into seven, was done by hand for one function to make the mechanism visible. A real codebase has usernames, promo codes, shipping rules, and dozens of other validators shaped the same way, and no team re-derives partitions from every one of them by hand every time a branch changes. What tends to happen instead looks like the requirements-document version from the first section: someone writes a handful of cases from memory, and whatever that codebase's equivalent of the reserved-word check turns out to be gets covered by luck, or not at all.

That's the gap our Planner agent closes. It reads a codebase's validators, the length checks, character patterns, and lookups that decide whether an input is accepted, and derives from that reading exactly the kind of partition this article walked through by hand: a class off a length comparison, a class off a character pattern, a class off a lookup against a list that never made it into a requirements document. Verification then happens by running the actual function, or, for a full registration flow, by driving the running application in a live preview environment and checking the specific outcome each partition is supposed to produce, not a guess at what the document implies. When that validator's branches change on a later pull request, the same reading happens again against the new structure, rather than against a stale assumption about what the old one used to look like.

Map it onto the worked example directly. The document read gets six partitions and a plausible test set. The code read gets the seventh, the one a real user eventually finds by trying to register "admin" and wondering why a perfectly good, unclaimed name got rejected.

Equivalence partitioning vs boundary value analysis

Equivalence partitioning answers one question: which classes exist, and which single value from each is worth running. It doesn't say where the edges of the valid partition actually sit, only that the class from three to twenty characters is one behavior. Boundary value analysis is the sibling technique that tests exactly those edges: two characters, three characters, twenty characters, twenty-one characters, on the theory that an inequality operator (>= versus >) is one of the most common places a boundary gets implemented one integer off from what the specification intended.

The two techniques aren't competing descriptions of the same test set, and neither substitutes for the other. Partitioning says which classes exist and which reason each rejection carries. Boundary value analysis says where each class actually ends, the better place to look for the off-by-one error the length check above, three characters to twenty, hasn't actually been checked for. A username validator that only ran the six-case partition table above could still have its length comparison implemented as > instead of >=, silently rejecting a valid three-character name, and nothing in the seven-case test set above would have caught it. That's boundary value analysis's job, not partitioning's.

None of this required a bigger vocabulary than the one every testing textbook already teaches: valid partition, invalid partition, derived partition, equivalence class. Some teams call the whole exercise equivalence class testing, the same technique under a second name. What changed is which sentence in that vocabulary was ever the point. "These inputs behave the same" was always a claim about the specification, sitting underneath a sampling trick that made sense for exactly as long as running a test case was expensive. It no longer is, for most of what a form validator does. What's left is the claim, and reading it straight out of a function's branches, reserved words included, beats guessing it from a document every time, whether the document was written by a product manager or reconstructed from memory by whoever's writing the test.

Verifying that claim by asserting a specific rejection reason, rather than a bare pass or fail, is also what separates a real test from a generated test that passes without checking anything. A test that only confirms valid: false for the "admin" case has confirmed almost nothing; a test that confirms the reason is specifically reserved_word, not already_taken or any of the other five, has confirmed the actual partition. That's the specific habit Autonoma is built around: read the code first, and let the specification it implies come from there instead of from whatever a requirements document happened to remember to mention.

Frequently Asked Questions

Equivalence partitioning is a test design technique that groups a system's possible inputs into equivalence classes, sets of inputs the system is supposed to treat identically, then tests one representative value from each class instead of every value in it. Each class is either a valid partition, inputs that should be accepted, or an invalid partition, inputs that should be rejected, usually for a specific, identifiable reason.

A username registration rule requiring three to twenty characters, letters, digits, and underscores only, and a letter as the first character produces several equivalence classes: too short, too long, disallowed characters, invalid start, and the valid partition itself, plus already taken as a separate rejection reason checked once the format passes. Testing one representative input from each class, rather than every possible string, is equivalence partitioning in practice.

Equivalence partitioning identifies which classes of input exist and treats every member of a class as interchangeable for testing purposes. Boundary value analysis assumes those classes already exist and tests specifically at, and just beside, the edges between them, since an inequality operator implemented one integer off from the specification is one of the most common places a bug hides. The two techniques are usually applied together: partitioning finds the classes, and boundary value analysis finds where each one actually ends.

The traditional approach reads a requirements document or specification and infers classes from the stated rules: a length requirement implies a too-short and too-long class, an allowed-character rule implies a disallowed-character class, and so on. A more complete approach reads the validating function's actual branches directly, since a codebase frequently contains rejection reasons, like a reserved-word check, that a requirements document never mentions but that still define a real equivalence class.

Classically, equivalence partitioning is taught as a black box technique: classes are derived from a specification or observed behavior, without looking at the source code. In practice, reading the actual implementation to derive partitions, rather than guessing them from a document, is a grey box move, and it reliably finds classes a specification-only reading misses.

Yes, for the codebase-level version of the exercise this article walked through by hand. Our Planner agent reads a codebase's validators, the length checks, character patterns, and lookups that decide whether an input is accepted, and derives equivalence classes directly from those branches, including ones like a reserved-word check that a requirements document never mentions. It doesn't replace unit tests written against the validator itself. It's the layer that verifies the same rule through the running application a real user would use.

Related articles

Six test automation anti-pattern icons, including an inverted ice cream cone test pyramid, arranged around a single root-cause symbol

Test Automation Anti-Patterns: 6 Failures, 1 Root Cause

Six test automation anti-patterns, each with a diagnostic tell you can check today, and the one root cause behind every one of them.

Grey box testing shown as three cubes, white, grey and black, with a source code sheet above the grey cube and a browser window below it, both linked to the same point on its face

Why Grey Box Testing Was Never a Compromise

Grey box testing was a compromise because one person could only hold one perspective at a time. Here's what changes when a system holds both perspectives fully.

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.

A six-state order lifecycle transition table with most of its state-event cells marked invalid, showing how few of the possible moves between Created, Paid, Shipped, Delivered, Refunded, and Cancelled are actually valid

State Transition Testing Maps All 30 Moves, Valid or Not

State transition testing on a full order-lifecycle transition table: 30 state-event cells, 7 valid transitions, 23 invalid ones, and tests for the refusals.