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

عرض عملي: الاختبار أولاً على دالة السحب في البنك

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

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

Lesson Overview

This is a hands-on lesson. You have learned why we test, how a test case is shaped, how to write JUnit 5 tests, how to test exceptions, how to choose boundary inputs, and how to read failures. Now we use all of it on one real method, in a particular order: tests first, code second.

The method is the bank's withdraw. We write the tests before the method exists, watch them fail, write the simplest code that passes one, make a mistake on purpose (a classic off-by-one), read the failure, fix it, and finish with boundary tests. The whole run was done on JDK 21 with JUnit 5.12.2; the output you see is real.

This is also the preparation for the module lab, where you will write tests for your own MyArrayList, MyHashMap, and the bank's withdraw. Menus differ between IDEs, so we describe the steps in plain terms: put the test class next to the code, and run it with your IDE's run icon for the test class (or any other way your tool offers).

Learning Objectives

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

  • Turn a list of rules into a list of test names before writing the code.
  • Run tests that fail for the right reason, and say why seeing red first matters.
  • Write the simplest code that makes one test pass, then extend it test by test.
  • Read a boundary failure (< instead of <=) and fix it.
  • Add boundary tests for 0, 1, and the exact balance.
  • Name the habits to carry into the lab.

Written Explanation

Step 1: list the rules

Start with the requirement, not code. For withdraw(amount) on an account:

  1. The amount must be positive. Zero or negative is a bug in the caller: IllegalArgumentException.
  2. The amount cannot exceed the balance: InsufficientFundsException.
  3. Otherwise, the balance decreases by the amount.

Each rule becomes at least one test name. That is already a result: we know what "done" means before typing any code.

Step 2: write the first three tests, with a stub

We write a normal withdrawal, an overdraw, and a negative amount. Each test builds its own account:

java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class AccountWithdrawTest {

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

    @Test
    void withdrawSubtractsFromBalance() throws InsufficientFundsException {
        Account account = hundred();
        account.withdraw(30);
        assertEquals(70, account.getBalance(), 0.001);
    }

    @Test
    void overdrawIsRejected() {
        assertThrows(InsufficientFundsException.class, () -> hundred().withdraw(150));
    }

    @Test
    void negativeAmountIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> hundred().withdraw(-1));
    }
}

A small private helper that builds a fresh account is fine: it creates a new object each time, so the tests stay independent.

The code must compile before the tests can run, so we write an empty stub: the method exists, but does nothing. (InsufficientFundsException is the class from the exceptions module.)

java
public class Account {
    private double balance;

    public Account(String owner, double openingBalance) {
        this.balance = openingBalance;
    }

    public void withdraw(double amount) throws InsufficientFundsException {
        // not written yet
    }

    public double getBalance() {
        return balance;
    }
}

Run all tests. Real output:

text
FAILED  negativeAmountIsRejected()
   AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
FAILED  withdrawSubtractsFromBalance()
   AssertionFailedError: expected: <70.0> but was: <100.0>
FAILED  overdrawIsRejected()
   AssertionFailedError: Expected InsufficientFundsException to be thrown, but nothing was thrown.
tests: 3, passed: 0, failed: 3

All red, and for the right reasons: each failure describes the missing behavior. This matters. A test that has never failed has not proven that it can fail; seeing it red first is the proof. (That is the answer to "why write the test first?": it forces you to state the requirement, and it shows that the test can fail.)

Step 3: the simplest code that passes

Make the first test green with the simplest possible body:

java
public void withdraw(double amount) throws InsufficientFundsException {
    balance -= amount;
}
text
SUCCESSFUL  withdrawSubtractsFromBalance()
FAILED  negativeAmountIsRejected()   (nothing was thrown)
FAILED  overdrawIsRejected()         (nothing was thrown)
tests: 3, passed: 1, failed: 2

One test is green; two are still red, and they tell us what to build next. Notice that this code is obviously incomplete and the tests know it.

Step 4: the planned mistake

Now add the overdraft rule, but write the comparison the wrong way round. This is a very common slip, and we do it on purpose:

java
public void withdraw(double amount) throws InsufficientFundsException {
    if (amount < balance) {
        balance -= amount;
    } else {
        throw new InsufficientFundsException(amount, balance);
    }
}

Run the three tests: overdrawIsRejected goes green and withdrawSubtractsFromBalance stays green, while negativeAmountIsRejected is still red because we have not added that rule yet. So the overdraft rule looks done. But rule 3 says a withdrawal of exactly the balance is allowed, and nothing tests it yet. Boundaries are where you look, so add the test:

java
@Test
void withdrawingExactlyTheBalanceIsAllowed() throws InsufficientFundsException {
    Account account = hundred();
    account.withdraw(100);
    assertEquals(0, account.getBalance(), 0.001);
}

Real output:

text
FAILED  withdrawingExactlyTheBalanceIsAllowed()
   InsufficientFundsException: Cannot withdraw 100.00: balance is only 100.00
FAILED  negativeAmountIsRejected()
   AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
SUCCESSFUL  withdrawSubtractsFromBalance()
SUCCESSFUL  overdrawIsRejected()
tests: 4, passed: 2, failed: 2

Read the first failure with the routine from lesson 6. It is an exception from the code, not an assertion: the test expected the withdrawal to work, but withdraw threw InsufficientFundsException, and its message says the amount (100) equals the balance (100). The rule says that is legal, so the code is wrong. The condition amount < balance rejects the equal case. Fix the operator:

java
if (amount <= balance) {

Rerun everything: the boundary test goes green, three tests pass, and only the negative-amount test remains red. Our tests found a real off-by-one before anyone used the method.

Step 5: add the exception for bad amounts

The last red test says a negative amount must throw. Add the check first, before any change to the balance:

java
public void withdraw(double amount) throws InsufficientFundsException {
    if (amount <= 0) {
        throw new IllegalArgumentException("Withdrawal must be positive, was " + amount);
    }
    if (amount <= balance) {
        balance -= amount;
    } else {
        throw new InsufficientFundsException(amount, balance);
    }
}
text
tests: 4, passed: 4, failed: 0

All four green. The <= 0 check also covers zero, but we have not proven that yet. A rule is not covered until a test exercises it.

Step 6: boundary tests for 0, 1, and 101

Following lesson 5's table, add the remaining boundaries. Each is short:

java
@Test
void zeroAmountIsRejected() {
    assertThrows(IllegalArgumentException.class, () -> hundred().withdraw(0));
}

@Test
void smallestAmountIsAllowed() throws InsufficientFundsException {
    Account account = hundred();
    account.withdraw(1);
    assertEquals(99, account.getBalance(), 0.001);
}

@Test
void justAboveBalanceIsRejectedAndBalanceKept() {
    Account account = hundred();
    assertThrows(InsufficientFundsException.class, () -> account.withdraw(101));
    assertEquals(100, account.getBalance(), 0.001);
}

Final run:

text
tests: 7, passed: 7, failed: 0

Seven tests cover the whole rule: normal withdrawal, the boundary amounts (-1 as a representative below zero, 0, 1, the exact balance, 101), and the "rejection changes nothing" behavior. The stub-to-finish route took six small steps, each guided by a red test.

Examples

Example 1: why red first (simple)

At the stub stage, overdrawIsRejected failed with "nothing was thrown". If we had written the test after a complete withdraw, it would have been green on the first run and we would never know whether it could fail. Seeing red first tells us the test is capable of failing for the reason we care about.

Example 2: the planned mistake (practical)

Write the comparison as amount < balance and the suite does not complain until you test the exact-balance case. In real banking software that case is an everyday one: a customer closing out an account withdraws the entire balance. A boundary test costs five lines and prevents it.

Example 3: the test list as a requirement document (realistic)

The final test names read as a description of withdraw:

text
withdrawSubtractsFromBalance
withdrawingExactlyTheBalanceIsAllowed
smallestAmountIsAllowed
overdrawIsRejected
justAboveBalanceIsRejectedAndBalanceKept
zeroAmountIsRejected
negativeAmountIsRejected

A new teammate can read this list and know what the method promises, without reading its code. If someone later changes withdraw, these seven tests will say at once whether the promises still hold.

Backend Insight

Writing tests first is a habit some teams use all the time, and others use only for tricky rules. You do not have to adopt it everywhere to benefit. It is most valuable when the rule is clear but subtle (money, limits, validation), when you are fixing a bug (first write a test that reproduces it, then fix it), and when you want to make a small step with a safety net. In a backend, the same steps apply to a service method or a controller: list the rules, write the failing test, make it pass, then add boundaries.

Common Mistakes

Mistake 1: Writing all the code, then the tests

You lose the proof that the tests can fail. If you write them afterward, deliberately break the code once to see them go red.

Mistake 2: Taking a big step

Writing the whole method in one go hides which test belongs to which rule. Make the smallest change that turns one red test green.

Mistake 3: Changing the test to match the code

When the boundary test failed, the code was wrong, not the test. Check the requirement first (lesson 6).

Mistake 4: Stopping when the happy path is green

Steps 4 to 6 were all about the edges. A green happy-path test says little about 0, 1, or the exact balance.

Exercises

Level A: Foundation

A1. Why do we run the tests before writing the method body?

أظهر الحل A1

A1. To see them fail for the right reason, which proves each test can fail. A test that is green from the start has never shown that it can detect a problem.

A2. In which of the six steps do we write the code that handles a negative amount, and which test drives it?

أظهر الحل A2

A2. In step 5, driven by negativeAmountIsRejected (which had been red since step 2).

A3. What is a stub, and why do we need one here?

أظهر الحل A3

A3. A stub is a method with an empty or placeholder body, so the code compiles and the tests can run and fail. Without it the test class would not compile.

Level B: Application

B1. Account also has deposit(double amount): the amount must be positive. List the rules, then write three test names (not the code) to write first.

أظهر الحل B1

B1. Rules: a positive amount increases the balance by that amount; zero is rejected; a negative amount is rejected. Test names: depositIncreasesBalance, zeroDepositIsRejected, negativeDepositIsRejected.

B2. Write the tests for deposit from B1 in JUnit 5 form, assuming the stub exists.

أظهر الحل B2

B2.

java
@Test
void depositIncreasesBalance() {
    Account account = new Account("Sam", 100);
    account.deposit(50);
    assertEquals(150, account.getBalance(), 0.001);
}

@Test
void zeroDepositIsRejected() {
    Account account = new Account("Sam", 100);
    assertThrows(IllegalArgumentException.class, () -> account.deposit(0));
}

@Test
void negativeDepositIsRejected() {
    Account account = new Account("Sam", 100);
    assertThrows(IllegalArgumentException.class, () -> account.deposit(-5));
}

B3. Suppose the planned mistake had been amount <= 0 written as amount < 0 in the validation. Which test from step 6 would catch it, and what failure would it show?

أظهر الحل B3

B3. zeroAmountIsRejected. With amount < 0, zero would not be rejected (it would pass the first check and be handled as a withdrawal of 0), so assertThrows would fail with Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown. We did not run this particular variant, but this is the same failure form seen at the stub stage.

Level C: Challenge

C1. Do the full test-first cycle for a new method Account.canWithdraw(double amount) that returns true if the amount is positive and at most the balance, false otherwise. List the rules, the test inputs for a balance of 100, and the order in which you would make them pass.

أظهر الحل C1

C1. Rules: positive and at most the balance gives true; zero, negative, and above the balance give false. Inputs (balance 100): -1 false, 0 false, 1 true, 100 true, 101 false. Order: write all five tests, run them red against a stub that returns false, the -1, 0, and 101 tests already pass against the stub that returns false, which is a warning that they could pass for the wrong reason, so keep them but remember that only the 1 and 100 tests are red. Make those pass with amount > 0 && amount <= balance. Finally break the operator on purpose and confirm that a test turns red.

C2. Your teammate says writing tests first is slower. Give two honest arguments for it and one honest limit.

أظهر الحل C2

C2. For: it forces a clear requirement before coding, and it proves the tests can fail. Limit: it can feel slower at first, and for throwaway code or when the requirement is still unknown, an exploratory sketch may be sensible before tests.

Mini Challenge

Repeat the cycle on a small method of your own with one numeric rule (for example, a shipping fee that is free from 50 upward, or a bus fare by age). Write the rule list and at least five tests (including boundaries) before the body. Use a stub, watch the tests fail, then write the code. Make the comparison operator wrong on purpose once, and read the failure.

Check your work: every test was seen failing before it passed; at least one test sits exactly on a boundary; the failure from your planned mistake points to the line of the comparison; and you ended with all tests green.

Summary & Next Lesson

Key takeaways

  • List the rules, then turn each into test names before writing code.
  • Run the tests against a stub first: red for the right reason proves the test can fail.
  • Write the simplest code that passes one test, then extend one step at a time.
  • Boundary tests catch off-by-one comparisons such as < versus <=.
  • Read the failure, decide whether the code or the test is wrong, fix one thing, and rerun everything.

Next lesson

This is the last lesson of the module. The next step is the module lab, Test what you built, where you add JUnit 5 to your project and write at least 20 meaningful tests for MyArrayList, MyHashMap, and the bank's withdraw, covering boundaries and exceptions, and show a test catching a bug.

Self-check

  1. Why write the test before the code?
  2. What does a stub allow you to do?
  3. Which test caught the < instead of <= bug?
  4. Why is smallestAmountIsAllowed (amount 1) a useful test?
  5. After fixing a failing test, what must you rerun?
أظهر الإجابات
  1. It forces a clear requirement and proves that the test can fail.
  2. Compile the test class and run the tests before the real code exists.
  3. withdrawingExactlyTheBalanceIsAllowed.
  4. It sits on the lower boundary of the allowed range (0 is rejected, 1 is the smallest valid amount), where off-by-one mistakes happen.
  5. All tests, not only the one that failed.

References

  • JUnit documentation, Writing Tests: Assertions. Supports the assertion forms used in the tests (assertEquals with expected first, assertThrows returning the exception).
  • JUnit documentation, Test Instance Lifecycle. Supports the point that each test method gets a new test-class instance, which, together with creating a fresh Account in each test, keeps tests independent.
  • Test-first is a widely taught practice; I did not fetch a specific source defining it, so the "why" given here is my own explanation. Every step's output above was produced by compiling and running the actual stub and code on JDK 21 with JUnit 5.12.2 via a small launcher program. The Account used here is a reduced version (no owner field or deposit) so the demo stays focused on withdraw.

التطبيق

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

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

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

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