5.代码中涉及外部接口时,如何来编写单元测试
我们的代码涉及的模块非常众多,经常需要相互协作来完成一个功能,在此过程中经常需要使用到外部的接口、同时也为别的模块提供服务。
5.1数据库
数据库的单元测试,由于测试无法进行数据库的连接,故我们通过提取通用接口(DBManagerInterface)和FakeDBManager来实现数据库解耦。FakeDBManager可以对真实的数据库进行模拟,也就是我们通过Fake一个简单的内存数据库来模拟实际真实的数据库。 DBManager是我们的真实连接数据库的业务类。我们在测试时,是可以通过注入的方式用FakeDBManager来替换DBManager。
5.2平台接口
5.2.1 平台接口的Mock
平台中的服务接口,都可以通过mock来进行测试。需要注意的是在业务代码中需要进行相应的解耦,可以通过SET方法或者构造器来注入平台的服务类。
public class ListenerTest { private ServerService service = mock(ServerService.class); @Before public void setUp() throws Exception { when(service.getIp()).thenReturn("127.0.0.1"); when(service.getPort()).thenReturn("80"); when(service.getTcpPort()).thenReturn("8080"); }
此处需要注意如果用到静态变量全局唯一的,需要在使用后在 tearDown中进行清除。
5.3 文件接口的测试
我们的业务中也会出现与外部文件进行读写的代码。按照单元测试书写的原则,单元测试应该是独立的,不依赖于外部任何文件或者资源的。好的单元测试是运行速度快,能够帮助我们定位问题。所以我们普通涉及到外部文件的代码,都需要通过mock来预期其中的信息,如MOCK(I18n)文件或者properties、xml文件中的数据。 对于一些重要的文件,考虑到资源消耗不大的情况下,我们也会去为这些文件添加单元测试。需要访问真实的文件,我们第一步就需要去获取资源文件的具体位置。通过下面的FileService的getFileWorkDirectory我们可以获取单元测试运行时的根目录。
public class FileService { public static String getFileWorkDirectory() { return new StringBuilder(getFileCodeRootDirectory()).append("test").toString(); } public static String getFileCodeRootDirectory() { String userDir = System.getProperty("user.dir"); userDir = userDir.substring(0, userDir.indexOf(File.separator + "CODE" + File.separator)); StringBuilder workFilePath = new StringBuilder(userDir); workFilePath.append(File.separator).append("CODE").append(File.separator); return workFilePath.toString(); } }
我们在单元测试中可以通过传入具体的文件名称,可以在测试代码中访问真实的文件。 这种方法可以适用I18n文件,xml文件, properties文件。 我们在对I18n文件进行测试时,也可以通过Fake对象根据具体的语言来进行国际化信息的测试。具体FakeI18nWrapper的代码在第7章中给出可以参考。
@Before public void setUp() throws Exception { String i18nFilePath = FileService.getFileWorkDirectory() + "\\conf\\i18n.xml"; I18N i18N = new FakeI18nWrapper(new File(i18nFilePath), I18nLanguageType.en_US); I18nOsf.setTestingI18NInstance(i18N); }
6.单元测试中涉及多线程、单例类、静态类的处理
6.1多线程测试
通过单元测试,能较早地发现 bug 并且能比不进行单元测试更容易地修复bug。但是普通的单元测试方法(即使当彻底地进行了测试时)在查找并行 bug 方面不是很有效。这就是为什么在实验室测试没有问题,但在外场经常出现各种莫名其妙的问题。 为什么单元测试经常遗漏并行 bug?通常的说法是并行程序和Bug的问题在于它们的不确定性。但是对于单元测试目的而言,在于并行程序是非常 确定的。所以我们单元测试需要对关键的逻辑、涉及到并发的场景进行多线程测试。 多线程的不确定性和单元测试的确定的预期确实是有点矛盾,这就需要精心的设计单元测试中的多线程用例。 Junit本身是不支持普通的多线程测试的,这是因为Junit的底层实现上是用System.exit退出用例执行的。
JVM都终止了,在测试线程启动的其他线程自然也无法执行。所以要想编写多线程Junit测试用例,就必须让主线程等待所有子线程执行完成后再退出。我们一般的方法是在主测试线程中增加sleep方法,这种方法优点是简单,但缺点是不同机器的配置不一样,导致等待时间无法确定。更为高效的多线程单元测试可以使用JAVA的CountDownLatch和第三方组件GroboUtils来实现。 下面通过一个简单的例子来说明下多线程的单元测试。 测试的业务代码如下,功能是唯一事务号的生成器。
class UniqueNoGenerator { private static int generateCount = 0; public static synchronized int getUniqueSerialNo() { return generateCount++; } }
6.1.1 Sleep
private static Set results = new HashSet<>();
@Test public void should_get_unique_no() throws InterruptedException { Thread[] threads = new Thread[100]; for (int i = 0; i < threads.length; i++) { threads[i] = generateThread(); } //启动线程 Arrays.stream(threads).forEach(Thread::start); Thread.sleep(100L); assertEquals(results.size(), 100); } private Thread generateThread() { return new Thread(() -> { int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo(); results.add(uniqueSerialNo); }); }
通过Sleep来等待测试线程中的所有线程执行完毕后,再进行条件的预期。问题就是用户无法准确的预期业务代码线程执行的时间,不同的环境等待的时间也是不等的。由于需要添加延时,同时也违背了我们单元测试执行时间需要尽量短的原则。
6.1.2 ThreadGroup
private static Set<Integer> results = new HashSet<>(); private ThreadGroup threadGroup = new ThreadGroup("test"); @Test public void should_get_unique_no() throws InterruptedException { Thread[] threads = new Thread[100]; for (int i = 0; i < threads.length; i++) { threads[i] = generateThread(); } //启动线程 Arrays.stream(threads).forEach(Thread::start); while (threadGroup.activeCount() != 0) { Thread.sleep(1); } assertEquals(results.size(), 100); } private Thread generateThread() { return new Thread(threadGroup, () -> { int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo(); results.add(uniqueSerialNo); }); }
这个是通过ThreadGroup来实现多线程测试的,可以把需要测试的类放入一个线程组,同时去判断线程组中是否还有未结束的线程。测试中需要注意把新建的线程加入到线程组中。
6.1.3 CountDownLatch
private static Set<Integer> results = new HashSet<>(); private CountDownLatch countDownLatch = new CountDownLatch(100); @Test public void should_get_unique_no() throws InterruptedException { Thread[] threads = new Thread[100]; for (int i = 0; i < threads.length; i++) { threads[i] = generateThread(); } //启动线程 Arrays.stream(threads).forEach(Thread::start); countDownLatch.await(); assertEquals(results.size(), 100); } private Thread generateThread() { return new Thread(() -> { int uniqueSerialNo = UniqueNoGenerator.getUniqueSerialNo(); results.add(uniqueSerialNo); countDownLatch.countDown(); }); }
通过JAVA的CountDownLatch可以很方便的来判断,测试中的线程是否已经执行完毕。CountDownLatch是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待,我们这里是让测试主线程等待。countDown方法是当前线程调用此方法,则计数减一。awaint方法,调用此方法会一直阻塞当前线程,直到计时器的值为0。
6.2单例类测试
单例模式要点:
- 单例类在一个容器中只有一个实例。
- 单例类使用静态方法自己提供向客户端提供实例,自己拥有自己的引用。
- 必须向整个容器提供自己的实例。
单例类的实现方式有多种方式,如懒汉式单例、饿汉式单例、登记式单例等。我们这里采用内部类的形式来构造单例类,实现的优点是此种方式不需要给类或者方法添加锁,唯一实例的生成是由JAVA的内部类生成机制保证。 下面的例子构造了一个单例类,同时这个单例类我们提供了一个获取远程Cpu信息的方法。再构造一个使用类ResourceManager.java来模拟调用此单例类,同时看下我们测试ResourceManager.java过程中遇到的问题。 单例类DBManagerTools.java:
public class DbManager { private DbManager() { } public static DbManager getInstance() { return DbManagerHolder.instance; } private static class DbManagerHolder { private static DbManager instance = new DbManager(); } public String getRemoteCpuInfo(){ FtpClient ftpClient = new FtpClient("127.0.0.1","22"); return ftpClient.getCpuInfo(); } }
调用类 ResourceManager.java:
public class ResourceManager { public String getBaseInfo() { StringBuilder buffer = new StringBuilder(); buffer.append("IP=").append("127.0.0.1").append(";CPU=").append(DbManager.getInstance().getRemoteCpuInfo()); return buffer.toString(); } } 测试类 @Test public void should_get_cpu_info() { String expected = "IP=127.0.0.1;CPU=Intel"; ResourceManager resourceManager = new ResourceManager(); String baseInfo = resourceManager.getBaseInfo(); assertThat(baseInfo, is(expected)); }
从上面的描述可以看到,由于业务代码强关联了一个单例类,同时这个单例类会去通过网络获取远程机器的信息。这样我们的单元测试在运行中就会去连接网络中的服务器导致测试失败。在业务类中类似这种涉及到单例类的调用经常用到。 这种情况下我们需要修改下业务代码使代码可测。 第一种方法:提取方法并在测试类中复写。
public class ResourceManager { public String getBaseInfo() { StringBuilder buffer = new StringBuilder(); buffer.append("IP=").append("127.0.0.1").append("CPU=").append(getRemoteCpuInfo()); return buffer.toString(); } @ForTest protected String getRemoteCpuInfo() { return DbManager.getInstance().getRemoteCpuInfo(); } } @Test public void should_get_cpu_info() { String expected = "IP=127.0.0.1;CPU=Intel"; ResourceManager resourceManager = new ResourceManager(){ @Override protected String getRemoteCpuInfo() { return "Intel"; } }; String baseInfo = resourceManager.getBaseInfo(); assertThat(baseInfo, is(expected)); }
第二种方法:提取单例类中的方法为接口,然后在业务代码中通过set方法或者构造器注入到业务代码中。
public class DbManager implements ResourceService{ private DbManager() { } public static DbManager getInstance() { return DbManagerHolder.instance; } private static class DbManagerHolder { private static DbManager instance = new DbManager(); } @Override public String getRemoteCpuInfo(){ FtpClient ftpClient = new FtpClient("127.0.0.1","22"); return ftpClient.getCpuInfo(); } public interface ResourceService { String getRemoteCpuInfo(); } public class ResourceManager { private ResourceService resourceService = DbManager.getInstance(); public String getBaseInfo() { StringBuilder buffer = new StringBuilder(); buffer.append("IP=").append("127.0.0.1").append("CPU=").append(resourceService.getRemoteCpuInfo()); return buffer.toString(); } public void setResourceService(ResourceService resourceService) { this.resourceService = resourceService; } } @Test public void should_get_cpu_info() { String expected = "IP=127.0.0.1;CPU=Intel"; ResourceManager resourceManager = new ResourceManager(); DbManager mockDbManager = mock(DbManager.class); resourceManager.setResourceService(mockDbManager); when(mockDbManager.getRemoteCpuInfo()).thenReturn("Intel"); String baseInfo = resourceManager.getBaseInfo(); assertThat(baseInfo, is(expected)); }
通过上面的方法可以方便的解开业务代码对单例的强依赖,有时候我们发现我们的业务代码是静态类,这个时候你会发下第一种方法是解决不了问题的,只能通过第2中方法来实现。 通过上面的代码可以看到我们应该尽量的少用单例,在必须使用单例时可以设计接口来进行业务与单例类的解耦。
6.3静态类测试
静态类与单例类类似,也可以通过提取方法后通过复现方法来解耦,同样也可以通过服务注入的方式来实现。也可以使用PowerMock来预期方法的返回。 实际应用中如果单例类不需要维护任何状态,仅仅提供全局访问的方法,这种情况考虑可以使用静态类,静态方法比单例更快,因为静态的绑定是在编译期就进行的。 同时需要注意的是不建议在静态类中维护状态信息,特别是在并发环境中,若无适当的同步措施而修改多线程并发时,会导致坏的竞态条件。 单例与静态主要的优点是单例类比静态类更具有面向对象的能力,使用单例,可以通过继承和多态扩展基类,实现接口和更有能力提供不同的实现。 在我们开发过程中考虑到单元测试,还是需要谨慎的使用静态类和单例类。
7.代码可测性的解耦方法
在使用一些解依赖技术时,我们常常会感觉到许多解依赖技术都破坏了原有的封装性。但考虑到代码的可测性和质量,牺牲一些封装性也是可以的,封装本身也并不是最终目的,而是帮助理解代码的。下面在介绍下常用的解依赖方法。这些解依赖方法的思想都是通用的,采用控制反转和依赖注入的方式来进行。
7.1尽量减少业务代码与平台代码之间的耦合
软件开发中调用平台服务查询资源属性的典型代码:
public class DataProceeor{ private static final SomePlatFormService service = ServerService.lookup(SomePlatFormService.ID); public static CompensateData getAttributes(String name){ service.queryCompensate(name); } }
这种代码在实现上没有问题,但是无法进行单元测试(不启动软件)。因为此类加载时需要获取平台查询资源相关的服务,业务代码与平台代码存在强耦合性。 在不破坏原有功能的基础上对这段代码做如下改造
1、引入实例变量和构造器
public class DataProceeor{ private static final SomePlatformService service = ServerService.lookup(SomePlatformService.ID); private SomePlatformService _service; public DataProceeor(SomePlatformService service) { _service = service; } public DataProceeor() { _service = ServerService.lookup(SomePlatformService.ID);; } public CompensateData getAttributes(String name){ service.queryCompensate(name); } }
2、增加新方法
public CompensateData getSomeAttributes(String name){ _service.queryCompensate(name); }
3、查找代码中所有用到方法getAttributes的地方,全部替换成getSomeAttributes。
4、完成第3步后,删除已经无用的变量和方法。
5、重命名引入的变量和方法,使其符合命名规范。
public class DataProceeor{ private SomePlatformService service; public DataProceeor(SomePlatformService service){ this.service = service; } public DataProceeor() { service = ServerService.lookup(SomePlatformService.ID);; } public static CompensateData getAttributes(String name){ service.queryCompensate(name); } }
6、增加对新方法的测试用例
public class DataProcessorTest { private DataProceeor dataProceeor; private SomePlateService somePlateService; private Map<String, String> attributes; @Before public void setUp() throws Exception { attributes.put("value", "1"); } @Test public void should_get_attributes() { somePlateService = mock(SomePlateService.class); when(somePlateService.queryAttribue()).thenReturn(attributes); dataProceeor = new DataProceeor(); Data data = dataProceeor.getAttributes("value"); assertThat(data.value(), is("1")); assertThat(data.value(), is("2")); } }
运行该测试用例,发现最后一句断言没有通过: 修改最后一句断言为:assertThat(data.value(), not("2")); 再次运行测试,测试用例通过。
7.2 扩展平台的部分类,实现测试的目的
模式1中的例子查询资源属性时没有设置过滤条件,事实上大多数处理都是依赖其他处理类:
public class ClassA { public void processA () { ClassBProcessor processor new ClassBProcessor(); processor.processB(); } catch (Exception e) { logger.warn(e); } } }
在本例中,processB方法的Filter是在processA方法内部构造出来的,我们可以尝试给processA方法编写测试用例: 测试用例没有通过,问题出在哪里呢? Debug代码发现,在processA方法内部构造出来的Filter和我们在测试代码中构造的Filter并不是同一个对象。很自然地想到为Filter类编写子类,并覆盖其equals方法。 用自定义的ClassBProcessor代替平台的ClassBProcessor:
public String getClassBProcessor(){ ClassBProcessor filter = new SelfClassBProcessor(); return filter); }
修改后测试用例运行通过。
7.3 巧用protedted方法实现测试的注入
在模式2中,由于ClassBProcessor是在processA内部构造的,并且没有euqals方法,导致无法测试。还可以用别的方法对其进行改造。代码示例如下: 1.提取protected方法buildProcessor()
public class ClassA { public void processA () { ClassBProcessor processor new ClassBProcessor(); processor.processB(); } catch (Exception e) { logger.warn(e); } } } @ForTest protected ClassBProcessor buildProcessor() { return new ClassBProcessor(); }
2.在测试代码中重写buildProcessor方法
@Before public void setUp() throws Exception { private ClassBProcessor classBProcessor; ClassA classA = new ClassA(){ @Override protected ClassBProcessor buildProcessor() { return classBProcessor; } }; }
运行测试,可以通过。
8、总结
UT是开发人员的利器,是开发的前置保护伞,也是写出健壮代码的有力保证,总之一句话不会写UT的开发不是好厨子