The previous chapter covered “Common misconceptions and practices about unit testing,” and this chapter will give you an example of what “first unit testing” should look like.
1. The demand
We will test the withdraw() method of a bank Account class. Let’s first define the contract for this method:
-
If the account is frozen, the withdrawal will fail and an AccountLockedException will be thrown
-
If the withdrawal amount is 0 or negative, the withdrawal will fail and InvalidAmountException will be thrown.
-
If the balance is insufficient, the withdrawal will fail, and throw BalanceInsufficientException anomalies.
-
If none of this happens, the withdrawal is successful, the account balance is reduced and the transaction is recorded in the system.
Here are the key business rules:
-
If the withdrawal fails for any reason, nothing changes to the account balance.
-
If the withdrawal is successful, the account balance is reduced and the transaction is recorded in the system.
2. Implement
2.1 Account of the Class under test
Based on the contract and rules above, we write the following implementation (TDD is not used here, we write the production code first, then we write the tests) :
package yang.yu.tdd.bank; Public Class Account {// Internal status: Whether the Account is frozen private Boolean locked =false; Private int balance = 0; // External dependencies (collaborators) : record every transaction. // The method used to inject external collaborators is public voidsetTransactions(Transactions transactions) {
this.transactions = transactions;
}
public boolean isLocked() {
return locked;
}
public int getBalance() {
returnbalance; Public void deposit(int amount) {// Fail path 1: no deposits allowed while the account is frozenif(locked) { throw new AccountLockedException(); } // Failed path 2: deposits are not allowed if the deposit amount is not positiveif(amount <= 0) { throw new InvalidAmountException(); } // Success (happy) path balance += amount; Transactions. Add (this, TransactionType.DEBIT, amount); Public void withdraw(int amount) {// Failure path 1: Do not allow withdrawals when the account is frozenif(locked) { throw new AccountLockedException(); } // Failed path 2: Withdrawals are not allowed if the amount is not positiveif(amount <= 0) { throw new InvalidAmountException(); } // Failed path 3: Withdrawals are not allowed when the amount exceeds the balanceif(amount > balance) { throw new BalanceInsufficientException(); } // Path balance -= amount; Transactions. Add (this, TransactionType.CREDIT, amount); // Freeze the unit of work public voidlock() {
locked = true; } // Unfreeze unit of work public voidunlock() {
locked = false;
}
Copy the code
} code description is as follows:Copy the code
-
The Account class has three fields. Locked and Balance are internal states representing the locked state and the current balance, respectively. Transactions are external dependencies (collaborators) that record access transactions.
-
The Account class provides isLocked() and getBalance() methods to expose the locked and Balance internal states to the outside world, respectively.
-
The Account class provides lock() and unlock() methods to set locked internal states, and deposit() and withdraw() to change the balance internal state.
-
The Account class provides the setTransactions() method for injecting external dependencies.
2.2 External Dependency Transactions Interface
The Transactions interface provides a method to record each deposit and withdrawal transaction. Add ():
public interface Transactions {
void add(Account account, TransactionType transactionType, int amount);
}Copy the code
The first parameter records the account associated with the transaction, and the second parameter, TransactionType, is an enumeration indicating whether it is a deposit or withdrawal. The third parameter represents the withdrawal amount.
Unit testing
For withdraw() contract and business rules, we write the following set of unit tests to fully test coverage:
package yang.yu.tdd.bank;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
public class AccountWithdrawTest {
private static final int ORIGINAL_BALANCE = 10000;
private Transactions transactions;
private Account account;
@BeforeEach
void setUp() { account = new Account(); transactions = mock(Transactions.class); account.setTransactions(transactions); account.deposit(ORIGINAL_BALANCE); } // The account status is normal, the withdrawal amount is less than the current balance of the withdrawal success @test voidshouldSuccess() { int amountOfWithdraw = 2000; account.withdraw(amountOfWithdraw); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE - amountOfWithdraw); verify(transactions).add(account, TransactionType.CREDIT, amountOfWithdraw); } @test voidshouldSuccessWhenWithdrawAll() { account.withdraw(ORIGINAL_BALANCE); assertThat(account.getBalance()).isEqualTo(0); verify(transactions).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE); } // The account is frozen, the withdrawal should fail @test voidshouldFailWhenAccountLocked() { account.lock(); assertThrows(AccountLockedException.class, () -> { account.withdraw(2000); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, 2000); } // If the withdrawal amount is negative, the withdrawal should fail @test voidshouldFailWhenAmountLessThanZero() { assertThrows(InvalidAmountException.class, () -> { account.withdraw(-1); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, -1); } // the amount of money is 0, should fail @test voidshouldFailWhenAmountEqualToZero() { assertThrows(InvalidAmountException.class, () -> { account.withdraw(0); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE); } // If the balance is insufficient, @test void should failshouldFailWhenBalanceInsufficient() { assertThrows(BalanceInsufficientException.class, () -> { account.withdraw(ORIGINAL_BALANCE + 1); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE + 1); }}Copy the code
The above test code was written in JUnit 5, Mockito 3, and AssertJ 3. You need to run JDK 8 or later.
Description:
-
Methods labeled @test are Test methods. Method has no return value. In general, there are no parameters. The method name can be arbitrary, but it should adequately convey the intent of the test.
-
Methods tagged with @beforeeach are executed once BeforeEach test method is executed. The method name can be arbitrary.
From each of the above test methods, each test typically includes the following procedures:
-
Create the object under test.
account = new Account();Copy the code
2. Set the internal status of the internal test object and inject external dependencies. For unit tests, external dependencies should be replaced with test surroents.
Transactions = mock(transactions.class); // Mock (transactions.class); // Mock (transactions.class); // Inject test substitute account.setTransactions(transactions); // Call the deposit method and set the initial balance account.deposit(ORIGINAL_BALANCE); // Call the freeze method and set the freeze state account.lock();Copy the code
3. Invoke the tested method and execute the test.
account.withdraw(amountOfWithdraw);Copy the code
4. Assert test results
On success, the assertion modifies the internal state and calls externally dependent methods:
AssertThat (Account.getBalance ()).isequalto (original_balance-amountofwithdraw); // assert calls the add() method of externally dependent transactions, Verify (transactions).add(Account, TransactionType.CREDIT, amountOfWithdraw, TransactionType. amountOfWithdraw);Copy the code
When this fails, the assertion throws the expected exception, the balance is not reduced, and no external dependent transactions are called to create the transaction record:
/ / assert that call measured method after throw AccountLockedException abnormal assertThrows (AccountLockedException. Class, () - > {the withdraw (2000); }); AssertThat (Account.getBalance ()).isequalto (ORIGINAL_BALANCE); Verify (transactions, never()).add(account, TransactionType.CREDIT, 2000); verify(transactions, never()).add(account, TransactionType.Copy the code
The unit tests above use the three main frameworks covered in this course:
-
JUnit is the body used to write tests
-
Mockito is used to create an external dependency test surrogate that is injected into the object under test.
-
AssertJ is used to write various assertions that assert the results of unit tests. JUnit does include its own assertion library, but it is not as rich or elegant. AssertJ is much better for assertions like readability.
The next chapter is about “What to test: Right-BICep”!