软件可测试性是指通过测试(通常是基于运行的测试)揭示软件缺陷的容易程度。在开发设计良好的系统的成本中,至少有40%是用在了测试上。如果我们能够降低此成本,那带回的回报将是巨大的。当然,如果要对系统进行正确的测试,必须能够“控制”每个组件的内部状态及其输入,然后“观察”其输出。这通常通过使用“测试工具”进行,这是一种专门设计的软件,用于执行所测试的软件。这可能会如同在各种接口上回放已记录的数据一样简单,也可能会像测试发动机的燃烧室一样复杂。
- 可测试性战术的目标是允许在完成软件开发的一个增量后,较轻松地对软件进行测试。
所以为了提高软件的可测试性,首先我们应该遵守“高内聚,低耦合”的设计原则。
在例01中使用了单一职责原则,一个类只负责一项职责。它是我们在写代码时必须遵守的原则,他能提高类的可读性,提高系统的可维护性。变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。 需要说明的一点是单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。
1 class Animal{ 2 public void breathe(String animal){ 3 System.out.println(animal+"呼吸空气"); 4 } 5 6 public void breathe2(String animal){ 7 System.out.println(animal+"呼吸水"); 8 } 9 } 10 11 public class Client{ 12 public static void main(String[] args){ 13 Animal animal = new Animal(); 14 animal.breathe("牛"); 15 animal.breathe("羊"); 16 animal.breathe("猪"); 17 animal.breathe2("鱼"); 18 }例01
- 记录回放。
记录回放是指捕获跨接口的信息,并将其作为测试专用软件的输入。在正常操作中操作中跨一个接口的信息保存在某个存储库中,它代表来自一个组件的输出和传到一个组件的输入。记录该信息使得能够生成对其中一个组件的测试输入,并保存用于以后比较测试输出。
这个功能其实十分常见,按下Ctrl+Z就实现了一次回放的功能。回放大多离不开缓冲区处理和对变量/内存读取,它是实现对一个或一组操作的记录和演示,目的是为了便于进行回归验证,以降低手工重复操作的工作量和能在性能场景中,模拟用户真实操作而进行的前期工作。
实现回放功能需要使用到命令模式,命令模式类图如下
新的命令可以很容易地加入到系统中。由于增加新的具体命令类不会影响到其他类,因此增加新的具体命令类很容易,无须修改原有系统源代码,甚至客户类代码,满足“开闭原则”的要求,也符合可测试性战术。
例02是对风扇档位的一个控制和恢复,整个过程中,最关键部分是命令对象的封装以及控制类与具体工厂类耦合的解除。
1 public class Control 2 { 3 List<ICommand> onCommands; 4 Stack<ICommand> undoCommands; 5 Stack<ICommand> redoCommands; // 记录前一个命令, 便于 undo 6 7 public Control() 8 { 9 onCommands = new List<ICommand>(); 10 undoCommands = new Stack<ICommand>(); 11 redoCommands = new Stack<ICommand>(); 12 } 13 14 public void SetCommand(int slot, ICommand onCmd) 15 { 16 onCommands[slot] = onCmd; 17 } 18 19 public void OnButtonWasPressed(int slot) 20 { 21 if (onCommands[slot] != null) 22 { 23 onCommands[slot].execute(); 24 undoCommands.Push(onCommands[slot]); 25 } 26 } 27 28 public void UndoButtonWasPressed() // 撤销,此处用 stack 后进先出的特性 29 { 30 if (undoCommands.Count > 0) 31 { 32 ICommand cmd = undoCommands.Pop(); 33 redoCommands.Push(cmd); 34 cmd.undo(); 35 } 36 } 37 38 public void RedoButtonWasPressed() 39 { 40 if(redoCommands.Count > 0) 41 { 42 ICommand cmd = redoCommands.Pop(); 43 undoCommands.Push(cmd); 44 cmd.execute(); 45 } 46 } 47 }
- 将接口与实现分离。
将接口与实现分离允许实现的代替,以支持各种测试目的。占位实现允许在缺少被占用的组件时,对系统的剩余部分进行测试。用一个组件代替某个专门的组件能够使被代替的组件充当系统剩余部分的测试工具。有了标准的接口,进行有效的隔离,能够极大程度的减少测试员的工作量,模块化测试,单元测试极大地减少了测试过程中用例的原则,如果没有接口隔离,不管用的是极值分析法还是等价划分法,都对测试员的工作造成了极大的负担,相对而言,单元测试更加简化了工作量,让白盒测试过程中的逻辑复杂度降低了不少。
1 public interface ImageLoader { 2 3 /** 4 * 初始化ImageLoader 5 * @param appContext ApplicatonContext 6 */ 7 void init(@NonNull Context appContext); 8 9 /** 10 * 展示图片 11 * @param targetView 12 * @param uri 13 * @param listener 14 */ 15 void displayImage(@NonNull ImageView targetView, @NonNull Uri uri, @Nullable LoadListener listener); 16 17 /** 18 * 取消图片展示 19 * @param targetView 20 */ 21 void cancelDisplay(ImageView targetView); 22 23 /** 24 * 销毁ImageLoader, 回收资源 25 */ 26 void destroy(); 27 28 }
3.特化访问路线/接口。具有特化的测试接口允许通过测试工具并独立于其正常操作,来捕获或指定组件的变量值。例如,可以通过允许特化的接口提供原数据,测试工具利用该接口推动其活动。
二、内部监视
内置监视器。组件可以维持状态、性能负载、容量、安全性或其他可通过接口访问的信息。此接口可以是该组件的一个永久接口,也可以是通过instrumentation技巧临时引入的接口,如面向方面编程或预处理程序宏。一个常见的技巧就是当监视状态被激活时记录事件。监视状态实际上会增加测试工作,因为随着监视的关闭,可能必须重复测试。尽管额外测试需要一定的开销,但这却使组件活动的可见性得以提高,这样做是值得的。
1 public class ThreadDemo10 { 2 public static void main(String[] args){ 3 Buffer buffer=new Buffer(); 4 Productor p = new Productor("生产者",buffer); 5 Consumer c=new Consumer("生产者",buffer); 6 p.start(); 7 c.start(); 8 } 9 10 } 11 //生产者 12 class Productor extends Thread{ 13 private String name; 14 private Buffer buffer; 15 public Productor(String name,Buffer buffer){ 16 this.name=name; 17 this.buffer=buffer; 18 } 19 public void run(){ 20 int i=0; 21 while(true){ 22 buffer.add(i++); //生产者线程往缓冲区里面添加数 23 try{ 24 Thread.sleep(50);; 25 } 26 catch(Exception e){ 27 e.printStackTrace(); //打印栈跟踪信息 28 } 29 System.out.println("add: "+i+""); 30 } 31 } 32 } 33 //消费者 34 class Consumer extends Thread{ 35 private String name; 36 private Buffer buffer; 37 public Consumer(String name,Buffer buffer){ 38 this.name=name; 39 this.buffer=buffer; 40 } 41 public void run(){ 42 while(true){ 43 int i=buffer.remove(); //消费者从缓冲区里面取出数 44 try{ 45 Thread.sleep(100);; 46 } 47 catch(Exception e){ 48 e.printStackTrace(); 49 } 50 System.out.println("remove: "+i); 51 52 } 53 } 54 } 55 class Buffer{ 56 private java.util.List<Integer> list=new java.util.ArrayList<Integer>(); 57 private int MAX=100; 58 public void add(int n){ 59 synchronized(this){ 60 try{ 61 while(list.size()>=MAX){ 62 try{ 63 this.wait(); //当缓冲区中的数超出最大值时,调用wait()方法释放锁的监控权,线程进入等待队列 64 } 65 catch(Exception e){ 66 e.printStackTrace(); 67 } 68 69 } 70 list.add(n); 71 System.out.println("size: "+list.size()); 72 this.notify(); 73 } 74 catch(Exception e){ 75 e.printStackTrace(); 76 } 77 78 } 79 } 80 public int remove(){ 81 synchronized(this){ 82 try{ 83 while(list.size()==0){ 84 try{ 85 this.wait(); // //当缓冲区中的数为0时,调用wait()方法释放锁的监控权,线程进入等待队列 86 } 87 catch(Exception e){ 88 e.printStackTrace(); 89 } 90 } 91 int i=list.remove(0); 92 this.notify(); 93 return i; 94 } 95 catch(Exception e){ 96 e.printStackTrace(); 97 } 98 return -1; 99 } 100 } 101 }
wait()、notify()和notifyAll()方法必须在这些方法的接收对象的同步方法或同步块中调用。否则就会出现IlleagalMonitorStateException异常。
当调用wait()方法时,它终止线程同时释放对象的锁。当线程被通知之后重新启动时,锁就被重新自动获取。
考虑典型的生产者与消费者的例子。假设使用缓冲区存储整数。缓冲区的大小是受限的。缓冲区提供add(int)方法将一个int值添加到缓冲区中,还提供remove()方法从缓冲区中取出值