第6章 敏捷功能测试原则
6.1 测试驱动开发(TDD)什么是单元测试
- 面向过程的编程:整个模块(Module),但更常见的是一个单独的函数 (Function)或过程 (Procedure)
- 面向对象的编程:一个完整的接口(Interface),上至一个类(Class),下至一个方法(Method),都可以是一个单元
编写单元测试时都遵循以下 3 步。
- 初始化对象
- 执行操作
- 验证结果
代码语言:javascript
复制
public void test check response is 200(){ //初始化对象 APIHelper apiHelper = new APIHelper 0; //执行 get 方法并获得对应代码的API结果 HttpResponse response = apiHelper.get("http://www.baidu.com/"); //验证结果 assert(response getStatus()).is(200); }
好的单元测试代码要具备以下 3 点。
- 测试代码的方法名能够体现出测试用例的内容。
- 初始化对象、执行操作和验证结果这3段之间有明显的分隔,一般使用空行进行分割
- 每个测试用例的代码行数均不多,每个测试用例只测试一个方法,测试目的是保证软件的可测试性。
什么是 TDD
测试驱动开发(Test Driven Development,TDD)
TDD 5步骤。
- 编写描述程序某方面功能的单个单元测试
- 运行单元测试,该测试会因为没有实现测试内容而失败
- 编写刚好够用的代码(最简单的方法) 使测试通过
- 重构代码,直到其符合简单性这一标准
- 随着时间的推移,重复累积单元测试
步骤
- 编写或重写自动化测试。
- 运行单元测试,查看测试是否失败,若成功,则返回第1步。
- 编写刚好能够通过测试的代码,让测试通过
- 如果测试通过,则检查全部测试是否都成功。
- 如果成功,则重构代码;如果失败,则更新或修复测试代码
- 除非有一个测试失败,否则不要写任何代码
- 定期重构,避免重复,保持代码设计的一致性和定义的唯一性。
除非存在没有通过的测试,否则不写代码
好处
- 代码更简洁,设计更好
- 代码更简单,维护成本更低
- 从一开始就较少的 Bug
- 一套全面的回归测试
案例
作为一名银行储户
我想要拥有一个储蓄账户
以便我可以存钱、取钱,并且显示当前余额
代码语言:javascript
复制
package com.Account.TDD; public class Account { }
代码语言:javascript
复制
package com.Account.TDD; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AccountTest{ @Test public void testCreateAccountTheBalanceIsZero() { //创建一个账户 Account account = new Account(); //期望getBalance 获得为0元的余额 assertEquals(0.0,account.getBalance()); } }
缺少getBalance()方法
代码语言:javascript
复制
package com.example; public class Account { public double getBalance() { return 0; } }
代码语言:javascript
复制
@Test public void testDeposit(){ // 创建一个账户对象 Account account= new Account(); // 给账户对象存入 500元 account.deposit(500.00); // 期望 getBalance方法返回500元余额 assertEquals(500.0,account.getBalance()); }
要创建 deposit 方法
代码语言:javascript
复制
package com.Account.TDD; public class Account { private double balance = 0.0; public double getBalance() { return this.balance; } public void deposit(double value) { this.balance += value; } }
如果存入负数如何?
在调用 deposit 方法时,如果是负值,就抛出IllegalDepositException (非法存款值)异常
代码语言:javascript
复制
@Test public void testDepositIllegalShouldThrowException(){ Account account = new Account(); //期待在调用deposit 方法为负值的时候抛出IlegalDepositException 异常 assertThrows(IllegalDepositException.class,0->account.deposit(-500)); assertEquals(0.0,account.getBalance());// 抛出异常也不能让余额出现问题 }
除了要抛出异常,我们还需要保持余额正确
代码语言:javascript
复制
package com.Account.TDD; public class Account { private double balance = 0.0; public double getBalance() { return this.balance; } public void deposit(double value) throws IllegalDepositException{ if(value < 0.0) throw new IllegalDepositException(); else this.balance += value; } }
代码语言:javascript
复制
package com.Account.TDD; public class IllegalDepositException extends Exception { private static final long serialVersionUID = 1L; IllegalDepositException() { super(); } IllegalDepositException(String msg) { super(msg); } }
加上处理异常
代码语言:javascript
复制
package com.Account.TDD; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AccountTest{ @Test public void testCreateAccountTheBalanceIsZero() { Account account = new Account(); assertEquals(0.0,account.getBalance()); } @Test public void testDeposit() throws IllegalDepositException { Account account = new Account(); account.deposit(500); assertEquals(500.0,account.getBalance()); } @Test public void testDepositIllegalShouldThrowException() { Account account = new Account(); assertThrows(IllegalDepositException.class,()->account.deposit(-500)); assertEquals(0.0,account.getBalance()); } }
在取款时,除了负值要抛出异常,我们还要判断余额不足时如何处理
- 拒绝:抛出IlegalWithdrawException。
- 透支:直接减去,保留负值。
- 取出可用部分,清零 balance 值。
选择第1个方案
代码语言:javascript
复制
@Test public void testWithdrawIfBalanceIsNegativeShouldThrowException() throws IllegalWithdrawException{ Account account = new Account(); assertThrows(IllegalWithdrawException.class,()->account.withdraw(500)); assertEquals(0.0,account.getBalance()); }
代码语言:javascript
复制
public class IllegalWithdrawException extends Exception{ private static final long serialVersionUID = 1L; IllegalWithdrawException() { super(); } IllegalWithdrawException(String msg) { super(msg); } }
书写withdraw方法
代码语言:javascript
复制
public void withdraw(double v) throws IllegalWithdrawException{ this.balance = 0.0; throw new IllegalWithdrawException(); }
withdraw 方法的参数也不能是负值。此时如果用同样的异常IllegalWithdrawException处理“负值”和“余额不足”2种情况,这时可以采取以下2种设计。
- 修改 IllegalWithdrawException0的实现,使用不同的 message 信息进行区分。也就是说,虽然同样是 llegalWithdrawExceptionO,但具体内容不同。
- 新建一个异常,命名为IegalBalanceException 异常,用于处理余额不足的
使用2透支:直接减去,保留负值。
代码语言:javascript
复制
@Test public void testWithdrawIfBalanceIsNegativeShouldThrowException() { Account account= new Account(); assertThrows(llegalBalanceException.class, ()->account.withdraw(500)); assertEquals(0.0,account,getBalance()); }
产品代码中加入:
代码语言:javascript
复制
package com.Account.TDD; public class IllegalWithdrawException extends Exception{ private static final long serialVersionUID = 1L; IllegalWithdrawException() { super(); } IllegalWithdrawException(String msg) { super(msg); } }
代码语言:javascript
复制
public void withdraw(double v) throws IllegalWithdrawException, lllegalBalanceException{ if(v < 0.0) throw new IllegalWithdrawException(); if (this.balance - v < 0) throw new lllegalBalanceException(); else this.balance -= v; }
修改测试并补充对取款为负值时进行测试的代码。
代码语言:javascript
复制
@Test public void testDepositThenWithdraw()throws IllegalWithdrawException, lllegalBalanceException,IllegalDepositException { Account account = new Account(); account.deposit(500); account.withdraw(300); assertEquals(200.0,account.getBalance()); } @Test public void testWithdrawIfBalanceIsNegativeShouldThrowException() throws IllegalWithdrawException{ Account account = new Account(); assertThrows(IllegalWithdrawException.class,()->account.withdraw(-500)); assertEquals(0.0,account.getBalance()); }
重构代码
问题
- 虽然能精确定义什么是非法的取钱和存钱,但非法的定义并不清晰。
- 同样地,非法余额的定义也不明确。
- 当取值为负的时候,应该抛出 NegativeValueException。
- 当余额为负的时候,应该抛出 NegativeBalanceException。
代码语言:javascript
复制
package com.Account.TDD; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AccountTest{ //测试创建帐户余额为零 @Test public void testCreateAccountTheBalanceIsZero() { Account account = new Account(); assertEquals(0.0,account.getBalance()); } //测试存款 @Test public void testDeposit() throws NegativeValueException { Account account = new Account(); account.deposit(500.00); assertEquals(500.0,account.getBalance()); } //测试存款负值应抛出异常 @Test public void testDepositNegativeValueShouldThrowException(){ Account account= new Account(); assertThrows(NegativeValueException.class, ()->account.deposit(-500)); assertEquals(0.0,account.getBalance()); } //测试提取负余额应抛出异常 @Test public void testWithdrawNegativeBalanceShouldThrowException() { Account account= new Account(); assertThrows(NegativeBalanceException.class, ()->account.withdraw(500)); assertEquals(0.0,account.getBalance()); } //测试先存后取 @Test public void testDepositThenWithdraw()throws NegativeValueException, NegativeBalanceException { Account account = new Account(); account.deposit(500); account.withdraw(300); assertEquals(200.0,account.getBalance()); } //测试取款负值应引发异常 @Test public void testWithdrawNegativeValueShouldThrowException () { Account account = new Account(); assertThrows(NegativeValueException.class, ()->account.withdraw(-500)); assertEquals(0.0,account.getBalance()); } }
代码语言:javascript
复制
package com.Account.TDD; public class Account { private double balance = 0.0; public double getBalance() { return this.balance; } public void deposit(double value) throws NegativeValueException { checkInputValue(value); this.balance += value; } public void withdraw(double value) throws NegativeValueException, NegativeBalanceException{ checkInputValue(value); if (this.balance - value < 0) throw new NegativeBalanceException(); else this.balance -= value; } private static void checkInputValue(double value) throws NegativeValueException { if (value < 0.0) throw new NegativeValueException(); } }
实际Double -> BigDecimal
模拟对象
如Mock、Stub、Fake、Spy、虚拟服务等。
“三段论”
- 创建一个模拟对象或监视 (Spy) 一个已创建的对象
- 在执行真实方法前绑定方法运行结果。
- 验证结果或方法是否被执行。
Mock 对象不能替代集成测试。
创建账户的时候生成一个 ID。
代码语言:javascript
复制
@Test public void verifyLoadAccountById () throws NegativeValueException { Account account =new Account(accountRepository); account.deposit(anyDouble); when(accountRepository.loadAccountByld(account.getld()).thenReturn(account); Account accountLoaded= accountRepository.loadAccountByld(account.getld()); assertEquals(account.getId(),accountLoaded.getld); assertEquals(account.getBalance(),accountLoaded.getBalance()); }
- 在创建账户的时候,需要保存数据库。
- 在存钱的时候,需要保存数据库。
- 在取钱的时候,需要保存数据库。
- 在抛出异常的时候,不保存数据库。
把数据库的操作对象“注入”进去,最好是使用构造函数的方式
代码语言:javascript
复制
@Test public void verifyCreateAccountWillSaveToRepository () { Account account = new Account(new AccountRepository()); }
- 在测试之前,我们要先去实现 AccountRepository 类。
- 我们希望它是一个接口,而接口是不能直接 new 的。
可通过构造一个实现 AccountRepository 接口的对象进行“模拟”?
代码语言:javascript
复制
@Test public void verifyCreateAccountWillSaveToRepository(){ AccountRepository accountRepository= mock(AccountRepository.class); Account account = new Account(accountRepository); verify(accountRepository).save(account); }
构造一个名为 AccountRepository 的接口
代码语言:javascript
复制
package com.example.account; public interface AccountRepository Boolean save(Account account); }
建立构造函数
代码语言:javascript
复制
public Account(AccountRepository accountRepository) { accountRepository.save(this); }
修改测试代码
代码语言:javascript
复制
import static org.mockito.Mockito.*; import org.junit.jupiter.api.Test; import org.mockito.Mockito; class AccountTest{ AccountRepository accountRepository= Mockito.mock(AccountRepository.class); //测试创建帐户余额为零 @Test public void testCreateAccountTheBalanceIsZero() { Account account = new Account(accountRepository); assertEquals(0.0,account.getBalance()); } //测试存款 @Test public void testDeposit() throws NegativeValueException { Account account = new Account(accountRepository); account.deposit(500.00); assertEquals(500.0,account.getBalance()); } //测试存款负值应抛出异常 @Test public void testDepositNegativeValueShouldThrowException(){ Account account= new Account(accountRepository); assertThrows(NegativeValueException.class, ()->account.deposit(-500)); assertEquals(0.0,account.getBalance()); } //测试提取负余额应抛出异常 @Test public void testWithdrawNegativeBalanceShouldThrowException() { Account account= new Account(accountRepository); assertThrows(NegativeBalanceException.class, ()->account.withdraw(500)); assertEquals(0.0,account.getBalance()); } //测试先存后取 @Test public void testDepositThenWithdraw()throws NegativeValueException, NegativeBalanceException { Account account = new Account(accountRepository); account.deposit(500); account.withdraw(300); assertEquals(200.0,account.getBalance()); } //测试取款负值应引发异常 @Test public void testWithdrawNegativeValueShouldThrowException () { Account account = new Account(accountRepository); assertThrows(NegativeValueException.class, ()->account.withdraw(-500)); assertEquals(0.0,account.getBalance()); } @Test public void verifyCreateAccountWillSaveToRepository(){ //AccountRepository accountRepository= Mockito.mock(AccountRepository.class); Account account = new Account(accountRepository); verify(accountRepository, times(1)).save(isA(account.getClass())); } }
代码语言:javascript
复制
package com.Account.TDD; public class Account { private final AccountRepository accountRepository; private double balance = 0.0; public Account(AccountRepository accountRepository) { this.accountRepository= accountRepository; this.accountRepository.save(this); } public double getBalance(){ return this.balance; } public void deposit(double value) throws NegativeValueException{ checkInputValue(value); this .balance += value; this.accountRepository.save(this); } public void withdraw(double value) throws NegativeValueException, NegativeBalanceException{ checkInputValue(value); if (this.balance -value < 0) throw new NegativeBalanceException(); else this.balance -= value; this.accountRepository.save(this); } private static void checkInputValue(double value) throws NegativeValueException{ if (value <0.0) throw new NegativeValueException(); } }
重构
代码语言:javascript
复制
… private void changeBalance(double value){ this.balance += value; this.accountRepository.save(this); } public void deposit(double value) throws NegativeValueException{ checkInputValue(value); changeBalance(value); } public void withdraw(double value) throws NegativeValueException, NegativeBalanceException{ checkInputValue(value); if (this.balance -value < 0) throw new NegativeBalanceException(); else changeBalance(-value); } …
作为一名银行储户
我想要通过账户 I 查询我的储蓄账户
以便我能够继续在我的储蓄账户上存取款
首先,我们列举出不同的场景。
- 新建空账户,显示账户 ID。
- 在存钱后根据账户 ID 读取账户,余额应该为最后一次操作后的余额
- 在取钱后根据账户ID 读取账户,余额应该为最后一次操作后的余额。
对于1新建空账户,显示账户 ID。
代码语言:javascript
复制
@Test public void verifyCreateAccountWillSaveToRepository (){ Account account = new Account(accountRepository); verify(accountRepository).save(account); assertNotNull(account.getId()); }
还没有想清楚怎么实现 ID 对象之前,可以先使用 String 类型
代码语言:javascript
复制
public String getId() { return ""; }
建立测试用例,运行失败
代码语言:javascript
复制
@Test public void verifyCreateTwoAccountsIdMustNotSame () { Account accountOne = new Account(accountRepository); Account accountTwo = new Account(accountRepository); assertNotEquals(accountOne.getId(), accountTwo.getId()); }
修改代码的实现。
代码语言:javascript
复制
public String getId(){ return UUID.randomUUID().toString(); }
(2)在存钱后根据账户 ID 读取账户,余额应该为最后一次操作后的余额
代码语言:javascript
复制
@Test public void verifyLoadAccount () throws NegativeValueException { Account account = new Account(accountRepository); account.deposit(500.00); Account accountLoaded = accountRepository.loadAccountById(account.getId()); assertEquals(account.getBalance(), accountLoaded.getBalance()); }
运行测试会抛出 NulPointerException,提示 account loaded 是空的对象,因此要构造一个对象。使用 any()让模拟对象的方法返回指定类型的任意对象。因为accoutRepository目前只是一个接口,没有任何实现,所以无法返回对象。不过,我们可以使用when()方创建一个对象。
代码语言:javascript
复制
@Test public void verifyLoadAccountById () throws NegativeValueException { Account account= new Account(accountRepository); account.deposit(anyDouble()); when(accountRepository.loadAccountById(account.getld())).thenReturn(account); Account account loaded = accountRepository.loadAccountByld(account.getId()); } …
修改产品代码
代码语言:javascript
复制
… public Account(AccountRepository accountRepository){ this.id=UUID.randomUUID().toString(); this.accountRepository= accountRepository; this.accountRepository.save(this); } … public String getId() { return this.id; }
采用自动化构建工具管理自动化测试任务
- Ant with Ivy(Ant)
- Maven
- Gradle
生成单元测试分析报告
3个主流的Java代码覆盖率统计工具
- Serenity BDD
- JCov
- JaCoCo
如果没有改动代码的需求,就不要增加单元测试
以下3 种场景就不需要进行单元测试。
- 留在系统中的未经动过的代码
- 过于简单的单元不需要测试,如某些 POJO类
- 第三方提供的库
代码覆盖率的意义
1.代码覆盖率与测试覆盖率的不同之处
代码覆盖率:覆盖代码百分率
测试覆盖率:覆盖需求百分率
插装
- 代码插装
- 运行时插装
- 中间代码插装。
2.不要被 100%的代码覆盖率欺骗
(1)100%的代码覆盖率不代表代码没有问题
(2)有些语句并没有需要覆盖的价值
有些语句不需要覆盖,如私有方法。我们需要坚持“一个实现类就有一个测试类”的法则,一个单元测试类至少应该对这个类的公共接口进行测试。
不应该和代码的实现有太耦合,代码耦合太过紧密,就会令人“厌烦”。当代码重构时单元测试就可能会因此无法再次运行
敏捷XP的专家Kent Beck也认可这一观点,测试 getter、setter 或其他简单的实现(如没有任何条件逻辑的实现)不会因此得到任何价值。
(3)100%的代码覆盖率会让人迷失目标。因此得到任何价值。
敏捷大师 Brian Marick 所述,设计初始测试套件来达到 100%的代码覆盖率是一个更糟糕的主意
Martin Fowler 曾在博客中写道:“我不时听到人们问代码覆盖率价值是什么,或者自豪地陈述他们的代码覆盖率水平。这种说法没有抓住问题的关键码覆盖率是发现代码库中未测试部分的有用工具,而代码覆盖率作为测试好坏的数字,几乎没有任何用处。”
没有断言的测试(Assertion Free Testing)
100%的目标设置会让人怀疑,那么代码覆盖率达到80%或90%以上即可。
更应该关注测试的充分性,而不是代码覆盖率
·很少有 Bug 会逃逸到生产环境
·很少会因为担心导致 Bug 而犹豫是否要更改代码。
代码覆盖率分析的价值是什么呢?它可以帮助发现代码哪些部分没有被测试,从而提高测试的充分性。