吐槽Robolectric
如果你讀過(guò)筆者的《Android單元測(cè)試 - Sqlite奠宜、SharedPreference包颁、Assets瞻想、文件操作 怎么測(cè)?》娩嚼,就知道我們可以用Robolectric去做SharePreference單元測(cè)試蘑险。但筆者越來(lái)越覺得Robolectric非常麻煩,主要以下幾點(diǎn):
1.對(duì)初學(xué)者門檻高
2.運(yùn)行效率低下
第一次使用Robolectric的同學(xué)岳悟,必然會(huì)卡在下載依賴的步驟佃迄,這一步讓多少同學(xué)放棄或者延遲學(xué)習(xí)robolectric。讀者可以參考《加速Robolectric下載依賴庫(kù)及原理剖析》贵少,徹底解決這個(gè)問(wèn)題呵俏。
其次,就是配置麻煩滔灶,從2.x到3.x版本普碎,配置一直改動(dòng)(其實(shí)是越來(lái)越精簡(jiǎn)),2.x版本的配置到3.x版本录平,就有問(wèn)題麻车,不得不重新看官方文檔如何配置。有時(shí)不知道是改了gradle
版本還是什么原因斗这,配置沒(méi)變动猬,就給你報(bào)錯(cuò),常見的"No such manifest file: build\intermediates\bundles\debug\AndroidManifest.xml"
......
至于運(yùn)行效率涝影,由于Robolectric是一個(gè)大而全的框架枣察,單元測(cè)試到UI測(cè)試都能做,運(yùn)行時(shí)先解析燃逻、加載一大堆東西序目,才給你跑測(cè)試。筆者研究過(guò)源碼伯襟,前期解析慢主要是UI方面猿涨,如果只是測(cè)SharePreference
和SQLiteDatabase
根本不需要,就想不明白R(shí)obolectric團(tuán)隊(duì)為什么不把SharePreference
和SQLiteDatabase
配置分離出來(lái)姆怪,好讓單元測(cè)試跑快一點(diǎn)叛赚。
簡(jiǎn)單實(shí)驗(yàn),跑一個(gè)什么都不做的robolectric test case:
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class RoboCase {
@Test
public void testRobo() {
}
}
盡管你什么都不做稽揭,不好意思俺附,Robolectric就得運(yùn)行3秒!而且隨著工程代碼增加溪掀,這個(gè)時(shí)間有增無(wú)減事镣。如果跑一個(gè)什么都不做的Junit單元測(cè)試,1ms不到揪胃。筆者本文介紹的方法璃哟,跑簡(jiǎn)單的運(yùn)行測(cè)試時(shí)間在10~1000ms不等氛琢,視乎測(cè)試代碼復(fù)雜度,最快比Robolectric快140+倍随闪。
理解SharedPreferences
我們通過(guò)Context
獲取SharedPreferences
:
Context context;
SharedPreferences sharePref = context.getSharedPreferences("name", Context.MODE_PRIVATE);
getSharedPreferences
有name
和mode
參數(shù)阳似,傳不同的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
實(shí)際上只是一個(gè)接口铐伴,我們獲取的對(duì)象撮奏,是繼承該接口的android.app.SharedPreferencesImpl
。Android sdk沒(méi)有提供這個(gè)類当宴,讀者可閱讀源碼:SharedPreferencesImpl.java挽荡。
從功能上看,SharedPreferences
就是簡(jiǎn)單的kev-value數(shù)據(jù)庫(kù)即供,在app運(yùn)行時(shí),對(duì)SharedPreferences
儲(chǔ)存于微、讀取數(shù)據(jù)逗嫡,會(huì)存放在Android手機(jī)該app空間的文件里。
單元測(cè)試思路
首先株依,單元測(cè)試原則是每個(gè)測(cè)試用例的數(shù)據(jù)獨(dú)立驱证。因此,前一個(gè)測(cè)試用例在SharedPreferences
儲(chǔ)存的數(shù)據(jù)恋腕,下一個(gè)用例不應(yīng)該讀取到抹锄,SharedPreferences
就沒(méi)有必要真的把數(shù)據(jù)儲(chǔ)存在文件了,只需要存放在jvm內(nèi)存就足夠荠藤。
既然SharedPreferences
的功能用內(nèi)存實(shí)現(xiàn)伙单,那么java代碼就能輕易實(shí)現(xiàn)key-value儲(chǔ)存,原理跟java.util.Map
如出一轍哈肖。
代碼實(shí)現(xiàn)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();
// 對(duì)比前后變化
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;
}
/**
* 監(jiān)聽對(duì)應(yīng)的key值的變化吻育,只有當(dāng)key對(duì)應(yīng)的value值發(fā)生變化時(shí),才會(huì)觸發(fā)
*
* @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);
// 每次提交清空緩存數(shù)據(jù)
map.clear();
commitClear = false;
removeList.clear();
}
}
}
SharePreferenceHelper:
public class SharePreferenceHelper {
public static SharedPreferences newInstance() {
return new ShadowSharePreference();
}
}
只需要兩個(gè)類淤井,準(zhǔn)備工作就大功告成了布疼,非常簡(jiǎn)單!
跑單元測(cè)試
BookDAO:
public class BookDAO {
SharedPreferences mSharedPre;
SharedPreferences.Editor mEditor;
// 單元測(cè)試調(diào)用币狠,注意聲明protected
protected BookDAO(SharedPreferences sharedPre) {
this.mSharedPre = sharedPre;
this.mEditor = sharedPre.edit();
}
// 正常代碼調(diào)用
public BookDAO(Context context) {
this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
}
/**
* 設(shè)置某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
有兩個(gè)構(gòu)造方法游两,BookDAO(SharedPreferences sharedPre)
和BookDAO(Context context)
,由于單元測(cè)試沒(méi)有Context漩绵,因此直接創(chuàng)建SharedPreferences對(duì)象即可贱案。
BookDAOTest單元測(cè)試:
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));
// 設(shè)置已讀
bookDAO.setBookRead(bookId, true);
// 已讀
Assert.assertTrue(bookDAO.isBookRead(bookId));
}
}
僅需要12ms,非辰バ校快轰坊,而且不需要任何配置铸董。
進(jìn)階
場(chǎng)景測(cè)試
你本來(lái)有BookDAO
,后來(lái)重構(gòu)肴沫,需要新增或者拋棄一些方法或者其他原因粟害,寫一個(gè)BookDAOV2
。這個(gè)BookDAOV2
與BookDAO
的數(shù)據(jù)共享颤芬,意味著用同一個(gè)SharedPreferences悲幅。
單元測(cè)試怎么寫呢?
public class BookDAOV2 {
SharedPreferences mSharedPre;
SharedPreferences.Editor mEditor;
protected BookDAOV2(SharedPreferences sharedPre) {
this.mSharedPre = sharedPre;
this.mEditor = sharedPre.edit();
}
public BookDAOV2(Context context) {
// 與BookDAO使用同一個(gè)SharedPreferences
this(context.getSharedPreferences("book", Context.MODE_PRIVATE));
}
public void clearAllRead() {
mEditor.clear();
mEditor.commit();
}
}
測(cè)試用例:
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;
// 設(shè)置已讀
bookDAO.setBookRead(bookId, true);
// 已讀
Assert.assertTrue(bookDAO.isBookRead(bookId));
// DAOV2 清除已讀
bookDAOV2.clearAllRead();
// 未讀
Assert.assertFalse(bookDAO.isBookRead(bookId));
}
}
但是這樣不太優(yōu)雅站蝠,能不能調(diào)用SharedPreferencesHelper
同一個(gè)方法汰具,返回同一個(gè)SharedPreferences
呢?
通過(guò)name獲取不同SharedPreferences
context.getSharedPreferences(name, mode)
可以改變name
類獲取不同SharedPreferences
對(duì)象菱魔,這些SharedPreferences
彼此數(shù)據(jù)獨(dú)立留荔。
因此,我們?cè)?code>SharePreferenceHelper加兩個(gè)靜態(tài)方法:
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();
}
......
}
我們調(diào)用SharePreferenceHelper.getInstance(name)
就可以獲取name對(duì)應(yīng)不同ShadowSharedPreferences
澜倦。
跑個(gè)測(cè)試:
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);
}
}
結(jié)果當(dāng)然是兩個(gè)都pass啦聚蝶!
處理Test Case前后數(shù)據(jù)干擾
運(yùn)行一次單元測(cè)試,無(wú)論Test Case多少藻治,jvm只啟動(dòng)一次碘勉,因此,靜態(tài)變量就會(huì)一直存在桩卵,直到該次單元測(cè)試完成验靡。問(wèn)題就出現(xiàn)了:上面介紹的SharedPreferenceHelper.getInstance(name)
,是通過(guò)static Map<String, SharedPreferences>
緩存SharedPreferences
對(duì)象雏节,所以胜嗓,同一次單元測(cè)試,上一個(gè)Test Case儲(chǔ)存的數(shù)據(jù)矾屯,會(huì)影響下一個(gè)Test Case兼蕊。
下面的單元測(cè)試,先執(zhí)行testA()
件蚕,儲(chǔ)存key=1孙技,在執(zhí)行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()的數(shù)據(jù),不應(yīng)該影響testB()
Assert.assertEquals(0, mSharedPref.getInt("key", 0));
}
}
很遺憾排作,testA()
的數(shù)據(jù)影響到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()
方法回調(diào)時(shí),調(diào)用SharedPreferenceHelper.clean()
妄痪,再運(yùn)行一次:
@FixMethodOrder(MethodSorters.NAME_ASCENDING) // 按case名稱字母順序排序
public class DistractionTest {
...
@After
public void tearDown() throws Exception {
SharedPreferencesHelper.clean();
}
...
}
統(tǒng)一處理tearDown()
如果我們每個(gè)Test Case都要寫testDown()
處理SharedPreferences
緩存哈雏,未免太不優(yōu)雅。我們可以借助TestRule
類完成。
SharedPrefRule:
public class SharedPrefRule extends ExternalResource {
@Override
protected void after() {
// 每測(cè)試完一個(gè)用例方法裳瘪,就回調(diào)
SharedPreferencesHelper.clean();
}
}
SharedPrefCase:
public class SharedPrefCase {
@Rule
public SharedPrefRule rule = new SharedPrefRule();
public SharedPreferences getInstance(String name) {
return SharedPreferencesHelper.getInstance(name);
}
}
于是土浸,我們所以SharedPrefences測(cè)試用例,都繼承SharedPrefCase
:
public class MySharedPrefTest extends SharedPrefCase {
SharedPreferences mSharedPre;
@Before
public void setUp() throws Exception {
mSharedPre = getInstance("name");
}
}
這樣彭羹,數(shù)據(jù)干擾的問(wèn)題就解決了黄伊。
修改BookUpdateTest
上文提到的BookDAO
和BookDAOV2
單元測(cè)試,可以修改如下:
public class BookUpdateTest extends SharedPrefCase {
@Before
public void setUp() throws Exception {
bookDAO = new BookDAO(getInstance("book"));
bookDAOV2 = new BookDAOV2(getInstance("book"));
}
}
比之前優(yōu)雅多了派殷。
Context獲取SharedPreferences
很多同學(xué)都會(huì)在Application.onCreate()
時(shí)还最,在某個(gè)地方把ApplicationContext
存起來(lái),方便其他地方獲取毡惜。然后拓轻,在DAO里面直接用這個(gè)Context
獲取SharedPreferences
。按照筆者的方法经伙,單元測(cè)試時(shí)扶叉,每個(gè)DAO都要傳一個(gè)新創(chuàng)建的SharedPreferences
。但有的同學(xué)就是懶帕膜,有其他更好的方式嗎辜梳?
你的代碼可能是這樣:
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);
}
}
我們的問(wèn)題是,如何讓context.getSharedPreferences
返回一個(gè)SharedPreferences
泳叠。借助一下mockito來(lái)實(shí)現(xiàn),修改SharedPrefRule
:
public class SharedPrefRule extends ExternalResource {
@Override
protected void before() throws Throwable {
Context context = mock(Context.class);
// 調(diào)用context.getSharedPreferences(name)時(shí)茶宵,執(zhí)行SharedPreferencesHelper.getInstance(name)危纫,返回結(jié)果
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());
// 設(shè)置Context
ContextProvider.setContext(context);
}
...
}
開源一個(gè)SharePreferences單元測(cè)試框架
本文的重頭戲——開源框架!
聽起來(lái)好像很屌的樣子乌庶,其實(shí)就是那么幾個(gè)類种蝶,見笑_. 上述的代碼,筆者整理成項(xiàng)目瞒大,在github開源螃征,并且發(fā)布到j(luò)itpack. 讀者可以免費(fèi)使用,通過(guò)gradle依賴透敌。
開源框架命名很頭痛盯滚,就叫SPTestFramework吧!
不需要Robolectric即可測(cè)試SharedPreferences酗电,SPTestFramework你值得擁有魄藕!
SPTestFramework項(xiàng)目是什么?
SPTestFramework(簡(jiǎn)稱SPTest)是一個(gè)SharedPreferences單元測(cè)試框架撵术。項(xiàng)目自帶單元測(cè)試背率,確保測(cè)試框架代碼質(zhì)量和功能正確。
同時(shí),歡迎各位同學(xué)使用寝姿、測(cè)試交排、提出問(wèn)題!
項(xiàng)目地址:https://github.com/kkmike999/SPTestFramework
關(guān)于作者
我是鍵盤男饵筑。
在廣州生活埃篓,悅跑圈Android工程師,猥瑣文藝碼農(nóng)翻翩。每天謀劃砍死產(chǎn)品經(jīng)理都许。喜歡科學(xué)、歷史嫂冻,玩玩投資胶征,偶爾旅行。