上一章讲到“关于单元测试的常见错误观念和做法”,这一章我们通过实例讲讲“第一个单元测试”到底应该怎么做。
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 条评论)