Android单元测试 - 如何开始?

ShastaMwd 8年前
   <p><img src="https://simg.open-open.com/show/5e272d10af66d77d684a36610f2b5286.jpg"></p>    <h2><strong>基本单元测试框架</strong></h2>    <p>Java单元测试框架: <strong>Junit、Mockito、Powermockito</strong> 等;Android: <strong>Robolectric、AndroidJUnitRunner、Espresso</strong> 等。</p>    <p>最开始建议先学习 <strong>Junit & Mockito</strong> 。这两款框架是java领域应用非常普及,使用简单,网上文章非常多,官网的说明也很清晰。junit运行在 <strong>jvm</strong> 上,所以只能测试纯java,若要测试依赖android库的代码,可以用mockito <strong>隔离依赖</strong> (下面会谈及)。</p>    <p><a href="/misc/goto?guid=4958544710893397957" rel="nofollow,noindex">Junit官网</a></p>    <p><a href="/misc/goto?guid=4958864743859781664" rel="nofollow,noindex">Mockito官网</a></p>    <p>之后学习 <strong>AndroidJUnitRunner</strong> ,Google官方的android单元测试框架之一,使用跟 <strong>Junit</strong> 是一样的,只不过需要运行在android真机或模拟器环境。由于 <strong>mockito</strong> 只在 <strong>jvm</strong> 环境生效,而android是运行在 <strong>Dalvik</strong> 或 <strong>ART</strong> ,所以 <strong>AndroidJUnitRunner不能使用mockito</strong> 。</p>    <p>然后可以尝试 <strong>Robolectric & Espresso</strong> 。 <strong>Robolectric</strong> 运行在 <strong>jvm</strong> 上,但是框架本身引入了android依赖库,所以可以做android单元测试,运行速度比运行在真机or模拟器快。但Robolectric也有局限性,例如不支持加载so,测试代码也有点别扭。当然,robolectric可以配合junit、mockito使用。 <strong>Espresso</strong> 也是Google官方的android单元测试框架之一,强大就不用说了,测试代码非常简洁。Espresso本身运行在真机上,因此android任何代码都能运行,不像junit&mockito那样隔离依赖。缺点也是显而易见,由于运行在真机,不能避免 <strong>“慢”</strong> 。</p>    <p><a href="/misc/goto?guid=4958851014005189476" rel="nofollow,noindex">Robolectric官网</a></p>    <p><a href="/misc/goto?guid=4959654206426585278" rel="nofollow,noindex">Android-testing-support-library官网</a></p>    <p>其实espresso应该是几款框架中最简单的,但笔者还是建议先学习junit&mockito。因为新手很可能会因为espresso的强大、简单,而忽略了junit做单元测试带来的巨大意义。例如,前文提到 <strong>“快速定位bug”、“提高代码质量”</strong> ,espresso慢,有违“快速”;用espresso不用修改工程任何代码,这不利于提高代码质量。</p>    <p>本文主要介绍 <strong>junit</strong> 和 <strong>mockito</strong> ,以及单元测试一些重要概念。</p>    <h2><strong>Junit</strong></h2>    <p>先给大家上两段代码压压惊:</p>    <pre>  <code class="language-java">public class Calculater {      public int add(int a, int b) {          return a + b;      }  }</code></pre>    <p><strong>AssertEquals</strong></p>    <p>单元测试用例:</p>    <pre>  <code class="language-java">public class CalculaterTest {        Calculater calculater = new Calculater();        @org.junit.Test      public void testAdd() {          int a = 1;          int b = 2;            int result = calculater.add(a, b);            Assert.assertEquals(result, 3); // 验证result==3,如果不正确,测试不通过      }  }</code></pre>    <p>以上是一个要测试的类 Calculater 和测试用例 CalculaterTest 。在 Intellij 或 Android Studio 对类 右键 -> run CalculaterTest ,用例中所有被 @org.junit.Test 注解的方法,就会被执行。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6031b065788bb2d37884ac8247d0296e.png"></p>    <p><img src="https://simg.open-open.com/show/14ed707e543a859a190d4f51454d0967.png"></p>    <p>测试通过。</p>    <p>如果代码改成 Assert.assertEquals(result, 4); ,测试会失败。</p>    <p><img src="https://simg.open-open.com/show/bac59cbfc8bdad1f4b035dc4734082e3.png"></p>    <p><strong>Verify</strong></p>    <p>verify 的作用,是验证函数是否被调用(以及调用了多少次)。</p>    <pre>  <code class="language-java">public class CalculaterTest {        @org.junit.Test      public void testAdd2() {          calculater = mock(Calculater.class);                    calculater.add(1, 2);            verify(calculater).add(1, 2); // 验证calculater.add(a, b)是否被调用过,且a==1 && b==2          // 测试通过      }  }</code></pre>    <p>是不是很简单?</p>    <h2><strong>Mockito</strong></h2>    <p>官网这样描述:</p>    <p>Mockito is a mocking framework that tastes really good. It lets you write beautiful tests with a clean & simple API.</p>    <p>大概意思是, <strong>Mockito</strong> 是一个体验很好的mocking框架,它可以让你写出漂亮、简洁的测试代码。</p>    <p>什么是mocking?下文会详细说明。不如先让你感受一下mockito代码:</p>    <pre>  <code class="language-java">public interface IMathUtils {      public int abs(int num); // 求绝对值  }</code></pre>    <pre>  <code class="language-java">import static org.mockito.Mockito.mock;  import static org.mockito.Mockito.when;    public class MockTest {        public static void main(String[] args) {          IMathUtils mathUtils = mock(IMathUtils.class); // 生成mock对象            when(mathUtils.abs(-1)).thenReturn(1); // 当调用abs(-1)时,返回1            int abs = mathUtils.abs(-1); // 输出结果 1                    Assert.assertEquals(abs, 1);// 测试通过      }  }</code></pre>    <p>可以发现 IMathUtils 是一个接口,根本就没有实现,用 <strong>Mockito</strong> 框架 mock 之后, IMathUtils.abs(-1) 就有 返回值1 了。这就是Mockito神奇的地方!Mockito代理了 IMathUtils.abs(num) 的行为,只要调用时符合 <strong>指定参数</strong> (代码中指定参数 -1 ),就可以得到 <strong>映射的返回值</strong> 。</p>    <p>Mockito的语法 when...thenReturn... 相当直观,只要你小学有学英语^_^都能看懂。</p>    <p>读者肯定认为Mockito用了Java代理,实际上要更高级一点,Mockito底层用了 <strong>CGLib</strong> ( <a href="/misc/goto?guid=4959714419765489795" rel="nofollow,noindex">github/cglib</a> )做动态代理。</p>    <h2><strong>依赖隔离</strong></h2>    <p>依赖隔离,这是单元测试中一个 <strong>非常重要的概念</strong> 。一个单元的代码,通常会有各种依赖。写单元测试时,应该把这些依赖隔离,让每个单元保持独立。举个例子:</p>    <pre>  <code class="language-java">public class Calculater {        public double divide(int a, int b) {          // 检测被除数是否为0          if (MathUtils.checkZero(b)) {              throw new RuntimeException("dividend is zero");          }            return (double) a / b;      }  }</code></pre>    <pre>  <code class="language-java">public class MathUtils {      public static boolean checkZero(int num) {          return num == 0;      }  }</code></pre>    <p>divide(a,b) 计算 a除以b ,但 被除数b 不应该为0,所以用 MathUtils.checkZero(b) 验证 b==0 。咋看这里好像没什么问题,但是,如果 MathUtils.checkZero 里面的判断逻辑写错呢?例如:</p>    <pre>  <code class="language-java">public static boolean checkZero(int num) {      return  num != 0; // bug   }</code></pre>    <p>如果不是 num==0 那么简单,而是更复杂的算法呢?</p>    <p>因为 Calculater 引用的任何依赖,都可能出错。 <strong>更糟糕的是</strong> ,如果用 <strong>junit</strong> 做单元测试,依赖里面可能是 <strong>Android库</strong> 或者 <strong>jni native</strong> 方法,依赖方法一执行就会报错。以上的各种原因,都会 <strong>影响单元测试的结果</strong> 。所以,我们对代码做如下改进:</p>    <pre>  <code class="language-java">public class Calculater {        IMathUtils mathUtils;            public double divide(int a, int b) {          if (mathUtils.checkZero(b)) {              throw ...          }          return (double) a / b;      }  }</code></pre>    <pre>  <code class="language-java">public interface IMathUtils {      public boolean checkZero(int num);  }</code></pre>    <p>我们可以在 Calculater 构造方法传入 IMathUtils 派生类,又或者用 setter 。在项目执行代码中,传 MathUtils ,而单元测试时,可以写一个 MathUtilsTest 继承 IMathUtils ,传给 Calculater 。只要保证 MathUtilsTest.checkZero() 正确就行。经过这么重构, Calculater 就不依赖原来的 MathUtils ,单元测试时可以替换专门的实现,达到了 <strong>依赖隔离的目的</strong> 。</p>    <p>有同学会问,这样岂不是每个依赖都要写一个专门给单元测试的类吗?这就等于拷贝多一份代码,并且写各种接口,而且不能保证单元测试的类一定正确。</p>    <p>说得很有道理。笔者为了尽量简单地演示代码,举了一个非常简单的例子。我们如何让单元测试更简洁,并且让它阅读起来更有意义呢?</p>    <h3><strong>Mock</strong></h3>    <p>为了更好地解决上述问题,我们引入 <strong>Mock</strong> 概念。 <strong>Mock</strong> ,翻译为模拟,在单元测试 <strong>mock</strong> 可以 <strong>模拟返回数据,也可以模拟接口、类的行为</strong> 。</p>    <p>什么是 <strong>模拟行为</strong> ?例如刚才 mathUtils.checkZero(b) ,意义为:“当 mathUtils 调用 checkZero(num) ”时,判断 num==0 ;又或者:“当调用 checkZero(0) 时返回 true , num 为其他值时返回 false ”,返回的 true、false 就是 <strong>模拟数据</strong> 。</p>    <p>例如,需要测试 a=2,b=1 和 a=2,b=0 调用 divide(a,b) 两者结果分别是 2,抛出错误 ,使用 <strong>mockito</strong> 框架 <strong>mock mathUtils.checkZero()的行为</strong> ,代码如下:</p>    <pre>  <code class="language-java">public static void main(String[] args) {      // 生成IMathUtils模拟对象      IMathUtils mathUtils = mock(IMathUtils.class);        when(mathUtils.checkZero(1)).thenReturn(false); // 当num==1时,checkZero(num)返回false      when(mathUtils.checkZero(0)).thenReturn(true); // 当num==0时,checkZero(num)返回true        Calculater calculater = new Calculater(mathUtils);        assertEquals(calculater.divide(2,1), 2); // 验证 divide(2,1) 结果是2        try {          calculater.divide(2, 0); // 预期抛出错误          throw new RuntimeException("no expectant exception"); // 如果divide没抛错,则此处抛错      } catch (Exception e) {          Assert.assertEquals(e.getMessage(), "dividend is zero"); // 验证错误信息      }  }</code></pre>    <p>这段测试代码可以 <strong>运行通过!</strong></p>    <p>代码剖析:</p>    <ul>     <li> <p>mock(IMathUtils.class) 生成 IMathUtils类的模拟对象 (称mock对象)。这个mock对象调用任何方法都不会被实际执行;</p> </li>     <li> <p>when(mathUtils.checkZero(1)).thenReturn(false) ,当调用 checkZero(num) 并且 num==1 ,返回 false ,这里 mockito 模拟了 checkZero() 行为,并模拟了返回数据;</p> </li>     <li> <p>所以, calculater.divide(2,1) 返回结果2, calculater.divide(2, 0) 抛出RuntimeException。</p> </li>    </ul>    <p>以上例子描述了,使用mockito模拟类方法和返回数据,通过mock隔离了 Calculater 对 IMathUtils 实现类的依赖,并通过单元测试,验证了 divide() 的逻辑正确性。</p>    <h3><strong>条件覆盖</strong></h3>    <p><strong>无限条件</strong></p>    <p>要验证程序正确性,必然要给出所有可能的条件(极限编程),并验证其行为或结果,才算是100%覆盖条件。实际项目中,验证边界条件和一般条件就OK了。</p>    <p>还是上面那个例子,只给出两个条件: a=2,b=1 和 a=2,b=0 , a=2,b=1 是一般条件, b=0 是边界条件,还有一些边界条件 a=NaN,b=NaN 等。要验证 <strong>除法</strong> 正确性,恐怕得给出无限的条件,实际上,只要验证几个边界条件和一般条件,基本认为代码是正确了。</p>    <p><strong>有限条件</strong></p>    <p>再举个例子: stateA='a0'、'a1', stateB='b0'、'b1'、'b2' ,根据 stateA 、 stateB 不同组合输出不同结果,例如 a0b0 输出 a0b0 , a0b1 输出 a0b1 ,所以,共2*3=6种情况。这时,并不存在边界条件,所以条件都是特定条件,并且条件有限。</p>    <p>这种情况在项目中很常见,以笔者经验,建议单元测试时把所有情况都验证一遍,确保没有遗漏。</p>    <h2><strong>单元测试不是集成测试</strong></h2>    <h3><strong>集成测试</strong></h3>    <p>集成测试,也叫组装测试、联合测试。在单元测试的基础上,将相关模块组合成为子系统或系统进行测试,称为 <strong>集成测试</strong> 。通俗一点,集成测试就是把多个(最少2个)组件合在一起,测试某个功能片段,甚至是单独功能。</p>    <h3><strong>单元测试仅针对单元</strong></h3>    <p>在微信群很多同学问: “用Robolectric能不能请求网络”,"Junit能直接请求服务器吗"?</p>    <p>例如,我们使用MVP模式,如果我们想测试:调用PresenterA接口,请求真实网络,并且返回数据后,解析成对象,并且根据返回数据执行对应逻辑。这明显 <strong>就是一个集成测试,而不是单元测试</strong> 。PresenterA是一个单元,M层的Repository、DAO等是一个单元,更底层的sqlite第三方库、网络请求第三方库(okhttp等) 也是单元.....组合了n个单元的测试,是 <strong>集成测试</strong> 。</p>    <h3><strong>Robolectric、Junit能否请求网络?</strong></h3>    <p>包括笔者在内,很多同学一开始都会有这个疑问。</p>    <p>阅读了本文第一部分,应该了解到 <strong>robolectric、junit是运行在jvm</strong> ,只要有一点点java开发经验的同学,都知道 <strong>jvm本身能连接网络</strong> 。如果你调用的方法所依赖的一切代码,都不依赖Android库(例如 <strong>okhttp、retrofit</strong> ),那99%都能在jvm上跑,并且能请求服务器。如果不幸有Android依赖,很大概率还是能在robolectric上跑的。</p>    <p>为什么robolectric不是100%能跑通测试呢? <strong>Robolectric仅支持API21及以下,并且不支持jni库</strong> 。因此,如果你的代码依赖了API21以上接口或者jni接口,robolectric也无能为力。天啊!怎么办?</p>    <p>请读者先不要沮丧,我们自有对策,不过要看读者慧根了^_^!。前文 <strong>“依赖隔离”</strong> 提到,我们可以 <strong>通过一定手段,把jni、android依赖隔离掉</strong> 。咦?咱们的代码是不是有救了?之后的文章,笔者会详细给大家讲解一下。</p>    <h3><strong>单元测试才是必要的</strong></h3>    <p>经过笔者指点,可能有读者蠢蠢欲动去尝试集成测试了.....且慢!说好的单元测试呢?集成测试看起来简单,实际上由于依赖过多,很多时候很麻烦,而且运行慢;相比之下,单元测试则小巧、灵活得多,运行快,快速发现bug。在这方面,有一个理论 <a href="/misc/goto?guid=4958969854140119347" rel="nofollow,noindex"> <strong>Test Pyramid</strong> </a> :</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/dc51f319d9da5193df815f01b7973ee7.png"></p>    <p>示意图中,左箭头表示速度,右箭头表示开发成。可以看到,单元测试速度比集成测试(Service,也叫Integration)、UI测试要快,并且开发成本也是最低。Test Pyramid告诉我们,应该花大部分精力去写单元测试,其次才是集成测试、UI测试。</p>    <p>笔者建议,还是先老老实实做单元测试,有时间精力再做集成测试。</p>    <h2><strong>小结</strong></h2>    <p>本文介绍了几个单元测试框架,介绍了junit、mockito初步使用,阐述了依赖隔离、mocking的概念,解答了"robolectric、junit能否请求网络"问题。结合阅读 <a href="/misc/goto?guid=4959714419885060858" rel="nofollow,noindex">《谈谈为什么写单元测试》</a> ,想必读者对单元测试有了一个初步的了解。</p>    <p>如果读者问笔者:“我的是小项目,是否有必要做单元测试?” 我很肯定地回答,任何项目都有必要做单元测试。至于单元测试是否耗费很多时间,或者效果不显著,这要看使用者的编程经验了,不能一概而论。</p>    <p>最后,叮嘱读者多敲代码,真枪实弹地实践单元测试。可以从公司项目小规模使用,形成自己单元测试风格后,就可以跟大范围地推广了。欢迎在本文留言讨论!</p>    <p> </p>    <p>来自:https://segmentfault.com/a/1190000006811141</p>    <p> </p>