写点什么

原创 | 使用 JUnit、AssertJ 和 Mockito 编写单元测试和实践 TDD (五)第一个单元测试

发布于: 2020 年 05 月 13 日
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (五)第一个单元测试


上一章讲到“关于单元测试的常见错误观念和做法”,这一章我们通过实例讲讲“第一个单元测试”到底应该怎么做。


1. 需求


我们要测试一个银行账户类 Account 的“取款”工作单元——withdraw()方法。我们先定义这个方法的契约:


  1. 如果账户被冻结,取款将失败,并抛出 AccountLockedException 异常

  2. 如果取款金额是 0 或者负数,取款将失败,并抛出 InvalidAmountException 异常。

  3. 如果余额不足,取款将失败,并抛出 BalanceInsufficientException 异常。

  4. 如果上述情况都没发生,取款将成功,账户余额会相应扣减,并在系统中记录这一笔交易。


下面是关键的业务规则:


  1. 如果取款由于任何原因失败,账户余额不会发生任何变化。

  2. 如果取款成功,账户余额将会相应减少,并在系统中记录这笔交易。


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 的方法,会在每一个测试方法执行之前都执行一次。方法名字可以任意取。


从上面每一个测试方法来看,每个测试通常都包含以下的过程:


  1. 创建被测对象。

account = new Account();
复制代码

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);
复制代码

上面的单元测试用到了本门课程将要介绍的三大框架:


  • JUnit 用来编写测试的主体

  • Mockito 用来创建外部依赖的测试替身,注入到被测对象。

  • AssertJ 用来编写各种断言,断言单元测试的结果。虽然 JUnit 也包含了本身的断言库,但是内容不够丰富,形式不够优美。用 AssertJ 来写断言可读性等方面会好得多。


下一章将讲讲“测试哪些内容:Right-BICEP”!



发布于: 2020 年 05 月 13 日阅读数: 189
用户头像

高级架构师,技术顾问,交流公号:编程道与术 2020.04.28 加入

杨宇于2020年创立编程道与术,致力于研究领域分析与建模、测试驱动开发、架构设计、自动化构建和持续集成、敏捷开发方法论、微服务、云计算等顶尖技术领域。 了解更多公众号:编程道与术

评论 (6 条评论)

发布
用户头像
鼓励原创!
2020 年 05 月 13 日 17:18
回复
感谢支持!希望有更多朋友可以看到!相互交流!
2020 年 05 月 13 日 17:23
回复
可以了解一下这个有奖投稿活动~让更多的朋友看到您的文章https://xie.infoq.cn/article/09aa89ffd2eb838e405491023
2020 年 05 月 13 日 17:34
回复
相当于我们继续通过写作平台来发布原创文章就可以了是吗?还需要将文章上传到其他地方吗?
2020 年 05 月 13 日 18:09
回复
查看更多回复
没有更多了
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (五)第一个单元测试