[譯] 通過測試來解耦 Activity

mnikn發表於2019-02-21

通過測試來解耦Activity

ActivityFragment,可能是因為一些奇怪的歷史巧合,從 Android 推出之時起就被視為構建 Android 應用的最佳構件。我們把ActivityFragment 是應用的最佳構件這種想法稱為“android-centric”架構。

本系列博文是關於 android-centric 架構的可測試性和其它問題之間的聯絡的,而這些問題正導致 Android 開發者們排斥這種架構。這些博文也涉及單元測試怎樣試圖告訴我們:ActivityFragment 不是應用的最佳構件,因為它們迫使我們寫出高耦合低內聚的程式碼。

上次,我們發現ActivityFragment有低內聚的傾向。這次,通過測試我們將會發現 Activity 是高耦合的。我們還會發現如何通過測試來驅使實現一個耦合度更低的設計,這樣我們就能輕易地改變應用和有更多的機會來減去重複程式碼。像本系列博文中的其他文章一樣,我們依然以 Google I/O 應用為例子進行探討。

目的碼

我們想要測試的“目的碼”,做了以下工作:當使用者進入展示所有 Google I/O session 的地圖介面時,app 會請求當前位置。如果使用者拒絕提供定位許可權,我們會彈出一個 toast 來提示使用者已禁用此許可權。這是其中的截圖:

[譯] 通過測試來解耦 Activity
拒絕請求的 toast

這是實現程式碼:

@Override
public void onRequestPermissionsResult(final int requestCode,
        @NonNull final String[] permissions,
        @NonNull final int[] grantResults) {

    if (requestCode != REQUEST_LOCATION_PERMISSION) {
        return;
    }

    if (permissions.length == 1 &&
            LOCATION_PERMISSION.equals(permissions[0]) &&
            grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        // Permission has been granted.
        if (mMapFragment != null) {
            mMapFragment.setMyLocationEnabled(true);
        }
    } else {
        // Permission was denied. Display error message.
        Toast.makeText(this, R.string.map_permission_denied,
                Toast.LENGTH_SHORT).show();
    }
    super.onRequestPermissionsResult(requestCode, permissions,
            grantResults);
}複製程式碼

測試程式碼

讓我們嘗試測試下這些程式碼,我們的測試程式碼看起來是這樣的:

@Test
public void showsToastIfPermissionIsRejected()
        throws Exception {
    MapActivity mapActivity = new MapActivity();

    mapActivity.onRequestPermissionsResult(
            MapActivity.REQUEST_LOCATION_PERMISSION,
            new String[]{MapActivity.LOCATION_PERMISSION}, new int[]{
                    PackageManager.PERMISSION_DENIED});

    assertToastDisplayed();
}複製程式碼

當然你很希望能知道 assertToastDisplayed() 是怎麼實現的。重點來了:我們不會直接實現該方法。為了避免實現後再重構我們的程式碼,我們需要使用 Roboelectric 和 Powermock。(譯者注:Roboelectric 和 Powermock 均為測試框架)

不過,既然我們更希望根據測試來改變我們寫程式碼的方式,而不是僅僅改變寫測試的方式,我們要停一會來想一想這些測試想要告訴我們什麼事情:

我們在 MapActivity 裡面的程式碼邏輯和 Toast 緊密地耦合在一起。

這之間的耦合驅使我們使用 Roboelectric 來模擬 android 行為和 powermock 來模擬靜態的 Toast.makeText 方法。作為替換,讓我們以測試為驅動來去除耦合。

為了讓我們重構有個方向,我們先寫測試。這將確保我們的類已經解耦。為了避免使用 Roboelectric 框架,我們需要在這特殊情況下建立一個新類,但是通常來說,我們只需重構已存在的類來解耦。

@Test
public void displaysErrorWhenPermissionRejected() throws Exception {

    OnPermissionResultListener onPermissionResultListener =
            new OnPermissionResultListener(mPermittedView);

    onPermissionResultListener.onPermissionResult(
            MapActivity.REQUEST_LOCATION_PERMISSION,
            new String[]{MapActivity.LOCATION_PERMISSION},
            new int[]{PackageManager.PERMISSION_DENIED});

    verify(mPermittedView).displayPermissionDenied();
}複製程式碼

我們已經介紹過 OnPermissionResultListener,它的工作就是處理使用者對 app 請求許可權的反應。程式碼如下:

void onPermissionResult(final int requestCode,
            final String[] permissions, final int[] grantResults) {
    if (requestCode != MapActivity.REQUEST_LOCATION_PERMISSION) {
        return;
    }

    if (permissions.length == 1 &&
            MapActivity.LOCATION_PERMISSION.equals(permissions[0]) &&
            grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        // Permission has been granted.
        mPermittedView.displayPermittedView();

    } else {
        // Permission was denied. Display error message.
        mPermittedView.displayPermissionDenied();
    }
}複製程式碼

我們把對 MapFragmentToast 的呼叫替換為對 PermittedView 裡面方法的呼叫,這個物件通過建構函式來傳遞。PermittedView 是一個介面:

interface PermittedView {
    void displayPermissionDenied();

    void displayPermittedView();
}複製程式碼

它在 MapActivity 裡實現:

public class MapActivity extends BaseActivity
        implements SlideableInfoFragment.Callback, MapFragment.Callbacks,
        ActivityCompat.OnRequestPermissionsResultCallback,
        OnPermissionResultListener.PermittedView {
    @Override
    public void displayPermissionDenied() {
        Toast.makeText(MapActivity.this, R.string.map_permission_denied,
                Toast.LENGTH_SHORT).show();
    }
}複製程式碼

這也許不是最好的解決方案,但是這能讓我們抓住可以在哪裡測試這一重心。這要求 OnPermissionResultListener 降低和 PermittedView 的耦合度。解耦 == 顯而易見的進步。

有必要麼?

對於這一點,一些讀者可能會有所懷疑。“這樣真的算優化程式碼嗎?”他們會大惑不解。有兩點理由可以確認為什麼這樣設計更好

(無論我給出哪一個理由,你都會發現其解釋是“因為它的可測試性更好,所以它設計得更好”,這是一個很重要的原因。)

更容易改變

首先,因為所組成的內容耦合度低,從而能夠更容易地改變程式碼,而且更精彩的是:我們剛剛測試 Google I/O 應用的程式碼實際上已經改變了,通過我們的測試,能讓其改程式碼變得更容易。所測試的程式碼來自一個較舊的 commit。之後,寫 I/O 應用的人們決定把 Toast 替換為 Snackbar

[譯] 通過測試來解耦 Activity
snackbar 拒絕請求

這是一個小改變,但是因為我們已經把 OnPermissionResultListenerPermittedView 中分離出來,我們可以只專注於改變 PermittedViewMapActivity 裡面的實現,而無需擔心 OnPermissionResultListener

這是我們改變程式碼後的樣子,使用他們的 PermissionUtils 類來顯示 SnackBar

@Override
public void displayPermissionDenied() {
    PermissionsUtils.displayConditionalPermissionDenialSnackbar(this,
            R.string.map_permission_denied, new String[]{LOCATION_PERMISSION},
            REQUEST_LOCATION_PERMISSION);
}複製程式碼

請再留意,我們可以不用考慮 OnPermissionResultListener 就直接改變其內容。這實際就是 Larry Constantine 在 70 年代提出對耦合這一概念的定義:

我們盡力讓系統解耦。。。這樣我們就能研究(或者除錯、維護)其中一個模組而無需考慮系統中的其他模組

–Edward Yourdon and Larry Constantine, Structured Design

去重

另一個“為什麼實際上通過我們的測試來迫使我們解耦是一件好事”的有趣原因是:耦合通常會導致重複。Kent Beck 曾對此有相關看法:

依賴是任意規模的軟體開發的重點問題。。。如果依賴成為了問題,這就會體現在重複上。

-Kent Beck, TDD By Example, pg 7.

如果這是對的,當我們解耦,我們將會發現更多的去重機會。的確,在我們這次案例中這個觀點顯得很準確。事實上有另外一個類的 onRequestPermissionsResultMapActivity 的幾乎一樣:AccountFragment。我們的測試指引我們來建立 OnPermissionResultListenerPermittedView 這兩個介面,因此無需任何修改就可以在其他類中複用。

結論

所以,當我們難以測試 ActivityFragment時,通常是因為我們的測試嘗試告訴我們所寫的程式碼耦合度太高。測試對耦合度的警告通常以我們無法對程式碼做出斷言的形式表現出來。

當我們聽從我們的測試時,與其通過 Roboelectric 和 powermock 替換測試程式碼,不如改變被測程式碼,讓其耦合度降低,這樣我們就能更容易改程式碼和有更多的機會去重。

注意

  1. 這也可能表現為無法讓你的被測程式碼在測試中以一個正確的狀態表現出來。例如我們在本篇中所看到的。

我們在 Unikey 招聘中級 Android 開發者。如果你想要在 Orlando 智慧鎖定空間裡的一間初創公司工作,請發郵件給我。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章