第一个单元测试
下面介绍第一个单元测试。
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以上的版本运行。
说明:
- 标注了@Test的方法是测试方法。方法没有返回值。一般情况下也没有参数。方法名字可以任意取,但最好能够充分表达测试意图。
- 标注了@BeforeEach的方法,会在每一个测试方法执行之前都执行一次。方法名字可以任意取。
从上面每一个测试方法来看,每个测试通常都包含以下的过程:
创建被测对象。
account = new Account();
设置内测对象的内部状态并注入外部依赖。对于单元测试,外部依赖应该用测试替身代替。
//用Mockito创建测试替身,它实现了Transactions接口 transactions = mock(Transactions.class); //注入测试替身 account.setTransactions(transactions); //调用存款方法,设置初始余额 account.deposit(ORIGINAL_BALANCE); //调用冻结方法,设置冻结状态 account.lock();
调用被测试方法,执行测试。
account.withdraw(amountOfWithdraw);
断言测试结果
成功时断言修改了内部状态并调用了外部依赖的方法:
//断言当前余额等于原有余额减去取款金额 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);
上面的单元测试用到了本门课程将要介绍的三大框架:
- JUnit用来编写测试的主体
- Mockito用来创建外部依赖的测试替身,注入到被测对象。
- AssertJ用来编写各种断言,断言单元测试的结果。虽然JUnit也包含了本身的断言库,但是内容不够丰富,形式不够优美。用AssertJ来写断言可读性等方面会好得多。