上一章讲到“关于单元测试的常见错误观念和做法”,这一章我们通过实例讲讲“第一个单元测试”到底应该怎么做。
1. 需求
我们要测试一个银行账户类 Account 的“取款”工作单元——withdraw()方法。我们先定义这个方法的契约:
如果账户被冻结,取款将失败,并抛出 AccountLockedException 异常
如果取款金额是 0 或者负数,取款将失败,并抛出 InvalidAmountException 异常。
如果余额不足,取款将失败,并抛出 BalanceInsufficientException 异常。
如果上述情况都没发生,取款将成功,账户余额会相应扣减,并在系统中记录这一笔交易。
下面是关键的业务规则:
如果取款由于任何原因失败,账户余额不会发生任何变化。
如果取款成功,账户余额将会相应减少,并在系统中记录这笔交易。
2. 实现
2.1 被测类 Account
基于上面的契约和规则,我们编写了下面的实现(此处暂不采用 TDD,我们先写好产品代码,再编写测试):
package yang.yu.tdd.bank;
//被测对象public class Account {
//内部状态:账户是否被冻结 private boolean locked = false;
//内部状态:当前余额 private int balance = 0;
//外部依赖(协作者):记录每一笔收支 private Transactions transactions;
//用于注入外部协作者的方法 public void setTransactions(Transactions transactions) { this.transactions = transactions; }
public boolean isLocked() { return locked; }
public int getBalance() { return balance; }
//存款工作单元 public void deposit(int amount) { //失败路径1:账户被冻结时不允许存款 if (locked) { throw new AccountLockedException(); } //失败路径2:存款金额不是正数时不允许存款 if (amount <= 0) { throw new InvalidAmountException(); } //成功(快乐)路径 balance += amount; //存款成功后改变内部状态 transactions.add(this, TransactionType.DEBIT, amount); //存款成功后调用外部协作者 }
//取款工作单元 public void withdraw(int amount) { //失败路径1:账户被冻结时不允许取款 if (locked) { throw new AccountLockedException(); } //失败路径2:取款金额不是正数时不允许取款 if (amount <= 0) { throw new InvalidAmountException(); } //失败路径3:取款金额超过余额时不允许取款 if (amount > balance) { throw new BalanceInsufficientException(); } //成功(快乐)路径 balance -= amount; //取款成功后改变内部状态 transactions.add(this, TransactionType.CREDIT, amount); //取款成功后调用外部协作者 }
//冻结工作单元 public void lock() { locked = true; }
//解冻工作单元 public void unlock() { locked = false; }}
复制代码
代码说明如下:
Account 类有三个字段,其中 locked 和 balance 是两个内部状态,分别代表冻结状态和当前余额;transactions 是外部依赖(协作者),用来记录存取交易。
Account 类提供了 isLocked()和 getBalance()方法,分别将 locked 和 balance 内部状态暴露给外界。
Account 类提供了 lock()和 unlock()方法来设置 locked 内部状态,deposit()和 withdraw()来更改 balance 内部状态。
Account 类提供了 setTransactions()方法,用来注入外部依赖。
2.2 外部依赖 Transactions 接口
Transactions 接口提供了记录每一笔存款、取款交易的方法 add():
public interface Transactions { void add(Account account, TransactionType transactionType, int amount);}
复制代码
第一个参数记录交易关联的账户,第二个参数 TransactionType 是个枚举,表明是存款还是取款。第三个参数表示存取金额。
3. 单元测试
针对 withdraw()契约和业务规则,我们编写下面一组单元测试来对它进行全面测试覆盖:
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); }
//账户状态正常,取款金额小于当前余额时取款成功 @Test void shouldSuccess() { int amountOfWithdraw = 2000; account.withdraw(amountOfWithdraw); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE - amountOfWithdraw); verify(transactions).add(account, TransactionType.CREDIT, amountOfWithdraw); }
//将余额全部取完,也可以取款成功 @Test void shouldSuccessWhenWithdrawAll() { account.withdraw(ORIGINAL_BALANCE); assertThat(account.getBalance()).isEqualTo(0); verify(transactions).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE); }
//账户被冻结,取款应当失败 @Test void shouldFailWhenAccountLocked() { account.lock(); assertThrows(AccountLockedException.class, () -> { account.withdraw(2000); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, 2000); }
//取款金额是负数,取款应当失败 @Test void shouldFailWhenAmountLessThanZero() { assertThrows(InvalidAmountException.class, () -> { account.withdraw(-1); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, -1); }
//取款金额是0,应当失败 @Test void shouldFailWhenAmountEqualToZero() { assertThrows(InvalidAmountException.class, () -> { account.withdraw(0); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE); }
//余额不足,应当失败 @Test void shouldFailWhenBalanceInsufficient() { assertThrows(BalanceInsufficientException.class, () -> { account.withdraw(ORIGINAL_BALANCE + 1); }); assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE); verify(transactions, never()).add(account, TransactionType.CREDIT, ORIGINAL_BALANCE + 1); }}
复制代码
上面的测试代码采用 JUnit 5,Mockito 3 和 AssertJ 3 编写。需要在 JDK 8 以上的版本运行。
说明:
从上面每一个测试方法来看,每个测试通常都包含以下的过程:
创建被测对象。
2.设置内测对象的内部状态并注入外部依赖。对于单元测试,外部依赖应该用测试替身代替。
//用Mockito创建测试替身,它实现了Transactions接口transactions = mock(Transactions.class);//注入测试替身account.setTransactions(transactions); //调用存款方法,设置初始余额account.deposit(ORIGINAL_BALANCE); //调用冻结方法,设置冻结状态account.lock();
复制代码
3.调用被测试方法,执行测试。
account.withdraw(amountOfWithdraw);
复制代码
4.断言测试结果
成功时断言修改了内部状态并调用了外部依赖的方法:
//断言当前余额等于原有余额减去取款金额assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE - amountOfWithdraw);//断言调用了外部依赖transactions的add()方法,以account, TransactionType.CREDIT, amountOfWithdraw为参数verify(transactions).add(account, TransactionType.CREDIT, amountOfWithdraw);
复制代码
失败时断言抛出了期待的异常,余额没有减少并且没有调用外部依赖 transactions 来创建交易记录:
//断言调用被测方法后抛出AccountLockedException异常assertThrows(AccountLockedException.class, () -> { account.withdraw(2000);}); //断言余额没有减少assertThat(account.getBalance()).isEqualTo(ORIGINAL_BALANCE);//断言没有调用外部依赖的方法verify(transactions, never()).add(account, TransactionType.CREDIT, 2000);
复制代码
上面的单元测试用到了本门课程将要介绍的三大框架:
下一章将讲讲“测试哪些内容:Right-BICEP”!
评论 (6 条评论)