Android单元测试 - Sqlite、SharedPreference、Assets、文件操作 怎么测?

dang619 7年前
   <h2 style="text-align:center"><img src="https://simg.open-open.com/show/5f101601301afdfaf099aac3b32f392f.jpg"></h2>    <h2><strong>前言</strong></h2>    <p>在日常开发中, <strong>数据储存是必不可少的</strong> 。例如,网络请求到数据,先存本地,下次打开页面,先从本地读取数据显示,再从服务器请求新数据。既然如此重要,对这块代码进行测试,也成为 <strong>单元测试的重中之重</strong> 了。</p>    <p>笔者在学会单元测试前,也像大多数人一样,写好了sql代码,运行app,报错了....检查代码,修改,再运行app....这真是效率太低了。有了单元测试做武器后,我写DAO代码轻松了不少,不担心出错,效率也高。</p>    <p>常用的数据储存有:sqlite、SharedPreference、Assets、文件。由于这前三种储取数据方式,都必须依赖android环境,因此要进行单元测试,不能仅仅用junit & mockito了,需要另外的单元测试框架。接下来,笔者介绍如何使用robolectric进行DAO单元测试。</p>    <p>缩写解释:DAO (Data Access Object) 数据访问对象</p>    <h2><strong>Robolectric配置</strong></h2>    <p>Robolectric配置很简单的。</p>    <p>build.gradle :</p>    <pre>  <code class="language-protobuf">dependencies {      testCompile "org.robolectric:robolectric:3.1.2"  }</code></pre>    <p>然后在测试用例 XXTest 加上注解:</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class)  public class XXTest {  }</code></pre>    <p>配置代码是写完了。</p>    <p>不过,别以为这样就完了。 <strong>Robolectric最麻烦就是下载依赖!</strong> 由于我们生活在天朝,下载国外的依赖很慢,笔者即使有了KX上网,效果也一般,可能是 https://oss.sonatype.org 服务器比较慢。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/da71ef2c3aa2842d8841abc6f9b17b99.png"></p>    <p>笔者已经下载好了依赖包,读者们可以到 <a href="/misc/goto?guid=4959721971515749834" rel="nofollow,noindex">http://git.oschina.net/kkmike999/Robolectric-Dependencies</a> 下载robolectric 3.1.2的依赖包,按照 Readme.md 说明操作。</p>    <h2><strong>Sqlite</strong></h2>    <p>DbHelper :</p>    <pre>  <code class="language-protobuf">public class DbHelper extends SQLiteOpenHelper {        private static final int DB_VERSION = 1;        public DbHelper(Context context, String dbName) {          super(context, dbName, null, DB_VERSION);      }      ...  }</code></pre>    <p>Bean :</p>    <pre>  <code class="language-protobuf">public class Bean {      int id;      String name = "";        public Bean(int id, String name) {          this.id = id;          this.name = name;      }  }</code></pre>    <p>Bean数据操作类 BeanDAO :</p>    <pre>  <code class="language-protobuf">public class BeanDAO {      static boolean isTableExist;            SQLiteDatabase db;        public BeanDAO() {          this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();      }        /**       * 插入Bean       */      public void insert(Bean bean) {          checkTable();            ContentValues values = new ContentValues();          values.put("id", bean.getId());          values.put("name", bean.getName());            db.insert("Bean", "", values);      }        /**       * 获取对应id的Bean       */      public Bean get(int id) {          checkTable();            Cursor cursor = null;            try {              cursor = db.rawQuery("SELECT * FROM Bean", null);                if (cursor != null && cursor.moveToNext()) {                  String name = cursor.getString(cursor.getColumnIndex("name"));                    return new Bean(id, name);              }          } catch (Exception e) {              e.printStackTrace();          } finally {              if (cursor != null) {                  cursor.close();              }              cursor = null;          }          return null;      }            /**       * 检查表是否存在,不存在则创建表       */      private void checkTable() {          if (!isTableExist()) {              db.execSQL("CREATE TABLE IF NOT EXISTS Bean ( id INTEGER PRIMARY KEY, name )");          }      }        private boolean isTableExist() {          if (isTableExist) {              return true; // 上次操作已确定表已存在于数据库,直接返回true          }                    Cursor cursor = null;          try {              String sql = "SELECT COUNT(*) AS c FROM sqlite_master WHERE type ='table' AND name ='Bean' ";                cursor = db.rawQuery(sql, null);              if (cursor != null && cursor.moveToNext()) {                  int count = cursor.getInt(0);                  if (count > 0) {                      isTableExist = true; // 记录Table已创建,下次执行isTableExist()时,直接返回true                      return true;                  }              }          } catch (Exception e) {              e.printStackTrace();          } finally {              if (cursor != null) {                  cursor.close();              }              cursor = null;          }          return false;      }  }</code></pre>    <p>以上是你在项目中用到的类,当然数据库一般开发者都会用第三方库,例如:greenDAO、ormlite、dbflow、afinal、xutils....这里考虑到代码演示规范性、通用性,就直接用android提供的SQLiteDatabase。</p>    <p>大家注意到 BeanDAO 的构造函数:</p>    <pre>  <code class="language-protobuf">public BeanDAO() {      this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();  }</code></pre>    <p>这种在内部创建对象的方式,不利于单元测试。 App 是项目本来的 Application ,但是使用 <strong>Robolectric</strong> 往往会指定一个测试专用的 Application (命名为 RoboApp ,配置方法下面会介绍),这么做好处是隔离 App 的所有依赖。</p>    <h3><strong>隔离原Application依赖</strong></h3>    <p>项目原本的 App :</p>    <pre>  <code class="language-protobuf">public class App extends Application {        private static Context context;        @Override      public void onCreate() {          super.onCreate();          context = this;                    // 各种第三方初始化,有很多依赖          ...      }        public static Context getContext() {          return context;      }  }</code></pre>    <p>而单元测试使用的 RoboApp :</p>    <pre>  <code class="language-protobuf">public class RoboApp extends Application {}</code></pre>    <p>如果用 <strong>Robolectric</strong> 单元测试,不配置 RoboApp ,就会调用原来的 App ,而 App 有很多第三方库依赖,常见的有 static{ Library.load() } 静态加载so库。于是,执行 App 生命周期时, <strong>robolectric</strong> 就报错了。</p>    <p>正确配置 Application 方式,是在单元测试 XXTest 加上 @Config(application = RoboApp.class) 。</p>    <h3><strong>改进DAO类</strong></h3>    <pre>  <code class="language-protobuf">public class BeanDAO {      SQLiteDatabase db;        public BeanDAO(SQLiteDatabase db) {          this.db = db;      }            // 可以保留原来的构造函数,只是单元测试不用这个方法而已      public BeanDAO() {          this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();      }</code></pre>    <h3><strong>单元测试</strong></h3>    <p>DAOTest</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)  public class DAOTest {        BeanDAO dao;        @Before      public void setUp() throws Exception {          // 用随机数做数据库名称,让每个测试方法,都用不同数据库,保证数据唯一性          DbHelper       dbHelper = new DbHelper(RuntimeEnvironment.application, new Random().nextInt(1000) + ".db");          SQLiteDatabase db       = dbHelper.getWritableDatabase();            dao = new BeanDAO(db);      }        @Test      public void testInsertAndGet() throws Exception {          Bean bean = new Bean(1, "键盘男");            dao.insert(bean);            Bean retBean = dao.get(1);            Assert.assertEquals(retBean.getId(), 1);          Assert.assertEquals(retBean.getName(), "键盘男");      }  }</code></pre>    <p>DAO单元测试跟Presenter有点不一样,可以说会更简单、直观。 <strong>Presenter单元测试</strong> 会用mock去隔离一些依赖,并且模拟返回值,但是 <strong>sqlite</strong> 执行是真实的,不能mock的。</p>    <p>正常情况, insert() 和 get() 应该分别测试,但这样非常麻烦,必然要在测试用例写sqlite语句,并且对SQLiteDatabase 操作。考虑到 <strong>数据库操作的真实性</strong> ,笔者把 insert 和 get 放在同一个测试用例:如果 insert() 失败,那么 get() 必然拿不到数据, testInsertAndGet() 失败; 只有 insert() 和 get() 代码都正确, testInsertAndGet() 才能通过 。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1c2b4552d7f368ddd417194c42fe5a9b.png"></p>    <p>由于用 <strong>Robolectric</strong> ,所以单元测试要比直接junit要慢。仅junit跑单元测试,耗时基本在毫秒(ms)级,而robolectric则是秒级(s)。不过怎么说也比跑真机、模拟器的单元测试要快很多。</p>    <h2><strong>SharedPreference</strong></h2>    <p>其实, <strong>SharedPreference</strong> 道理跟sqlite一样,也是对每个测试用例创建单独SharedPreference,然后 保存、查找 一起测。</p>    <p>ShareDAO :</p>    <pre>  <code class="language-protobuf">public class ShareDAO {      SharedPreferences        sharedPref;      SharedPreferences.Editor editor;        public ShareDAO(SharedPreferences sharedPref) {          this.sharedPref = sharedPref;          this.editor = sharedPref.edit();      }        public ShareDAO() {          this(App.getContext().getSharedPreferences("myShare", Context.MODE_PRIVATE));      }        public void put(String key, String value) {          editor.putString(key, value);          editor.apply();      }        public String get(String key) {          return sharedPref.getString(key, "");      }  }</code></pre>    <p>单元测试 ShareDAOTest</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)  public class ShareDAOTest {        ShareDAO shareDAO;        @Before      public void setUp() throws Exception {          String name = new Random().nextInt(1000) + ".pref";            shareDAO = new ShareDAO(RuntimeEnvironment.application.getSharedPreferences(name, Context.MODE_PRIVATE));      }        @Test      public void testPutAndGet() throws Exception {          shareDAO.put("key01", "stringA");            String value = shareDAO.get("key01");            Assert.assertEquals(value, "stringA");      }  }</code></pre>    <p style="text-align:center"><img src="https://simg.open-open.com/show/8124afbc10f93eef24b6291f4a40ca48.png"></p>    <p>测试通过了。是不是很简单?</p>    <h2><strong>Assets</strong></h2>    <p>Robolectric对 <strong>Assets</strong> 支持也是相当不错的,测Assets道理也是跟sqlite、sharePreference相同。</p>    <p>/assets/test.txt :</p>    <pre>  <code class="language-protobuf">success</code></pre>    <pre>  <code class="language-protobuf">public class AssetsReader {        AssetManager assetManager;        public AssetsReader(AssetManager assetManager) {          this.assetManager = assetManager;      }        public AssetsReader() {          assetManager = App.getContext()                            .getAssets();      }        public String read(String fileName) {          try {              InputStream inputStream = assetManager.open(fileName);                StringBuilder sb = new StringBuilder();                byte[] buffer = new byte[1024];                int hasRead;                while ((hasRead = inputStream.read(buffer, 0, buffer.length)) > -1) {                  sb.append(new String(buffer, 0, hasRead));              }                inputStream.close();                return sb.toString();          } catch (IOException e) {              e.printStackTrace();          }          return "";      }  }</code></pre>    <p>单元测试 AssetsReaderTest :</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)  public class AssetsReaderTest {        AssetsReader assetsReader;        @Before      public void setUp() throws Exception {          assetsReader = new AssetsReader(RuntimeEnvironment.application.getAssets());      }        @Test      public void testRead() throws Exception {          String value = assetsReader.read("test.txt");            Assert.assertEquals(value, "success");      }  }</code></pre>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a82f177c795ca0e9d71d15b051dfeef4.png"></p>    <p>通过了通过了,非常简单!</p>    <h2><strong>文件操作</strong></h2>    <p>日常开发中,文件操作相对比较少。由于通常都在真机测试,有时目录、文件名有误导致程序出错,还是挺烦人的。所以,笔者教大家在本地做文件操作单元测试。</p>    <h3>Environment.getExternalStorageDirectory()</h3>    <p>APP运行时,通过 Environment.getExternalStorageDirectory() 等方法获取android储存目录,因此,只要我们改变 Environment.getExternalStorageDirectory() 返回的目录,就可以在单元测试时,让jvm写操作指向本地目录。</p>    <p>在 test/java 目录下,创建 android/os/Environment.java</p>    <pre>  <code class="language-protobuf">package android.os;    public class Environment {      public static File getExternalStorageDirectory() {          return new File("build");// 返回src/build目录      }  }</code></pre>    <p style="text-align:center"><img src="https://simg.open-open.com/show/8c9ad82fd6c87a899704d43754f25125.png"></p>    <h3><strong>Context.getCacheDir()</strong></h3>    <p>如果你是用 contexnt.getCacheDir() 、 getFilesDir() 等,那么只需要使用 RuntimeEnvironment.application 就行。</p>    <h3><strong>代码</strong></h3>    <p>写完 android.os.Environment ,我们离成功只差一小步了。 FileDAO :</p>    <pre>  <code class="language-protobuf">public class FileDAO {        Context context;        public FileDAO(Context context) {          this.context = context;      }            public void write(String name, String content) {          File file = new File(getDirectory(), name);            if (!file.getParentFile().exists()) {              file.getParentFile().mkdirs();          }          try {              FileWriter fileWriter = new FileWriter(file);                fileWriter.write(content);              fileWriter.flush();              fileWriter.close();          } catch (IOException e) {              e.printStackTrace();          }      }        public String read(String name) {          File file = new File(getDirectory(), name);            if (!file.exists()) {              return "";          }            try {              FileReader reader = new FileReader(file);                StringBuilder sb = new StringBuilder();                char[] buffer = new char[1024];              int    hasRead;                while ((hasRead = reader.read(buffer, 0, buffer.length)) > -1) {                  sb.append(new String(buffer, 0, hasRead));              }              reader.close();                return sb.toString();          } catch (IOException e) {              e.printStackTrace();          }          return "";      }        public void delete(String name) {          File file = new File(getDirectory(), name);            if (file.exists()) {              file.delete();          }      }        protected File getDirectory() {          // return context.getCacheDir();          return Environment.getExternalStorageDirectory();      }  }</code></pre>    <p>FileDAO单元测试</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)  public class FileDAOTest {        FileDAO fileDAO;        @Before      public void setUp() throws Exception {          fileDAO = new FileDAO(RuntimeEnvironment.application);      }        @Test      public void testWrite() throws Exception {          String name = "readme.md";            fileDAO.write(name, "success");            String content = fileDAO.read(name);            Assert.assertEquals(content, "success");            // 一定要删除测试文件,保留的文件会影响下次单元测试          fileDAO.delete(name);      }  }</code></pre>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e3372c01f2419a8dd58c3fa872d1eacb.png"></p>    <p>注意,用 Environment.getExternalStorageDirectory() 是不需要robolectric的,直接junit即可;而 context.getCacheDir() 需要robolectric。</p>    <h2><strong>小技巧</strong></h2>    <p>如果你嫌麻烦每次都要写 @RunWith(RobolectricTestRunner.class) & @Config(...) ,那么可以写一个基类:</p>    <pre>  <code class="language-protobuf">@RunWith(RobolectricTestRunner.class)  @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = Build.VERSION_CODES.JELLY_BEAN, application = RoboApp.class)  public class RoboCase {        protected Context getContext() {          return RuntimeEnvironment.application;      }  }</code></pre>    <p>然后,所有使用robolectric的测试用例,直接继承 RoboCase 即可。</p>    <h2><strong>小结</strong></h2>    <p>我想,大家应该感觉到, <strong>Sqlite、SharedPreference、Assets、文件操作</strong> 几种单元测试,形式都差不多。有这种感觉就对了,举一反三。</p>    <p> </p>    <p> </p>    <p>来自:https://segmentfault.com/a/1190000007250610</p>    <p> </p>