前言
在软件构造过程中关键的一步就是为将要实现的模块写下单元测试(先写specification再写测试最后进行实现的过程称作测试优先编程)。一个成熟的模块需要重写toString()
方法(以java为例,其他用于输出的方法\函数类似),为保证正确性,这些方法也要进行单元测试,然而因为这些方法主要是为了帮助程序员输出提示信息,结果字符串中的格式要求并不是十分明确。
目录
具体问题
以作者所学软件构造课的lab2为例。简单来说,需要开发一个ADT代表简单有向无环图,以边集作为内部表示,其rep如下:
public class ConcreteEdgesGraph<L> implements Graph<L> {
private final Set<L> vertices = new HashSet<>();
private final List<Edge<L>> edges = new ArrayList<>();
// Abstraction function:
// 图以集合vertices作为点集edges中所有边权大于0的元素构成的集合作为边集
// Representation invariant:
// edges中每一元素的from,to都在vertices中
// edges中每一元素的weight均>=0
// edges中不存在两元素from和to均相同
// Safety from rep exposure:
// 只有方法vertices、sources、targets会返回可变类型
// 这些方法返回时都使用了防御性复制,不会产生别名问题
//.....
}
其toString()
方法被重写如下
@Override
public String toString()
{
StringBuffer ans=new StringBuffer();
ans.append("We have "+vertices.size()+" vertices\n"+
"use method vertices() to get the set of them.\n");
ans.append("Here are the edges:\n");
for(Edge<L> e: edges)
{
if(e.getWeight()>0)
ans.append("From: "+e.getFrom()+" To: "+e.getTo()+" Weight: "+e.getWeight()+"\n");
}
checkRep();
return ans.toString();
}
将点集和边集的信息组合成一个字符串并返回。
然而注意到这个字符串后半部分为一些边的信息,这些边出现的顺序是由List决定的,然而List中的存储的边的顺序对测试用例的编写者来说应当是不可见的,而且程序员很难对其内部实际存储顺序下任何的结论。
这个问题在同实验中基于点集和Map
的图ADT的实现中问题更为明显(然而那个程序较为复杂,不适合用作示例),如果说List中存储的顺序对于程序员而言在理解内部实现原理之后能通过操作顺序做出一定的预测,Map中存储的顺序就更为微妙,如果使用HashMap,你需要计算HashCode,即使计算出了所有的HashCode,你也很难详细的知晓Map内部的存储方式。
注意:测试的编写者应当不知道ADT内部的实现,此处只是为了证明即使知道内部实现也很难确定toString会返回什么字符串
这就为toString的测试带来了问题,如何确定返回值是否满足预期呢?作者想到的方法是对toString字符串进行拆解,提取出其中的信息,并与预测结果加以比较,于是就产生了解决方案一。
方案一
作者实现了一个辅助方法,用于判断提取后的信息与预计是否相同(这里的测试是以String作为泛型的具体类型处理的):
/**
* 给定答案边集和由字符串代表的边集,判断是否相同
* @param trueEdges Map<String,Map<String,Integer>>,边集答案
* 对于一条边S,T,W,其在边集中,可用trueEdges.get(S).get(T).equals(W)表示
* @param ans 一个From: xxx To: yyy Weight: zzz格式的字符串代表边集
* @return 是否相同
*/
private boolean checkEqual(Map<String,Map<String,Integer>> trueEdges,String ans)
{
Map<String,Map<String,Integer>> ansEdges=new HashMap<>();
Scanner in=new Scanner(ans);
while(in.hasNext())
{
String nowline=in.nextLine();
String source=nowline.substring(nowline.indexOf("From: ")+6,nowline.indexOf("To: ")-1);
String target=nowline.substring(nowline.indexOf("To: ")+4,nowline.indexOf("Weight: ")-1);
int weight=Integer.valueOf(nowline.substring(nowline.indexOf("Weight: ")+8));
if(!ansEdges.containsKey(source))
ansEdges.put(source,new HashMap<String,Integer>());
ansEdges.get(source).put(target,weight);
}
in.close();
assertEquals(trueEdges,ansEdges);
return ansEdges.equals(trueEdges);
}
这个方法将预处理后的toString字符串和构成答案的Collection进行了比较,使用Scanner读入每一行信息,从中拆解出想要的部分来,并在答案Collection中进行查找。
有了这个辅助方法,对于toString的测试就简单了不少,截取其中一个较为简单的测试用例进行展示如下:
@Test
public void testToString()
{
StringBuffer groundTruth=new StringBuffer();
Graph<String> graph=emptyInstance();
graph.set("aa","bb",1);
graph.set("bb","cc",2);
groundTruth=new StringBuffer();
groundTruth.append("We have "+3+" vertices\n"+
"use method vertices() to get the set of them.\n");
groundTruth.append("Here are the edges:\n");
String ans=graph.toString();
int index=ans.indexOf("Here are the edges:\n");
assertEquals(groundTruth.toString(),ans.substring(0,index+20));
ans=ans.substring(index+20);
Map<String,Map<String,Integer>> trueEdges=new HashMap<>();
Map<String,Integer> nowTargets=new HashMap<>();
nowTargets.put("bb",1);
trueEdges.put("aa",nowTargets);
nowTargets=new HashMap<>();
nowTargets.put("cc",2);
trueEdges.put("bb",nowTargets);
assertTrue(checkEqual(trueEdges,ans));
}
其中的set方法为设置一条有向边的起终点(没有则会被创建)以及边权。之后构造了答案Collection并调用上面的辅助方法进行判断。
这个方案的缺点在于对于字符串的处理比较麻烦,需要自行数数才能将其进行拆解。
那么有没有什么好的方法简便的做到这一点呢?自然是有的,可以使用最近新学的正则表达式。
方案二
方案二实际上是使用正则表达式对方案一的一些优化
将辅助方法优化为:
/**
* 给定答案边集和由字符串代表的边集,判断是否相同
* @param trueEdges Map<String,Map<String,Integer>>,边集答案
* 对于一条边S,T,W,其在边集中,可用trueEdges.get(S).get(T).equals(W)表示
* @param ans 一个From: xxx To: yyy Weight: zzz格式的字符串代表边集
* @return 是否相同
*/
private boolean checkEqualUsingRegex(Map<String,Map<String,Integer>> trueEdges,String ans)
{
Map<String,Map<String,Integer>> ansEdges=new HashMap<>();
Scanner in=new Scanner(ans);
while(in.hasNext())
{
String nowline=in.nextLine();
Pattern regex=Pattern.compile("From: (?<source>\\w*) To: (?<target>\\w*) Weight: (?<weightString>\\d*)");
Matcher m=regex.matcher(nowline);
assertTrue(m.matches());
String source=m.group("source");
String target=m.group("target");
int weight=Integer.valueOf(m.group("weightString"));
if(!ansEdges.containsKey(source))
ansEdges.put(source,new HashMap<String,Integer>());
ansEdges.get(source).put(target,weight);
}
in.close();
assertEquals(trueEdges,ansEdges);
return ansEdges.equals(trueEdges);
}
其中主要区别在于15-20行使用的正则表达式提取字符串中的信息,其中的(?<source>\\w*)为named capturing groups,意为将正则表达式中与尖括号后面的内容相匹配的部分存储在Matcher中,可以使用Matcher.group("source")
直接得到这部分字符串,其他的target和weightString同理。
用同样的方式可以简化测试代码
@Test
public void testToStringUsingRegex()
{
Graph<String> graph=emptyInstance();
graph.set("aa","bb",1);
graph.set("bb","cc",2);
String ans=graph.toString();
Pattern regex=Pattern.compile("We have "+3+" vertices\n"+
"use method vertices\\(\\) to get the set of them\\.\n"+
"Here are the edges:\n"+
"(?<edges>((\\w| |:)*\n?)*)");
//System.out.println(regex);
Matcher m=regex.matcher(ans);
assertTrue(m.matches());
ans=m.group("edges");
//System.out.println("ans=");
//System.out.println("|"+ans+"|");
Map<String,Map<String,Integer>> trueEdges=new HashMap<>();
Map<String,Integer> nowTargets=new HashMap<>();
nowTargets.put("bb",1);
trueEdges.put("aa",nowTargets);
nowTargets=new HashMap<>();
nowTargets.put("cc",2);
trueEdges.put("bb",nowTargets);
assertTrue(checkEqualUsingRegex(trueEdges,ans));
}
这里用到的字符串"(?<edges>((\\w| |:)*\n?)*)"
比较复杂,简单解释一下:
- ?<>部分和上面说过的类似,代表提取出后面的内容存储在Matcher中
- \w代表任意字符,与[a-zA-Z_0-9]等价
- |代表或的关系
- ?代表可选,即被?的部分出现1次或者不出现
- *代表出现0次或多次
- 至于\\和\的区别,其中\\的目的是第一个\代表java字符串的转义,第二个\代表正则表达式的转义,具体可以看我的另一篇博客:java中利用正则表达式总结
可以看到使用了正则表达式之后,代码变得简洁,程序员无需再进行繁琐的字符串处理工作,尤其是不需要再数一个字符串子串有多少个字符(这一般是治疗程序员低血压的有效方法)。