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

photo-1469521669194-babb45599def.jpg

前言

上篇《Android单元测试 - 几个重要问题》 讲解了“何解决Android依赖、隔离Native方法、静态方法、RxJava异步转同步”这几个Presenter单元测试中常见问题。如果读者你消化得差不多,就接着看本篇吧。

在日常开发中,数据储存是必不可少的。例如,网络请求到数据,先存本地,下次打开页面,先从本地读取数据显示,再从服务器请求新数据。既然如此重要,对这块代码进行测试,也成为单元测试的重中之重了。

笔者在学会单元测试前,也像大多数人一样,写好了sql代码,运行app,报错了....检查代码,修改,再运行app....这真是效率太低了。有了单元测试做武器后,我写DAO代码轻松了不少,不担心出错,效率也高。

常用的数据储存有:sqlite、SharedPreference、Assets、文件。由于这前三种储取数据方式,都必须依赖android环境,因此要进行单元测试,不能仅仅用junit & mockito了,需要另外的单元测试框架。接下来,笔者介绍如何使用robolectric进行DAO单元测试。

缩写解释:DAO (Data Access Object) 数据访问对象


Robolectric配置

Robolectric官网:http://robolectric.org/

Robolectric配置很简单的。

build.gradle

dependencies {
    testCompile "org.robolectric:robolectric:3.1.2"
}

然后在测试用例XXTest加上注解:

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class XXTest {
}

配置代码是写完了。

不过,别以为这样就完了。Robolectric最麻烦就是下载依赖! 由于我们生活在天朝,下载国外的依赖很慢,即使有了翻墙,效果也一般。解决办法《加速Robolectric下载依赖库及原理剖析》


Sqlite

DbHelper:

public class DbHelper extends SQLiteOpenHelper {

    private static final int DB_VERSION = 1;

    public DbHelper(Context context, String dbName) {
        super(context, dbName, null, DB_VERSION);
    }
    ...
}

Bean:

public class Bean {
    int id;
    String name = "";

    public Bean(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

Bean数据操作类 BeanDAO:

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;
    }
}

以上是你在项目中用到的类,当然数据库一般开发者都会用第三方库,例如:greenDAO、ormlite、dbflow、afinal、xutils....这里考虑到代码演示规范性、通用性,就直接用android提供的SQLiteDatabase。

大家注意到BeanDAO的构造函数:

public BeanDAO() {
    this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
}

这种在内部创建对象的方式,不利于单元测试。App是项目本来的Application,但是使用Robolectric往往会指定一个测试专用的Application(命名为RoboApp,配置方法下面会介绍),这么做好处是隔离App的所有依赖。

隔离原Application依赖

项目原本的App:

public class App extends Application {

    private static Context context;

    @Override
    public void onCreate() {
        super.onCreate();
        context = this;
        
        // 各种第三方初始化,有很多依赖
        ...
    }

    public static Context getContext() {
        return context;
    }
}

而单元测试使用的RoboApp:

public class RoboApp extends Application {}

如果用Robolectric单元测试,不配置RoboApp,就会调用原来的App,而App有很多第三方库依赖,常见的有static{ Library.load() }静态加载so库。于是,执行App生命周期时,robolectric就报错了。

正确配置Application方式,是在单元测试XXTest加上@Config(application = RoboApp.class)

改进DAO类

public class BeanDAO {
    SQLiteDatabase db;

    public BeanDAO(SQLiteDatabase db) {
        this.db = db;
    }
    
    // 可以保留原来的构造函数,只是单元测试不用这个方法而已
    public BeanDAO() {
        this.db = new DbHelper(App.getContext(), "Bean").getWritableDatabase();
    }

单元测试

DAOTest

@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(), "键盘男");
    }
}

DAO单元测试跟Presenter有点不一样,可以说会更简单、直观。Presenter单元测试会用mock去隔离一些依赖,并且模拟返回值,但是sqlite执行是真实的,不能mock的。

正常情况,insert()get()应该分别测试,但这样非常麻烦,必然要在测试用例写sqlite语句,并且对SQLiteDatabase 操作。考虑到数据库操作的真实性,笔者把insertget放在同一个测试用例:如果insert()失败,那么get()必然拿不到数据,testInsertAndGet()失败;只有insert()get()代码都正确,testInsertAndGet()才能通过

Insert and Get Pass

由于用Robolectric,所以单元测试要比直接junit要慢。仅junit跑单元测试,耗时基本在毫秒(ms)级,而robolectric则是秒级(s)。不过怎么说也比跑真机、模拟器的单元测试要快很多。


SharedPreference

其实,SharedPreference道理跟sqlite一样,也是对每个测试用例创建单独SharedPreference,然后保存、查找一起测。

ShareDAO:

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, "");
    }
}

单元测试ShareDAOTest

@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");
    }
}
SharePreference单元测试通过

测试通过了。是不是很简单?

请继续看《SharePreference单元测试超级简单!》,不需要robolectric,单元测试跑更快!


Assets

Robolectric对Assets支持也是相当不错的,测Assets道理也是跟sqlite、sharePreference相同。

/assets/test.txt:

success
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 "";
    }
}

单元测试AssetsReaderTest:

@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");
    }
}
Assets单元测试通过

通过了通过了,非常简单!


文件操作

日常开发中,文件操作相对比较少。由于通常都在真机测试,有时目录、文件名有误导致程序出错,还是挺烦人的。所以,笔者教大家在本地做文件操作单元测试。

Environment.getExternalStorageDirectory()

APP运行时,通过Environment.getExternalStorageDirectory()等方法获取android储存目录,因此,只要我们改变Environment.getExternalStorageDirectory()返回的目录,就可以在单元测试时,让jvm写操作指向本地目录。

《Android单元测试 - 几个重要问题》 介绍过如何解决android.text.TextUtils依赖,那么android.os.Environment也是故伎重演:

test/java目录下,创建android/os/Environment.java

package android.os;

public class Environment {
    public static File getExternalStorageDirectory() {
        return new File("build");// 返回src/build目录
    }
}
Environment.java

Context.getCacheDir()

如果你是用contexnt.getCacheDir()getFilesDir()等,那么只需要使用RuntimeEnvironment.application就行。

代码

写完android.os.Environment,我们离成功只差一小步了。FileDAO:

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();
    }
}

FileDAO单元测试

@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);
    }
}
File单元测试通过

注意,用Environment.getExternalStorageDirectory()是不需要robolectric的,直接junit即可;而context.getCacheDir()需要robolectric。


小技巧

如果你嫌麻烦每次都要写@RunWith(RobolectricTestRunner.class)&@Config(...),那么可以写一个基类:

@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;
    }
}

然后,所有使用robolectric的测试用例,直接继承RoboCase即可。


小结

我想,大家应该感觉到,Sqlite、SharedPreference、Assets、文件操作几种单元测试,形式都差不多。有这种感觉就对了,举一反三。

本篇文字描述不多,代码比例较大,相信读者能看懂的。

如果读者对Presenter、DAO单元测试运用自如,那应该跟笔者水平相当了,哈哈哈。下一篇会介绍如何优雅地测试传参对象,敬请期待!


关于作者

我是键盘男。
在广州生活,在创业公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,277评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,689评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,624评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,356评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,402评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,292评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,135评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,992评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,429评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,636评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,785评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,492评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,092评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,723评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,858评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,891评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,713评论 2 354

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 172,104评论 25 707
  • afinalAfinal是一个android的ioc,orm框架 https://github.com/yangf...
    passiontim阅读 15,429评论 2 45
  • 前言 在之前的系列博客中,主要围绕的是测试工具的介绍与使用。经过几个月的沉寂,在项目中摸索与实践单元测试,曾经踩坑...
    水木飞雪阅读 2,833评论 0 8
  • 今天读了一篇文章,讲的是关于产品如何做用户激励。作者用一句话把用户激励总结的非常到位:用户使用产品,如果说用户需求...
    人间四月天zjs阅读 236评论 0 0
  • 清明节放假回家,这两天天气还不错,第一天大晴天,自己本想出去逛逛,却发现没地方去,也没有人出去玩,后面就这样呆在家...
    陌如烟雨淡如尘阅读 261评论 0 0