SharedPreferences のモックを作る、たった一つの冴えたやり方

`SharedPreferences`を使った、カンタンな KVS をデータソースに持つモデルを作った時、そのモデルをテストしようとすると、どうしても `SharedPreferences`をモックしたくなります。

`SharedPreferences`は、その取得方法によって、2 種類のものが存在します。

1 つは、`Context#getSharedPreferences(String, int)`で、自分で好きに名前を決めた`SharedPreferences`。もう一つは、`PreferenceManager#getDefaultSharedPreferences(Context)`で、システムによって生成された`SharedPreferences`です。

`SharedPreferences`そのものはインタフェースですので、`SharedPreferences`のインスタンスをモックするのであれば、以下のようにモックのクラスを作って、`Context`から返すインスタンスをそのモックのオブジェクトにすれば良いだけです。

```java:MockSharedPreferences.java

import android.content.SharedPreferences;
import java.util.Map;
import java.util.Set;
public class MockSharedPreferences implements SharedPreferences {
    @Override
    public boolean contains(String key) {
        throw new UnsupportedOperationException();
    }
    @Override
    public Editor edit() {
        throw new UnsupportedOperationException();
    }
    @Override
    public Map<String, ?> getAll() {
        throw new UnsupportedOperationException();
    }
    @Override
    public boolean getBoolean(String key, boolean defValue) {
        throw new UnsupportedOperationException();
    }
    @Override
    public float getFloat(String key, float defValue) {
        throw new UnsupportedOperationException();
    }
    @Override
    public int getInt(String key, int defValue) {
        throw new UnsupportedOperationException();
    }
    @Override
    public long getLong(String key, long defValue) {
        throw new UnsupportedOperationException();
    }
    @Override
    public String getString(String key, String defValue) {
        throw new UnsupportedOperationException();
    }
    @Override
    public Set<String> getStringSet(String arg0, Set<String> arg1) {
        throw new UnsupportedOperationException();
    }
    @Override
    public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        throw new UnsupportedOperationException();
    }
    @Override
    public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
        throw new UnsupportedOperationException();
    }
    public static class MockEditor implements Editor {
        @Override
        public void apply() {
            throw new UnsupportedOperationException();            
        }
        @Override
        public Editor clear() {
            throw new UnsupportedOperationException();
        }
        @Override
        public boolean commit() {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putBoolean(String key, boolean value) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putFloat(String key, float value) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putInt(String key, int value) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putLong(String key, long value) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putString(String key, String value) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor putStringSet(String arg0, Set<String> arg1) {
            throw new UnsupportedOperationException();
        }
        @Override
        public Editor remove(String key) {
            throw new UnsupportedOperationException();
        }
    }
}
```

```java:MockPreferenceContext.java

public class MockPreferenceContext extends MockContext {
    private Context mTestContext;

    public MockPreferenceContext(Context context) {
        mTestContext = context;
    }

    @Override
    public String getPackageName() {
        return mTestContext.getPackageName();
    }

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return new MockSharedPreferences();
    }
}
```

このようにしておけば、どの方法で`SharedPreferences`を取得したとしても、少なくともインスタンスをモックした状態でテストができます。

さて、`SharedPreferences`をモックするということは、実データへの書き込み等もすべて無視して、自分が好きに書き換えた振る舞いしかしないことになります。これを、実データへの書き込み等の振る舞いを変えないで、しかし、本番データをいじらないようにするようなテストを書く場合、どうしたらよいかということが問題になります。

`SharedPreferences`は、`Context#getSharedPreferences(String, int)`で取得する場合は、引数に与えた名前をファイル名に、`PreferenceManager#getDefaultSharedPreferences(Context)`で取得する場合は、`{$package_name}_preferences.xml`をファイル名にしてデータをストレージに保存しています。

つまり、`Context#getSharedPreferences(String, int)`を使って`SharedPreferences`を取得する場合、`Context#getSharedPreferences(String, int)`をモックした上で、引数の文字列に適当な文字列を付け加えて、親へ丸投げしてしまえば、テスト用にファイルを用意した状態で、振る舞いを得ることなく`SharedPreferences`のインスタンスを生成できるようになります。

```java:MockPreferenceContext.java

public class MockPreferenceContext extends MockContext {
    private Context mTestContext;

    public MockPreferenceContext(Context context) {
        mTestContext = context;
    }

    @Override
    public String getPackageName() {
        return mTestContext.getPackageName();
    }

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return mTestContext.getSharedPreferences(name + ".test", mode);
    }
}
```

一方、`PreferenceManager#getDefaultSharedPreferences(Context)`は static なメソッドです。
つまり、Mockito とか使わない限り、このメソッドの振る舞いをモックすることは叶いません。
ただ、先にも述べたように、このメソッドは、`{$package_name}_preferences.xml`をファイル名にした`SharedPreferences`を取得するように作られています。つまり、以下のようにモックした`Context`を渡してやれば、テスト用にファイルをモックしたものが得られることになります。

```java:MockPreferenceContext.java

public class MockPreferenceContext extends MockContext {
    private Context mTestContext;

    public MockPreferenceContext(Context context) {
        mTestContext = context;
    }

    @Override
    public String getPackageName() {
        return mTestContext.getPackageName() + ".test";
    }

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return mTestContext.getSharedPreferences(name + ".test", mode);
    }
}
```

テスト用に、データベースを別の場所に向けてくれる`IsolatedContext`や、ファイルストレージを別の場所にむけてくれる`RenamingDelegatingContext`などがある中、`SharedPreferences`のファイルを別のところに向けてくれる`Context`が無いのはなかなか苦労しますが、一度作ってしまえばどこでも使えるようになるので便利ですね。