如何设计单元测试代码?
设计原则
命名规范
确保测试用例名字中包括被测试的场景和期望的输出。单一职责
测试类一次仅测试一个类,测试方法一次仅测试一个方法。行为而不是方法
测试用例与被测方法并非是“一一对应”关系,我们真正想要验证的是软件的行为,而一个方法可以表现出很多种行为。
单测工具
测试桩(Stub):
public class LoggerStub implements Logger{
@Override
public void log(LogLevel logLevel, String message) {
}
public LogLevel getLogLevel() {
return LogLevel.WARN;
}
}
伪造对象(Fake):
public interface UserRepository {
void save(User user);
User findById(int id);
User findByUserName(String userName);
}
public class FakeUserRepository implements UserRepository{
private List users = new ArrayList();
@Override
public void save(User user) {
if (findById(user.getId()) == null) {
user.add(user);
}
}
@Override
public User findById(int id) {
for (User user: users) {
if (user.getId() == id) return user;
}
return null;
}
@Override
public User findByUserName(String userName) {
for (User user: users) {
if (user.getName().equals(userName)) return user;
}
return null;
}
}
测试间谍(Spy):
public String concat(String first, String second) {
return first + ":" + second;
}
public void write(Level level, String message) {
for (DLogTarget dLogTarget : targets) {
dLogTarget.write(level, message);
}
}
public class DLog {
private final DLogTarget[] targets;
public DLog(DLogTarget... targets) {
this.targets = targets;
}
public void write(Level level, String message) {
for (DLogTarget dLogTarget : targets) {
dLogTarget.write(level, message);
}
}
}
public class DLogTest {
@Test
public void writesEachMessageToAllTargets() throws Exception {
// 间谍
SpyTarget spyTarget1 = new SpyTarget();
SpyTarget spyTarget2 = new SpyTarget();
// 加入
DLog dLog = new DLog(spyTarget1, spyTarget2);
dLog.write(Level.INFO, "message");
// 断言结果
assert(spyTarget1.received(Level.INFO, "message"));
assert(spyTarget2.received(Level.INFO, "message"));
}
// 实现间谍类
private static class SpyTarget implements DLogTarget {
private List log = new ArrayList<>();
private String concatenated(Level level, String message) {
return level.getName() + ":" + message;
}
// 接受信息
@Override
public void write(Level level, String message) {
log.add(concatenated(level, message));
}
// 读取信息
boolean received(Level level, String message) {
return log.contains(concatenated(level, message));
}
}
}
模拟对象:
@RunWith(MockitoJUnitRunner.class)
public class MockUnitTest {
@Mock
Context mContext;
@Test
public void getAppNameByContext() {
when(mContext.getString(R.string.app_name)).thenReturn("MockUnitTest");
assertEquals(mContext.getString(R.string.app_name), "MockUnitTest");
}
}
单测常见问题
基本断言:
问题:断言目标不清晰直接,此代码测试目的,是为了验证文本某一行是否包含了期望的字符。
但这里使用角标、assertTrue、!= 使逻辑复杂化,以及魔数-1,使接受的人猜测,为什么要与-1比较,如果不是-1是否会出现其他问题
修改一:使用函数,尽量不使用!=等方式
修改二:此例关键问题在于抽象层次的不正确,即使用基本类型来表达更高层次的概念。当引入!=或==、魔数-1或0等的断言,就应该考虑是否是抽象层次不对。
过度断言:
public class LogFileTransformerTest {
private String expectedOutput;
private String logFile;
@Before
public void setUpBuildLogFile(){
StringBuilder lines = new StringBuilder();
lines.append("[2015-05-23 21:20:33] session-di###SID");
lines.append("[2015-05-23 21:20:33] user-id###UID");
lines.append("[2015-05-23 21:20:33] screen1");
logFile = lines.toString();
}
@Before
public void setUpBuildTransformedFile(){
StringBuilder lines = new StringBuilder();
lines.append("session-di###SID");
lines.append("user-id###UID");
lines.append("screen1");
expectedOutput = lines.toString();
}
@Test
public void testTransformationGeneratesRgiht(){
TransfermationGenerator generator = new TransfermationGenerator();
File outputFile = generator.transformLog(logFile);
Assert.assertTrue("目标文件转换后不存在!", outputFile.exists());
Assert.assertEquals("目标文件转换后不匹配!", expectedOutput, getFileContent(outputFile));
}
}
问题:这是测试TransfermationGenerator类的日志转换功能是否有效。但对于最后一个断言,expectedOutput转后失败,其包含的可能原因有多个,这违背了设计的单一职责原则,测试的意图也不明确
@Test
public void testTransformationGeneratesRgiht2(){
TransfermationGenerator generator = new TransfermationGenerator();
File outputFile = generator.transformLog(logFile);
Assert.assertTrue("目标文件转换后不匹配!", getFileContent(outputFile).contains("screen1###0"));
Assert.assertTrue("目标文件转换后不匹配!", getFileContent(outputFile).contains("screen1###51"));
}
@Test
public void testTransformationGeneratesRgiht3(){
TransfermationGenerator generator = new TransfermationGenerator();
File outputFile = generator.transformLog(logFile);
String outputString = getFileContent(outputFile);
Assert.assertTrue(outputString .indexof("screen1") > outputString.indexof(user-id###UID));
}
修改:将断言进行细分,根据不同的情况创建单独的测试方法
突显关键语句:
问题:数据的初始化和关键测试模块混淆在一起,可读性太差,需要花更多的时间才能找到关键测试代码。
修改:将初始化数据抽取,放在setUp方法中,使测试的感觉代码更加清晰突显。此示例是在同一个测试行为中表现的结构不清晰,进一步可能导致不同行为混淆在一个方法中测试
不同行为混淆:
示例一:
问题:不同的行为断言,在同一个方法里进行,违背了单一职责原则。
修改:新建不同的测试方法,对不同行为进行测试。如果不同的行为足够复杂,可创建新的类进行管理,如示例二。
示例二:
问题:一是,Configuration可在初始化中构建;二是,文件名、调试、警告、信息开关、版本号显示以及空的命令行参数列表混淆在同一个方法中测试
修改:将不同的行为抽取到单独的测试类中,继承是否会导致逻辑缺乏内聚,取决于测试类是否从基类中共享了不变的测试
逻辑缺乏内聚:
问题:初始化数据的位置,距离测试代码有200多行,不容易找到数据来源,其次,初始化数据和测试方法逻辑分散
修改:在“靠近”测试方法的位置初始化数据。
何时内联数据或逻辑?
1. 如若短小,则内联
2. 如若过长,则将其藏到工厂方法或测试数据构造器背后
3. 如果不变,则拉进单独文件
魔法数字
问题:使用魔数无法得知为什么会产生300
修改:使用常量清晰地命名
问题:在初始化setUp方法中做太多事情
过度保护:
问题:当data为空时,断言处仍旧会抛出NullPointException
可维护性
- 重复
问题:字符串重复,进而导致结构重复
修改:抽取结构方法作为替换
- 条件逻辑
问题:对于测试代码中条件语句,容易认为某个短语通过了,但实际没有执行到
修改:for循环替换成自定义断言,添加失败反馈。应尽量避免if、else、while等条件语句
- 不确定性
问题:无法得知sleep多久能保证线程执行完成,而且sleep会造成测试缓慢
修改:去除sleep,每个线程工作结束后,再通知测试线程