SharePreference单元测试超级简单!

SharePreference单元测试超级简单!_第1张图片

吐槽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 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 getAll();

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

    @Nullable
    Set getStringSet(String key, @Nullable Set 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 mOnChangeListeners = new ArrayList<>();
    Map                    map                = new ConcurrentHashMap<>();

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

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

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

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

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

                // 添加元素
                Set 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 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 getStringSet(String key, @Nullable Set defValues) {
        if (map.containsKey(key)) {
            return new HashSet<>((Set) 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 map, List removeList, boolean commitClear);
    }

    public class ShadowEditor implements SharedPreferences.Editor {

        boolean commitClear;

        Map map        = new ConcurrentHashMap<>();
        /**
         * 待移除列表
         */
        List        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 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));
    }
}
SharePreference单元测试超级简单!_第2张图片
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 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缓存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)

SharePreference单元测试超级简单!_第3张图片
数据干扰

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

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

    @After
    public void tearDown() throws Exception {
        SharedPreferencesHelper.clean();
    }
    ...
}
SharePreference单元测试超级简单!_第4张图片
排除干扰

统一处理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工程师,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。

你可能感兴趣的:(SharePreference单元测试超级简单!)