SharePreference单元测试超级简单!

吐槽Robolectric

如果你读过笔者的《Android单元测试 - Sqlite、SharedPreference、Assets、文件操作 怎么测?》,就知道我们可以用Robolectric去做SharePreference单元测试。但笔者越来越觉得Robolectric非常麻烦,主要以下几点:

1.对初学者门槛高
2.运行效率低下

第一次使用Robolectric的同学,必然会卡在下载依赖的步骤,这一步让多少同学放弃或者延迟学习robolectric。读者可以参考《加速Robolectric下载依赖库及原理剖析》,彻底解决这个问题。

其次,就是配置麻烦,从2.x到3.x版本,配置一直改动(其实是越来越精简),2.x版本的配置到3.x版本,就有问题,不得不重新看官方文档如何配置。有时不知道是改了gradle版本还是什么原因,配置没变,就给你报错,常见的"No such manifest file: build\intermediates\bundles\debug\AndroidManifest.xml"......

至于运行效率,由于Robolectric是一个大而全的框架,单元测试到UI测试都能做,运行时先解析、加载一大堆东西,才给你跑测试。笔者研究过源码,前期解析慢主要是UI方面,如果只是测SharePreferenceSQLiteDatabase根本不需要,就想不明白Robolectric团队为什么不把SharePreferenceSQLiteDatabase配置分离出来,好让单元测试跑快一点。

简单实验,跑一个什么都不做的robolectric test case:

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class RoboCase {
    @Test
    public void testRobo() {

    }
}
Robo Test Case

尽管你什么都不做,不好意思,Robolectric就得运行3秒!而且随着工程代码增加,这个时间有增无减。如果跑一个什么都不做的Junit单元测试,1ms不到。笔者本文介绍的方法,跑简单的运行测试时间在10~1000ms不等,视乎测试代码复杂度,最快比Robolectric快140+倍


理解SharedPreferences

我们通过Context获取SharedPreferences

Context context;
SharedPreferences sharePref = context.getSharedPreferences("name", Context.MODE_PRIVATE);

getSharedPreferencesnamemode参数,传不同的name获取不同的SharedPreferences

SharedPreferences源码:

public interface SharedPreferences {

    public interface OnSharedPreferenceChangeListener {
        void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
    }

    public interface Editor {
        Editor putString(String key, @Nullable String value);

        Editor putStringSet(String key, @Nullable Set<String> values);

        Editor putInt(String key, int value);

        Editor putLong(String key, long value);

        Editor putFloat(String key, float value);

        Editor putBoolean(String key, boolean value);

        Editor remove(String key);

        Editor clear();

        boolean commit();

        void apply();
    }

    Map<String, ?> getAll();

    @Nullable
    String getString(String key, @Nullable String defValue);

    @Nullable
    Set<String> getStringSet(String key, @Nullable Set<String> defValues);

    int getInt(String key, int defValue);

    long getLong(String key, long defValue);

    float getFloat(String key, float defValue);

    boolean getBoolean(String key, boolean defValue);

    boolean contains(String key);

    Editor edit();

    void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);

    void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
}

SharedPreferences实际上只是一个接口,我们获取的对象,是继承该接口的android.app.SharedPreferencesImpl。Android sdk没有提供这个类,读者可阅读源码:SharedPreferencesImpl.java

从功能上看,SharedPreferences就是简单的kev-value数据库,在app运行时,对SharedPreferences储存、读取数据,会存放在Android手机该app空间的文件里。

单元测试思路

首先,单元测试原则是每个测试用例的数据独立。因此,前一个测试用例在SharedPreferences储存的数据,下一个用例不应该读取到SharedPreferences就没有必要真的把数据储存在文件了,只需要存放在jvm内存就足够。

既然SharedPreferences的功能用内存实现,那么java代码就能轻易实现key-value储存,原理跟java.util.Map如出一辙。

代码实现SharedPreferences

ShadowSharedPreferences:

public class ShadowSharedPreference implements SharedPreferences {

    Editor editor;

    List<OnSharedPreferenceChangeListener> mOnChangeListeners = new ArrayList<>();
    Map<String, Object>                    map                = new ConcurrentHashMap<>();

    public ShadowSharedPreference() {
        editor = new ShadowEditor(new EditorCall() {

            @Override
            public void apply(Map<String, Object> commitMap, List<String> removeList, boolean commitClear) {
                Map<String, Object> realMap = map;

                // clear
                if (commitClear) {
                    realMap.clear();
                }

                // 移除元素
                for (String key : removeList) {
                    realMap.remove(key);

                    for (OnSharedPreferenceChangeListener listener : mOnChangeListeners) {
                        listener.onSharedPreferenceChanged(ShadowSharedPreference.this, key);
                    }
                }

                // 添加元素
                Set<String> keys = commitMap.keySet();

                // 对比前后变化
                for (String key : keys) {
                    Object lastValue = realMap.get(key);
                    Object value     = commitMap.get(key);

                    if ((lastValue == null && value != null) || (lastValue != null && value == null) || !lastValue.equals(value)) {
                        for (OnSharedPreferenceChangeListener listener : mOnChangeListeners) {
                            listener.onSharedPreferenceChanged(ShadowSharedPreference.this, key);
                        }
                    }
                }

                realMap.putAll(commitMap);
            }
        });
    }

    public Map<String, ?> getAll() {
        return new HashMap<>(map);
    }

    public String getString(String key, @Nullable String defValue) {
        if (map.containsKey(key)) {
            return (String) map.get(key);
        }

        return defValue;
    }

    public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
        if (map.containsKey(key)) {
            return new HashSet<>((Set<String>) map.get(key));
        }

        return defValues;
    }

    public int getInt(String key, int defValue) {
        if (map.containsKey(key)) {
            return (Integer) map.get(key);
        }

        return defValue;
    }

    public long getLong(String key, long defValue) {
        if (map.containsKey(key)) {
            return (Long) map.get(key);
        }

        return defValue;
    }

    public float getFloat(String key, float defValue) {
        if (map.containsKey(key)) {
            return (Float) map.get(key);
        }

        return defValue;
    }

    public boolean getBoolean(String key, boolean defValue) {
        if (map.containsKey(key)) {
            return (Boolean) map.get(key);
        }

        return defValue;
    }

    public boolean contains(String key) {
        return map.containsKey(key);
    }

    public Editor edit() {
        return editor;
    }

    /**
     * 监听对应的key值的变化,只有当key对应的value值发生变化时,才会触发
     *
     * @param listener
     */
    @Override
    public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        mOnChangeListeners.add(listener);
    }

    @Override
    public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        mOnChangeListeners.remove(listener);
    }

    interface EditorCall {
        void apply(Map<String, Object> map, List<String> removeList, boolean commitClear);
    }

    public class ShadowEditor implements SharedPreferences.Editor {

        boolean commitClear;

        Map<String, Object> map        = new ConcurrentHashMap<>();
        /**
         * 待移除列表
         */
        List<String>        removeList = new ArrayList<>();

        EditorCall mCall;

        public ShadowEditor(EditorCall call) {
            this.mCall = call;
        }

        public ShadowEditor putString(String key, @Nullable String value) {
            map.put(key, value);
            return this;
        }

        public ShadowEditor putStringSet(String key, @Nullable Set<String> values) {
            map.put(key, new HashSet<>(values));
            return this;
        }

        public ShadowEditor putInt(String key, int value) {
            map.put(key, value);
            return this;
        }

        public ShadowEditor putLong(String key, long value) {
            map.put(key, value);
            return this;
        }

        public ShadowEditor putFloat(String key, float value) {
            map.put(key, value);
            return this;
        }

        public ShadowEditor putBoolean(String key, boolean value) {
            map.put(key, value);
            return this;
        }

        public ShadowEditor remove(String key) {
            map.remove(key);
            removeList.add(key);
            return this;
        }

        public ShadowEditor clear() {
            commitClear = true;
            map.clear();
            removeList.clear();
            return this;
        }

        public boolean commit() {
            try {
                apply();
                return true;
            } catch (Exception e) {
                e.printStackTrace();
                return false;
            }
        }

        public void apply() {
            mCall.apply(map, removeList, commitClear);

            // 每次提交清空缓存数据
            map.clear();
            commitClear = false;
            removeList.clear();
        }
    }
}

SharePreferenceHelper:

public class SharePreferenceHelper {

    public static SharedPreferences newInstance() {
        return new ShadowSharePreference();
    }
}

只需要两个类,准备工作就大功告成了,非常简单!

跑单元测试

BookDAO:

public class BookDAO {

    SharedPreferences        mSharedPre;
    SharedPreferences.Editor mEditor;

    // 单元测试调用,注意声明protected
    protected BookDAO(SharedPreferences sharedPre) {
        this.mSharedPre = sharedPre;
        this.mEditor    = sharedPre.edit();
    }

    // 正常代码调用
    public BookDAO(Context context) {
        this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
    }

    /**
     * 设置某book是否已读
     *
     * @param bookId 书本id
     * @param isRead 是否已读
     */
    public void setBookRead(int bookId, boolean isRead) {
        mEditor.putBoolean(String.valueOf(bookId), isRead);
        mEditor.commit();
    }

    /**
     * book是否已读
     *
     * @param bookId 书本id
     * @return
     */
    public boolean isBookRead(int bookId) {
        return mSharedPre.getBoolean(String.valueOf(bookId), false);
    }
}

BookDAO有两个构造方法,BookDAO(SharedPreferences sharedPre)BookDAO(Context context),由于单元测试没有Context,因此直接创建SharedPreferences对象即可。

BookDAOTest单元测试:

public class BookDAOTest {

    BookDAO bookDAO;

    @Before
    public void setUp() throws Exception {
        bookDAO = new BookDAO(SharePreferenceHelper.newInstance());
    }

    @Test
    public void isBookRead() throws Exception {
        int bookId = 10;

        // 未读
        Assert.assertFalse(bookDAO.isBookRead(bookId));

        // 设置已读
        bookDAO.setBookRead(bookId, true);

        // 已读
        Assert.assertTrue(bookDAO.isBookRead(bookId));
    }
}
BookDAO Test Case

仅需要12ms,非常快,而且不需要任何配置。

进阶

场景测试

你本来有BookDAO,后来重构,需要新增或者抛弃一些方法或者其他原因,写一个BookDAOV2。这个BookDAOV2BookDAO的数据共享,意味着用同一个SharedPreferences。

单元测试怎么写呢?

public class BookDAOV2 {

    SharedPreferences        mSharedPre;
    SharedPreferences.Editor mEditor;

    protected BookDAOV2(SharedPreferences sharedPre) {
        this.mSharedPre = sharedPre;
        this.mEditor = sharedPre.edit();
    }
    
    public BookDAOV2(Context context) {
        // 与BookDAO使用同一个SharedPreferences
        this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
    }

    public void clearAllRead() {
        mEditor.clear();
        mEditor.commit();
    }
}

测试用例:

public class BookUpdateTest {

    BookDAO   bookDAO;
    BookDAOV2 bookDAOV2;

    @Before
    public void setUp() throws Exception {
        SharedPreferences sharedPref = SharedPreferencesHelper.newInstance();

        bookDAO = new BookDAO(sharedPref);
        bookDAOV2 = new BookDAOV2(sharedPref);
    }

    @Test
    public void testClearAllRead() {
        int bookId = 10;

        // 设置已读
        bookDAO.setBookRead(bookId, true);

        // 已读
        Assert.assertTrue(bookDAO.isBookRead(bookId));

        // DAOV2 清除已读
        bookDAOV2.clearAllRead();

        // 未读
        Assert.assertFalse(bookDAO.isBookRead(bookId));
    }
}

但是这样不太优雅,能不能调用SharedPreferencesHelper同一个方法,返回同一个SharedPreferences呢?

通过name获取不同SharedPreferences

context.getSharedPreferences(name, mode)可以改变name类获取不同SharedPreferences对象,这些SharedPreferences彼此数据独立。

因此,我们在SharePreferenceHelper加两个静态方法:

public class SharePreferenceHelper {

    private static Map<String, SharedPreferences> map = new ConcurrentHashMap<>();

    public static SharedPreferences getInstance(String name) {
        if (map.containsKey(name)) {
            return map.get(name);
        } else {
            SharedPreferences sharedPreferences = new ShadowSharePreference();

            map.put(name, sharedPreferences);

            return sharedPreferences;
        }
    }

    public static void clean() {
        map.clear();
    }
    ......
}

我们调用SharePreferenceHelper.getInstance(name)就可以获取name对应不同ShadowSharedPreferences

跑个测试:

public class MultipleSharedPrefTest {

    @Test
    public void testSampleSharedPrefer() {
        SharedPreferences sharedPref0 = SharedPreferenceHelper.getInstance("name");
        SharedPreferences sharedPref1 = SharedPreferenceHelper.getInstance("name");

        Assert.assertEquals(sharedPref0, sharedPref1);
    }

    @Test
    public void testDifferentSharedPref() {
        SharedPreferences sharedPref0 = SharedPreferenceHelper.getInstance("name");
        SharedPreferences sharedPref1 = SharedPreferenceHelper.getInstance("other");

        // 不同SharedPreferences
        Assert.assertNotEquals(sharedPref0, sharedPref1);
    }
}

结果当然是两个都pass啦!

处理Test Case前后数据干扰

运行一次单元测试,无论Test Case多少,jvm只启动一次,因此,静态变量就会一直存在,直到该次单元测试完成。问题就出现了:上面介绍的SharedPreferenceHelper.getInstance(name),是通过static Map<String, SharedPreferences>缓存SharedPreferences对象,所以,同一次单元测试,上一个Test Case储存的数据,会影响下一个Test Case。

下面的单元测试,先执行testA(),储存key=1,在执行testB():

@FixMethodOrder(MethodSorters.NAME_ASCENDING)  // 按case名称字母顺序排序
public class DistractionTest {

    SharedPreferences        mSharedPref;
    SharedPreferences.Editor mEditor;

    @Before
    public void setUp() throws Exception {
        mSharedPref = SharedPreferencesHelper.getInstance("name");
        mEditor = mSharedPref.edit();
    }

    @Test
    public void testA() {
        mEditor.putInt("key", 1);
        mEditor.commit();
    }

    @Test
    public void testB() {
        // testA()的数据,不应该影响testB()
        Assert.assertEquals(0, mSharedPref.getInt("key", 0));
    }
}

很遗憾,testA()的数据影响到testB():

java.lang.AssertionError:
Expected :0
Actual :1

at org.junit.Assert.assertEquals(Assert.java:631)
at com.sharepreference.library.DistractionTest.testB(DistractionTest.java:34)

数据干扰

因此,需要在Test Case tearDown()方法回调时,调用SharedPreferenceHelper.clean(),再运行一次:

@FixMethodOrder(MethodSorters.NAME_ASCENDING)  // 按case名称字母顺序排序
public class DistractionTest {
    ...

    @After
    public void tearDown() throws Exception {
        SharedPreferencesHelper.clean();
    }
    ...
}
排除干扰

统一处理tearDown()

如果我们每个Test Case都要写testDown()处理SharedPreferences缓存,未免太不优雅。我们可以借助TestRule类完成。

SharedPrefRule:

public class SharedPrefRule extends ExternalResource {

    @Override
    protected void after() {
        // 每测试完一个用例方法,就回调
        SharedPreferencesHelper.clean();
    }
}

SharedPrefCase:

public class SharedPrefCase {

    @Rule
    public SharedPrefRule rule = new SharedPrefRule();
    
    public SharedPreferences getInstance(String name) {
        return SharedPreferencesHelper.getInstance(name);
    }
}

于是,我们所以SharedPrefences测试用例,都继承SharedPrefCase:

public class MySharedPrefTest extends SharedPrefCase {

    SharedPreferences mSharedPre;

    @Before
    public void setUp() throws Exception {
        mSharedPre = getInstance("name");
    }
}

这样,数据干扰的问题就解决了。

修改BookUpdateTest

上文提到的BookDAOBookDAOV2单元测试,可以修改如下:

public class BookUpdateTest extends SharedPrefCase {

    @Before
    public void setUp() throws Exception {
        bookDAO = new BookDAO(getInstance("book"));
        bookDAOV2 = new BookDAOV2(getInstance("book"));
    }
}

比之前优雅多了。

Context获取SharedPreferences

很多同学都会在Application.onCreate()时,在某个地方把ApplicationContext存起来,方便其他地方获取。然后,在DAO里面直接用这个Context获取SharedPreferences。按照笔者的方法,单元测试时,每个DAO都要传一个新创建的SharedPreferences。但有的同学就是懒,有其他更好的方式吗?

你的代码可能是这样:

public class ContextProvider {

    private static Context context;

    public static Context getContext() {
        return context;
    }

    public static void setContext(Context context) {
        ContextProvider.context = context;
    }
}
public class BookDAO {

    SharedPreferences mSharedPre;

    public BookDAO() {
        Context context = ContextProvider.getContext();
        mSharedPre      = context.getSharedPreferences("book", Context.MODE_PRIVATE);
    }
}

我们的问题是,如何让context.getSharedPreferences返回一个SharedPreferences。借助一下mockito来实现,修改SharedPrefRule

public class SharedPrefRule extends ExternalResource {

    @Override
    protected void before() throws Throwable {
        Context context = mock(Context.class);

        // 调用context.getSharedPreferences(name)时,执行SharedPreferencesHelper.getInstance(name),返回结果
        doAnswer(new Answer() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                String name = (String) invocation.getArguments()[0];
                return SharedPreferencesHelper.getInstance(name);
            }
        }).when(context).getSharedPreferences(anyString(), anyInt());
        
        // 设置Context
        ContextProvider.setContext(context);
    }
    ...
}

开源一个SharePreferences单元测试框架

本文的重头戏——开源框架!

听起来好像很屌的样子,其实就是那么几个类,见笑_. 上述的代码,笔者整理成项目,在github开源,并且发布到jitpack. 读者可以免费使用,通过gradle依赖。

开源框架命名很头痛,就叫SPTestFramework吧!

不需要Robolectric即可测试SharedPreferences,SPTestFramework你值得拥有!

SPTestFramework项目是什么?

SPTestFramework(简称SPTest)是一个SharedPreferences单元测试框架。项目自带单元测试,确保测试框架代码质量和功能正确。

同时,欢迎各位同学使用、测试、提出问题!

项目地址:https://github.com/kkmike999/SPTestFramework


关于作者

我是键盘男。
在广州生活,悦跑圈Android工程师,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。

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

推荐阅读更多精彩内容