工作多年后我更体会到单元测试的重要性

码农沉思录

共 32279字,需浏览 65分钟

 ·

2021-03-14 19:26

对于有经验的开发写单元测试是非常有必要的,并且对自己的代码质量以及编码能力也是有提高的。单元测试可以帮助减少bug泄露,通过运行单元测试可以直接测试各个功能的正确性,bug可以提前发现并解决,由于可以跟断点,所以能够比较快的定位问题,比泄露到生产环境再定位要代价小很多。同时充足的UT是保证重构正确性的有效手段,有了足够的UT防护,才能放开手脚大胆重构已有代码,工 作多年后更了解了UT,了解了UT的重要性。

单元测试

在敏捷的开发理念中,覆盖全面的自动化测试是添加新特性和重构的必要前提。单元测试在软件开发过程中的重要性不言而喻,特别是在测试驱动开发的开发模式越来越流行的前提下,单元测试更成为了软件开发过程中不可或缺的部分。同时单元测试也是提高软件质量,花费成本比较低的重要方法。

1.单元测试的时机和测试点

1.1单元测试的时机

  1. 在业务代码前编写单元测试采用测试驱动开发,这是我们经常使用和推荐的。
  2. 在业务代码过程中进行单元测试,对重要的业务逻辑和复杂的业务逻辑进行添加测试。
  3. 在业务逻辑之后再编写测试是我们不建议的,除非对遗留代码的修改,需要先进行测试用例的添加,保证我们修改和重构后的代码不会破坏之前的业务逻辑。

1.2单元测试的测试点

  1. 在逻辑复杂的代码中添加测试。
  2. 在容易出错的地方添加测试。
  3. 不易理解的代码中添加测试,在以后看到测试就可以非常清楚代码要实现的逻辑。
  4. 在考虑后期需求变更相对较大的代码中添加测试,这样后期需求更变修改代码之后就不用太担心写的代码对不对以及是否破坏了已有代码逻辑。
  5. 外部接口处添加解耦代码、同时增加单元测试。

2.代码不可测试性的根源

  1. 代码中调用到了底层平台的接口或只有系统运行后才能获得的资源(数据库连接、发送邮件,网络通讯,远程服务, 文件系统等)但业务代码与这些资源未解耦。这样在测试代码需要创建这个类的时候会去初始化这些资源时导致无法测试。
  2. 在方法内部new一个与本次测试无关的对象。
  3. 代码依赖层次很深,逻辑复杂,一次方法的往往要调用N次底层的接口,或者类的方法非常多。这样的代码我们需要对类进行重构,尽量保证类的单一职责:这个类在系统中的意图应当是单一的,且修改它的原因应该只有一个。
  4. 使用单例类和静态方法,并且单例类和静态方法使用到了我们底层的接口或者其他接口。

3.测试工具使用和测试方法介绍

在做单元测试的时候,我们会发现我们要测试的方法会引用很多外部依赖的对象,如调用平台接口、连接数据库、网络通讯、远程服务、FTP、文件系统等等。 而我们没法控制这些外部依赖的对象,为了解决这个问题,我们就需要用到Mock工具来模拟这些外部依赖的对象,来完成单元测试。 现在比较流行的Mock工具有JMock、EasyMock、Mockito、PowerMock。我们使用的是Mockito和PowerMock。PowerMock弥补了其他3个Mock工具不能mock静态、final 、私有方法的缺点。 在下面的情况下我们可以使用Mock对象来完成单元测试。

  1. 实对象具有不可确定的行为,会产生不可预测的结果。 如:数据库查询可以查出一条记录、多条记录、或者返回数据库异常等结果。
  2. 真实对象很难被创建。如:平台代码,或者Web、JBoss容器等。
  3. 真实对象的某些行为很难触发。 如:代码中需要处理的网络异常、数据库异常、消息发送异常等。
  4. 真实情况令程序运行很慢。 在敏捷的实践中我们完成了CI,在开发提交代码前需要执行整个项目的单元测试用例,只有测试通过才可以提交代码。这就要求我们每个单元测试用例需要尽可能的短,整个项目的测试时间才会短。当有的测试用例需要测试大数据量情况下系统的预期时,就需要使用Mock对象。 如我们代码中需要判断只有当系统的缓存队列大于40000时,我们开始考虑丢弃非关键的消息,当超过48000时,需要只处理最重要的消息,当超过50000时需要丢弃全部消息。此时就需要对此缓存队列进行Mock,根据调用返回不同的数据量给测试。
  5. 测试需要知道真实对象是如何被调用的。如:测试用例需要验证是否发送了JMS,此时就可以通过Mock对象是否被调用来测试。
  6. 真实对象实际不存在时。 如:当我们与其他模块交互时,或者与新的接口打交道时,更有就是对方的代码还没有开发完毕时,我们可以通过Mock来模拟接口的行为,实现代码逻辑的验证和测试。

3.1 Mocktio简单使用说明

mock可以模拟各种各样的对象,从而代替真正的对象做出希望的响应。

1、模拟对象的创建

List cache = mock(ArrayList.class);
System.out.println(cache.get(0));
//-> null 由于没有对mock对象给预期,所以返回都是null

2、模拟对象方法调用的返回值

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn("hello");
System.out.println(cache.get(0));
//-> hello

3、模拟对象方法多次调用和多次返回值

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn("0").thenReturn("1").thenReturn("2");
System.out.println(cache.get(0));
System.out.println(cache.get(0));
System.out.println(cache.get(0));
System.out.println(cache.get(0));
//-> 0,1,2,2 如果实际调用的次数超过了预期的次数,则会一直返回最后一次的预期值。

4、模拟对象方法调用抛出异常

List cache = mock(ArrayList.class);
when(cache.get(0)).thenReturn(new Exception("Exception"));
System.out.println(cache.get(0));

5、模拟对象方法在没有返回值时也可以抛异常

List cache = mock(ArrayList.class);
doThrow(new Exception("Exception")).when(cache).clear();

6、模拟方法调用时的参数匹配

AnyInt的使用,匹配任何int参数
List cache = mock(ArrayList.class);
when(cache.get(anyInt())).thenReturn("0");
System.out.println(cache.get(0));
System.out.println(cache.get(2));
//-> 0,0

7、模拟方法是否被调用和调用的次数,预期调用了一次

List cache = mock(ArrayList.class);
cache.add("steven");
verify(cache).add("steven");

预期调用了两次入缓存,没有调用清除缓存的方法

List cache = mock(ArrayList.class);
cache.add("steven");
cache.add("steven");
verify(cache,times(2)).add("steven");
verify(cache,never()).clear();

还可以通过atLeast(int i)和atMost(int i)来替代times(int i)来验证被调用的次数最小值和最大值。【注意】Mock对象默认情况下,对于所有有返回值且没有预期过的方法,Mocktio会返回相应的默认值。

对于内置类型会返回默认值,如int会返回0,布尔值返回false。对于其他type会返回null。mock对象会覆盖整个被mock的对象,因此没有预期的方法只能返回默认值。这个在初次使用Mock时需要注意,经常会发现测试结果不对,最后才发现自己未给相应的预期。

3.2 PowerMock简单使用说明

PowerMock使用一个自定义类加载器和字节码操作来模拟静态方法,构造函数,final类和方法,私有方法,去除静态初始化器等等。 PowerMock使用简单,在类名前添加注解,在预期前调用PowerMock的mock静态类方法,其他的预期方法和Mockito类似。

@PrepareForTest(System.class)
@RunWith(PowerMockRunner.class)
public class Test 
{
@org.junit.Test
public void should_get_filed() 
{
 System.out.println(System.getProperty("myName"));
 PowerMockito.mockStatic(System.class);
 PowerMockito.when(System.getProperty("myName")).thenReturn("steven");
 System.out.println(System.getProperty("myName"));
 //->null steven
 }
}

3.3 Fake对象的使用

测试中需要模拟对象,除了常用的mock对象外,我们还会经常用到Fake对象。Mock对象是预先计划好的对象,带有各种期待,他们组成了一个关于他们期待接受的调用的详细说明。

而Fake对象是有实际可工作的实现,但是通常有一些缺点导致不适合用于产品,我们通常使用Fake对象在测试中来模拟真实的对象。 在测试中经常会发现我们需要使用系统或者平台给我们提供的接口,在测试中我们可以新创建一个类去实现此接口,然后在根据具体情况去实习此模拟类的相应方法。 

如我们创建了自己的FakeLog对象来模拟真实的日志打印,这样我们可以在测试类中使用FakeLog来代替代码中真实使用的Log类,可以通过FakeLog的方法和预期的结果比较来进行测试正确性的判断。

Fake对象和mock对象还有一个实际中使用的区别,Fake对象我们构造好后,以后所有的代码都去调用此Fake对象就可以了,不用每个类每次都要给预期。从这个角度可以看到当一个类的方法或者预期相对不变时,可以采用Fake对象,当这个类的返回信息预期变化非常不可预期时,可以采用MOCK对象。

3.4Mock服务的两种方式

(1)直接注入:用于类之间的依赖层次较多的情况,测试整个业务流程,粒度大。

ResourceServerService service = mock(ResourcePPUServerService.class);
new Processor().process(service );

(2)重写protected方法返回mock对象:用于类直接依赖于该服务的情况,测试行为的细节,粒度小。

ResourceServerService service = mock(ResourceServerService .class);
generator = new EutranAnrDeletingItemGenerator() {
    @Override
    protected ResourceServerService getService() {
        return service;
    }
}

3.5测试异常

Throwable有两个直接子类:Exception和Error

1、expcetd=SomeExecption.class

@Test(expected = AssertionError.class)
public void should_occur_assertion_error_when_emb_number_is_not_eutran_or_utran_anr_delete() throws Exception 
{
    EMBObject eMBObject = new EMBObject();
    new AnrDeleteProcessor().getAnrDeleteGenerator(EMBObject);
}

@Test(expected = NumberFormatException.class)
public void should_throw_number_format_exception_when_input_string_field_greater_255() 
{
 TransactionIDConvert.convertTransIDToLong(transactionError);
}

2、try-catch-fail只能用于Exception,Error不能用此种方式

try {
    method.invoke();
    fail();
catch (Exception e) {
 assertTrue(e.getCause() instanceof RuntimeException);
}

3.6私有方法—采用反射来调用

@Test
public void should_throw_runtime_exception_when_check_eutran_trap_data_fail() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    when(eutranAnrAddItemGenerator.getSrvCelProcessor()).thenReturn(processor);
    when(processor.validateTrapData(any(AnrItem.class), any(AnrBean.class))).thenReturn(false);

    Method method = EutranAnrAddItemGenerator.class.getDeclaredMethod("check", AnrItem.class);
    method.setAccessible(true);
    try {
        method.invoke(eutranAnrAddItemGenerator, anrAddItem);
    } catch (Exception e) {
        assertTrue(e.getCause() instanceof RuntimeException);
    }
}

4.单元测试的格式

4.1测试类结构

public class ExampleTest {
    @BeforeClass
    public static void setUp() throws Exception {
        initGlobalParameter();
        registerServices();
    }

    @Before
    public void setUp() throws Exception {
        initGlobalParameter();
        registerServices();
    }

    @After
    public void tearDown() throws Exception {
        ServiceAccess.serviceMap.clear();
        clearCache();     }

    @AfterClass
    public static void tearDown() throws Exception {
        ServiceAccess.serviceMap.clear();
        clearCache();
    }

    @Test
    public void should_get_some_result1_when_give_some_condition1{
    }

    @Test
    public void should_get_some_result2_when_give_some_condition2{
    }
}

JUnit4是JUnit框架有史以来的最大改进,其主要目标便是利用Java5的Annotation特性简化测试用例的编写。先简单解释一下什么是Annotation,这个单词一般是翻译成元数据。元数据是什么?元数据就是描述数据的数据。也就是说,这个东西在Java里面可以用来和public、static等关键字一样来修饰类名、方法名、变量名。修饰的作用描述这个数据是做什么用的,差不多和public描述这个数据是公有的一样。

  • @Before:每个测试方法执行之前都要执行一次。
  • @After:before对应,每个测试方法执行之后要执行一次。
  • @BeforeClass:在所有测试方法之前运行,只运行一次。一般在此类中申请昂贵的外部资源。父类中有@BeforeClass方法,在其子类运行之前也会运行。
  • @AfterClass:与BeforeClass对应,在所有测试结束后,释放BeforeClass中申请的资源。 注意:@Before,@After,@BeforeClass,@AfterClass 标示的方法一个类中只能各有一个
  • @Test: 告诉JUnit,该方法要作为一个测试用例来运行。

4.2测试代码的位置

在Java中一个包可以横跨两个不同的目录,所以我们的测试代码和产品代码放在同一目录中,这样维护起来更方便,测试代码和产品代码在同一个包中,这样也减少了不必要的包引起,同时在测试类中使用继承更加的方便。

4.3测试用例格式3段式

一个测试用例主体内容一般采用三段式:given-when-then

  • Given:构造测试条件;

  • When:执行待测试的方法;

  • Then:判断测试结果是否符合期望。 例如:

@Test
public void should_get_correct_result_when_add_two_numbers() {
 int a = 1;
 int b = 2;

 int c = MyMath.add(a, b);

 assertEquals(3, c);
}

4.4类名的命名方式

测试类的名称以Test结尾。从目标类的类名衍生出其单元测试类的类名。类名前加上Test后缀。 Fake(伪类)放在测试包中,使用前缀Fake。

4.5方法名的定义方式

should …do something…when…under some conditions…

例如:

should_NOT_delete_A_when_exists_B_related_with_A
should_throw_exception_when_the_parameter_is_illegal

4.6业务代码中为测试提供的方法的注解

在业务代码中为了测试而单独提供的保护方法或者其他方法,我们通过@ForTest来标注。FofTest类如下:

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE})
public @interface ForTest {
 String description() default "";
}

5.代码中涉及外部接口时,如何来编写单元测试

我们的代码涉及的模块非常众多,经常需要相互协作来完成一个功能,在此过程中经常需要使用到外部的接口、同时也为别的模块提供服务。

5.1数据库

数据库的单元测试,由于测试无法进行数据库的连接,故我们通过提取通用接口(DBManagerInterface)和FakeDBManager来实现数据库解耦。FakeDBManager可以对真实的数据库进行模拟,也就是我们通过Fake一个简单的内存数据库来模拟实际真实的数据库。 DBManager是我们的真实连接数据库的业务类。我们在测试时,是可以通过注入的方式用FakeDBManager来替换DBManager。

5.2平台接口

5.2.1 平台接口的Mock

平台中的MinosMmlPPUServerService、ResourcePPUServerService等服务接口,都可以通过mock来进行测试。需要注意的是在业务代码中需要进行相应的解耦,可以通过SET方法或者构造器来注入平台的服务类。

public class ICMEMBMessageListenerTest {
    private MinosMmlPPUServerService  minosMmlPPUServerService = mock(MinosMmlPPUServerService.class);

@Before
public void setUp() throws Exception {
    registerServices();
    icmembMessageListener = new ICMEMBMessageListener(){
    };
    when(minosMmlPPUServerService.getIp()).thenReturn("127.0.0.1");
    when(minosMmlPPUServerService.getPort()).thenReturn("80");
    when(minosMmlPPUServerService.getEmbPort()).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);
    I18nAnrOsf.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 Setresults = 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单例类测试

单例模式要点:

  1. 单例类在一个容器中只有一个实例。
  2. 单例类使用静态方法自己提供向客户端提供实例,自己拥有自己的引用。
  3. 必须向整个容器提供自己的实例。 单例类的实现方式有多种方式,如懒汉式单例、饿汉式单例、登记式单例等。我们这里采用内部类的形式来构造单例类,实现的优点是此种方式不需要给类或者方法添加锁,唯一实例的生成是由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("pci""1");
    }

    @Test
    public void should_get_attributes() {
        somePlateService = mock(SomePlateService.class);
        when(somePlateService.queryAttribue()).thenReturn(attributes);

        dataProceeor = new DataProceeor();

        CompensateData compensateData = dataProceeor.getAttributes("pci");
        assertThat(compensateData.value(), is("1"));
        assertThat(compensateData.value(), is("2"));
    }
}

运行该测试用例,发现最后一句断言没有通过: 修改最后一句断言为:assertThat(attributeValue+"", not("2")); 再次运行测试,测试用例通过。

7.2 扩展平台的部分类,实现测试的目的

模式1中的例子查询资源属性时没有设置过滤条件,事实上大多数处理都是依赖其他处理类:

public class NotificationDispatcher {
    private static Logger logger = LoggerFactory.getLogger(NotificationDispatcher.class);

    public void processMessage(String notificationMsg) {
        NotificationMsg notification = new Gson().fromJson(notificationMsg, NotificationMsg.class);
        Map<String, String> sctpInfo;
        try {
            sctpInfo = new NotificationParser().parse(notification.getMessage());
            logger.info("Parse notification xml success: " + sctpInfo);
            NotificationProcessor processor = new NotificationProcessorFactory().getProcessor(sctpInfo.get(CONFIGURATION_TYPE));
            processor.process(sctpInfo);
        } catch (Exception e) {
            logger.warn(String.format("Deal notification failed. Exception: %s", e.getMessage()), e);
        }
    }
}

在本例中,查询MOI的Filter是在getCellMoi方法内部构造出来的,我们可以尝试给getCellMoi方法编写测试用例: 测试用例没有通过,问题出在哪里呢? Debug代码发现,在getCellMoi方法内部构造出来的Filter和我们在测试代码中构造的Filter并不是同一个对象。很自然地想到为Filter类编写子类,并覆盖其equals方法。 用自定义的Filter代替平台的Filter:

public String getCellMoi(String cellName){
    Filter filter = new SelfFilter(cellName);
    return getAttributers(filter,"moi");
}

修改后测试用例运行通过。

7.3 巧用protedted方法实现测试的注入

在模式2中,由于Filter是在getCellMoi内部构造的,并且没有euqals方法,导致无法测试。还可以用别的方法对其进行改造。代码示例如下: 1.提取protected方法buildFilter()

public void processMessage(String notificationMsg) {
    UmeNotificationMsg umeNotificationMsg = new Gson().fromJson(notificationMsg, UmeNotificationMsg.class);
    Map<String, String> sctpInfo;
    try {
        sctpInfo = new NotificationParser().parse(umeNotificationMsg.getMessage());
        logger.info("Parse notification xml success: " + sctpInfo);
        NotificationProcessor processor = getNotificationProcessorFactory().getProcessor(sctpInfo.get(CONFIGURATION_TYPE));

        processor.process(sctpInfo);
    } catch (Exception e) {
        logger.warn(String.format("Deal notification failed. Exception: %s", e.getMessage()), e);
    }
}

@ForTest
protected NotificationProcessorFactory getNotificationProcessorFactory() {
     return new NotificationProcessorFactory();
}

2.在测试代码中重写getNotificationProcessorFactory方法

@Before
public void setUp() throws Exception {
    NotificationProcessorFactory notificationProcessorFactory = mock(NotificationProcessorFactory.class);
    notificationDispatcher = new NotificationDispatcher(){
        @Override
        protected NotificationProcessorFactory getNotificationProcessorFactory() {
            return notificationProcessorFactory;
        }
    };
}

运行测试,可以通过。

8、总结

UT是开发人员的利器,是开发的前置保护伞,也是写出健壮代码的有力保证,总之一句话不会写UT的开发不是好厨子

浏览 29
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报