انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-045-L05

الحدود وفئات التكافؤ

  • الدرس 5 من 7
  • 10 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

You can now write tests. The next question is the hard one: which inputs should you test? A method like isAdult(int age) has billions of possible inputs, and you cannot test them all. You also should not pick numbers at random, because bugs do not live at random places. They cluster at the edges of rules.

This lesson teaches two simple techniques that professional testers use every day. Equivalence classes group inputs that the code treats in the same way, so you test one from each group. Boundaries are the edges between groups, where off-by-one mistakes (> instead of >=) hide. Together they give you a small, sharp set of tests.

The safety inspector does not check every bolt on a bridge; they check one of each kind, and the places where parts join. This lesson shows how to choose those places in code.

Learning Objectives

By the end of this lesson you will be able to:

  • Define an equivalence class and split a rule's inputs into classes.
  • Define a boundary and list the boundary inputs for a rule.
  • Pick test inputs for a numeric rule such as "at least 18" or "above 0 and not above the balance".
  • Choose the standard shapes for collections: empty, one item, many items, and a bad index.
  • Show with a real example why a mid-range test misses an off-by-one bug that a boundary test catches.
  • Include invalid input and not only the happy path.

Written Explanation

Equivalence classes

An equivalence class is a group of inputs that the code treats the same way. If one value in the group works, the others almost certainly do too, so testing one from each group is enough to cover the group.

Take the rule: isAdult(age) returns true from 18 up, false from 0 to 17, and rejects negative ages. There are three classes:

ClassExample inputsExpected
Negative (invalid)-1, -50IllegalArgumentException
0 to 170, 10, 17false
18 and above18, 30, 100true

Pick one representative per class (say -5, 10, and 30) and you have a first set of tests. Notice the classes were found by reading the rule, not the code. That is deliberate: tests derived from the requirement stay valid when the code changes.

Boundaries

Bugs love boundaries: the values where one class ends and the next begins. A classic mistake is writing age > 18 when the rule says "from 18", which wrongly treats 18-year-olds as children. The representatives 10 and 30 will not notice; only 18 will.

For each boundary, test the value on each side of it, and the boundary value itself:

BoundaryInputs to test
Between invalid and valid (below 0)-1 and 0
Between "child" and "adult"17 and 18

A handy rule for a numeric edge n: test n - 1, n, and n + 1, which for adjacent integers overlaps with the neighboring boundary. For isAdult that gives -1, 0, 17, 18, 19. (Often 19 is not needed once 17 and 18 are covered, but it is cheap.)

Applying it to withdraw

The withdraw rule from the exceptions module: the amount must be above 0, and not above the balance. With a balance of 100, the classes are "zero or negative (rejected)", "1 to 100 (allowed)", and "above 100 (rejected)". The boundaries sit at 0/1 and 100/101, so the inputs are:

AmountExpected
-1IllegalArgumentException
0IllegalArgumentException
1allowed; balance 99
99allowed; balance 1
100allowed; balance 0
101InsufficientFundsException

The row for 100 is the one beginners forget, and it is the one that catches >= written where > was meant.

Collections: empty, one, many, bad index

For anything that holds items (the ArrayList, HashMap, or stack you have built or used), the standard shapes are:

  • Empty: size is 0; reading from it is rejected or returns nothing.
  • One item: the smallest non-empty case; first and last item are the same.
  • Many items: enough to trigger internal work, such as an array that has to grow. Tests of your own MyArrayList should add more items than its starting capacity.
  • Bad index: the first invalid index (equal to the size, or -1).

The same four shapes apply to a map (empty, one key, many keys, missing key) and a stack (empty pop, one push/pop, many).

Test the unhappy paths

A suite that checks only valid input (the "happy path") catches the fewest bugs. For every rule, ask: what is the smallest invalid input, the largest valid input, an empty or missing value? The exception tests from lesson 4 are how you check the answers.

How many tests is enough?

You are not trying to prove correctness; you are choosing a small number of cases with a high chance of failing if the code is wrong. For a single numeric rule with one boundary, three or four tests are often enough. If you are adding tests that sit in a class you have already covered and no boundary is nearby, stop.

Examples

Example 1: isAdult (simple)

java
class AgeRulesTest {
    @Test void adultOfThirtyIsAdult()   { assertTrue(AgeRules.isAdult(30)); }
    @Test void childOfTenIsNotAdult()   { assertFalse(AgeRules.isAdult(10)); }
    @Test void seventeenIsNotAdult()    { assertFalse(AgeRules.isAdult(17)); }
    @Test void eighteenIsAdult()        { assertTrue(AgeRules.isAdult(18)); }
    @Test void zeroIsNotAdult()         { assertFalse(AgeRules.isAdult(0)); }
    @Test void negativeAgeIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> AgeRules.isAdult(-1));
    }
}

Against the correct isAdult (return age >= 18;) all six passed. Now we planted the off-by-one bug (age > 18) and ran the same tests. Real output:

text
SUCCESSFUL  childOfTenIsNotAdult()
SUCCESSFUL  adultOfThirtyIsAdult()
SUCCESSFUL  seventeenIsNotAdult()
FAILED  eighteenIsAdult()
   org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
tests: 6, passed: 5, failed: 1

(The order of the lines varies.) The two "typical" tests, 10 and 30, still pass with the bug. Only the boundary test eighteenIsAdult fails.

Example 2: the withdraw table as tests (practical)

java
class WithdrawBoundaryTest {
    private Account hundred() { return new Account("Sam", 100); }

    @Test void negativeAmountIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> hundred().withdraw(-1));
    }
    @Test void zeroAmountIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> hundred().withdraw(0));
    }
    @Test void smallestAmountIsAllowed() throws InsufficientFundsException {
        Account a = hundred();
        a.withdraw(1);
        assertEquals(99, a.getBalance());
    }
    @Test void exactlyTheBalanceIsAllowed() throws InsufficientFundsException {
        Account a = hundred();
        a.withdraw(100);
        assertEquals(0, a.getBalance());
    }
    @Test void justAboveBalanceIsRejected() {
        assertThrows(InsufficientFundsException.class, () -> hundred().withdraw(101));
    }
}

(The full class, with six tests, also has one for 99, the fifth row of the table.) A small helper method that builds an account is fine, and it keeps tests independent because each test gets a new object. Against the correct Account, all six tests passed. We then changed amount > balance to amount >= balance in a copy, and ran them:

text
FAILED  exactlyTheBalanceIsAllowed()
   InsufficientFundsException: Cannot withdraw 100.00: balance is only 100.00
      at Account.withdraw(Account.java:25)
      at WithdrawBoundaryTest.exactlyTheBalanceIsAllowed(WithdrawBoundaryTest.java:22)
tests: 6, passed: 5, failed: 1

One boundary test found the bug, and the message points at the line where it came from. Withdrawing exactly the balance is a legal and very common action, so the bug would have been visible to real customers.

Example 3: the shapes of a collection (realistic)

Using the JDK's ArrayList (the same checks apply to your own MyArrayList):

java
class ListShapeTest {
    @Test void emptyListHasSizeZero() {
        List<String> list = new ArrayList<>();
        assertEquals(0, list.size());
        assertTrue(list.isEmpty());
    }
    @Test void oneItemCanBeReadBack() {
        List<String> list = new ArrayList<>();
        list.add("a");
        assertEquals(1, list.size());
        assertEquals("a", list.get(0));
    }
    @Test void manyItemsKeepTheirOrder() {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < 100; i++) list.add(i);
        assertEquals(100, list.size());
        assertEquals(99, list.get(99));
    }
    @Test void indexEqualToSizeIsRejected() {
        List<String> list = new ArrayList<>();
        list.add("a");
        assertThrows(IndexOutOfBoundsException.class, () -> list.get(1));
    }
}

All four passed. For your own array-backed list, the "many items" test is the one that forces a resize; a bug in the copy-to-bigger-array step will show up only there, and the "index equal to size" test checks the most common off-by-one in get.

Backend Insight

Almost every backend rule has a number or a size in it: a minimum order amount, a maximum page size, a password length, a stock quantity, a limit of requests per minute. Each is a boundary, and each is a place where < and <= are one keystroke apart. Teams that write boundary tests for these rules catch the off-by-one bugs before customers do. Equivalence classes also help you talk to the people who own the rules: asking "what should happen at exactly 18?" is a boundary question, and getting it answered early is cheaper than a bug report.

Common Mistakes

Mistake 1: Testing only typical values

  • Incorrect: testing isAdult(30) and isAdult(10) and stopping.
  • Correct: add 17 and 18, the two values either side of the edge.

Mistake 2: Forgetting the boundary value itself

Testing 99 and 101 but not 100 for a balance of 100. The > versus >= bug lives exactly at 100.

Mistake 3: Testing the happy path only

  • Incorrect: never testing a negative amount, an empty list, or a missing key.
  • Correct: for each rule, include the smallest invalid input.

Mistake 4: Piling up redundant tests

Ten tests in the same class (ages 20, 21, 22, ...) add no new information. Choose by class and boundary, not by count.

Exercises

Level A: Foundation

A1. In one sentence each, define "equivalence class" and "boundary".

أظهر الحل A1

A1. An equivalence class is a group of inputs the code treats the same way, so one test from the group stands for all of them. A boundary is the edge between two classes, the place where off-by-one bugs happen.

A2. A shop gives free delivery when the order total is at least 50. List the equivalence classes of the total (assume the total cannot be negative).

أظهر الحل A2

A2. Two classes: totals below 50 (delivery charged) and totals of 50 and above (free).

A3. For the rule in A2, list the boundary inputs to test.

أظهر الحل A3

A3. 49, 50, and 51 (and possibly 0 as the smallest valid total).

Level B: Application

B1. A method isValidPasswordLength(int length) accepts lengths from 8 to 64 inclusive. List the classes and the boundary inputs.

أظهر الحل B1

B1. Classes: below 8 (invalid), 8 to 64 (valid), above 64 (invalid). Boundaries: 7, 8, 64, 65.

B2. Write JUnit tests (assertions only, using isValidPasswordLength) for the four boundary inputs 7, 8, 64, and 65.

أظهر الحل B2

B2.

java
@Test void sevenIsTooShort()   { assertFalse(Rules.isValidPasswordLength(7)); }
@Test void eightIsAccepted()   { assertTrue(Rules.isValidPasswordLength(8)); }
@Test void sixtyFourIsAccepted() { assertTrue(Rules.isValidPasswordLength(64)); }
@Test void sixtyFiveIsTooLong() { assertFalse(Rules.isValidPasswordLength(65)); }

B3. A list class has get(int index). List the inputs you would test for a list holding three items.

أظهر الحل B3

B3. Index 0 (first), 2 (last), 3 (equal to size, rejected), and -1 (rejected). Optionally a middle one, 1.

Level C: Challenge

C1. A method discount(int quantity) returns 0 for 1 to 9 items, 10 for 10 to 99 items, and 20 for 100 or more; it rejects quantity below 1. Give a complete list of test inputs with expected outputs, and say how many tests you chose and why.

أظهر الحل C1

C1. Classes: below 1 (rejected), 1 to 9, 10 to 99, 100 and above. Inputs: 0 (rejected), 1 (0), 9 (0), 10 (10), 99 (10), 100 (20); optionally -1 (rejected) and 150 (20) as one representative from the top class. That is 6 core tests, chosen because each boundary is covered on both sides; a test for 50 would add no new information because it falls in the same class as 10 and 99.

C2. A teammate's implementation of isAdult passes the tests for ages 10 and 30 but is wrong. Describe two different bugs it could have, and say which extra test inputs would catch each.

أظهر الحل C2

C2. Possible bug 1: age > 18 (misses the boundary). Add 18 (expect true). Possible bug 2: age >= 17 or a wrong threshold in the other direction. Add 17 (expect false). A third: negative ages are accepted instead of rejected; add -1 (expect an exception).

Mini Challenge

Choose a rule with a threshold from your earlier work or from daily life (a bus fare by age, a speeding limit, a password length). Write it as a sentence, list its classes, and list the boundary inputs. Implement the rule in a small method, write one JUnit test for each boundary input, and then plant an off-by-one bug in the method.

Check your work: every boundary value is tested, including the boundary itself; at least one test fails with the bug planted; and the tests for "typical" values (if you wrote any) still pass, showing why they were not enough.

Summary & Next Lesson

Key takeaways

  • An equivalence class is a group of inputs treated the same way: test one from each.
  • Boundaries are the edges between classes, where off-by-one bugs hide: test both sides and the edge itself.
  • For collections, test empty, one item, many items, and a bad index.
  • Always include invalid input; the happy path alone catches too little.
  • Derive the cases from the rule, not from the code.

Next lesson

The next lesson, "Reading test failures", teaches you to read the red results that good boundary tests will sometimes produce, and to find the cause quickly.

Self-check

  1. What is an equivalence class?
  2. For "withdraw amount must not exceed the balance of 100", why test 100 and 101?
  3. Which four shapes do you test for a collection?
  4. Why did the tests for ages 10 and 30 miss the age > 18 bug?
  5. Why derive test cases from the rule rather than from the code?
أظهر الإجابات
  1. A group of inputs that the code treats the same way.
  2. They sit on either side of the boundary, where off-by-one bugs happen: 100 must be allowed and 101 rejected.
  3. Empty, one item, many items, and a bad index.
  4. Both are far from the edge, so the faulty comparison gives the right answer for them; only 18 exposes it.
  5. Cases derived from the rule remain valid when the code changes and are not biased by the code's own mistakes.

References

  • JUnit documentation, Writing Tests: Assertions. Supports the assertion style used in the tests (expected value first).
  • Equivalence partitioning and boundary value analysis are standard software-testing techniques. I did not fetch a specific textbook or standards page for them in this session, so the definitions here are my own plain-language paraphrase of the common usage, not a citation.
  • All tests and their output were compiled and run on JDK 21 with JUnit 5.12.2 and the Account from the exceptions module (BEG-029). The off-by-one versions of isAdult and withdraw were deliberate copies made for the demonstration.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.