吐槽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方面,如果只是測SharePreference
和SQLiteDatabase
根本不需要,就想不明白Robolectric團隊為什么不把SharePreference
和SQLiteDatabase
配置分離出來,好讓單元測試跑快一點。
簡單實驗,跑一個什么都不做的robolectric test case:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class RoboCase {
@Test
public void testRobo() {
}
}
盡管你什么都不做,不好意思,Robolectric就得運行3秒!而且隨著工程代碼增加,這個時間有增無減。如果跑一個什么都不做的Junit單元測試,1ms不到。筆者本文介紹的方法,跑簡單的運行測試時間在10~1000ms不等,視乎測試代碼復雜度,最快比Robolectric快140+倍。
理解SharedPreferences
我們通過Context
獲取SharedPreferences
:
Context context;
SharedPreferences sharePref = context.getSharedPreferences("name", Context.MODE_PRIVATE);
getSharedPreferences
有name
和mode
參數,傳不同的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));
}
}
僅需要12ms,非常快,而且不需要任何配置。
進階
場景測試
你本來有BookDAO
,后來重構,需要新增或者拋棄一些方法或者其他原因,寫一個BookDAOV2
。這個BookDAOV2
與BookDAO
的數據共享,意味著用同一個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 :1at 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
上文提到的BookDAO
和BookDAOV2
單元測試,可以修改如下:
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工程師,猥瑣文藝碼農。每天謀劃砍死產品經理。喜歡科學、歷史,玩玩投資,偶爾旅行。