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工程師,猥瑣文藝碼農。每天謀劃砍死產品經理。喜歡科學、歷史,玩玩投資,偶爾旅行。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容