zaterdag 5 september 2015

Robolectric and a Custom Shadow for Testing a Snackbar

Robolectric allows you to test your code and assess its correct behavior by using/providing Shadows. As with many things in Robolectric, a poorly documented part remains on how to create Custom Shadows.

Some will actually point out that it is bad practice to create your own Shadow classes, but what if you use libraries provided by Google, which are yet to be integrated in the list of Shadow-classes?

Side-note: take an example on the HttpClient class, it has been deprecated as of API level 23 (well, removed to be correct). But you can't run any tests with Robolectric 3.0 if you don't include the HttpClient. Quite a nuisance. Something needs to be done to keep Robolectric viable for the future.

One of the tests which I needed to complete is to test if a Snackbar notification is displayed when performing certain actions.

TL;DR

  • A Shadow class will mimic the behavior of the original class, and is extended with facilitators to be able to test the result accordingly.
  • You will need to load your ShadowClass as InstrumentedClass in order to get it working;
  • You'll need to apply some Reflection on the Snackbar to be able to instantiate an object (otherwise, you'll get these nasty StackOverflowError's)

The Custom Shadow Class


  • Your ShadowClass will be annotated with @Implements(Original.class). This will tell Robolectric which class you actually are Shadowing;
  • I've kept a list of Shadowed Snackbars as a static member, which allows to keep track of the objects which are created;
  • The Reflection will search for the Snackbar(Viewgroup group) constructor. This constructor is used when the Snackbar.make(...) method is invoked. When the constructor is found we'll modify the access from private to public by setting it accessible. We can then create a new instances with the found and updated constructor.
  • The newly created class is then 'shadowed' via the ShadowExtractor.extract(object) method, and not the ShadowOf_(object) as indicated in the documentation. This method actually doesn't exists anymore as of Robolectric 3.0.
  • Set the text on the ShadowSnackbar and keep a reference to it.

PS: this is just the first version of the code, I'll update it to make it a bit prettier.



ShadowSnackbar.java:

package be.acuzio.mrta.test.shadow;

import android.support.annotation.NonNull;
import android.support.annotation.StringRes;
import android.support.design.widget.CoordinatorLayout;
import android.support.design.widget.Snackbar;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.FrameLayout;

import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;
import org.robolectric.internal.ShadowExtractor;

import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;

@SuppressWarnings({"UnusedDeclaration", "Unchecked"})
@Implements(Snackbar.class)
public class ShadowSnackbar {
    static List&lt:ShadowSnackbar> shadowSnackbars = new ArrayList<>();

    @RealObject
    Snackbar snackbar;

    String text;
    private int duration;
    private int gravity;
    private int xOffset;
    private int yOffset;
    private View view;

    @Implementation
    public static Snackbar make(@NonNull View view, @NonNull CharSequence text, int duration) {
        Snackbar snackbar = null;

        try {
            Constructor<Snackbar> constructor = Snackbar.class.getDeclaredConstructor(ViewGroup.class);

            //just in case, maybe they'll change the method signature in the future
            if (null == constructor)
                throw new IllegalArgumentException("Seems like the constructor was not found!");


            if (Modifier.isPrivate(constructor.getModifiers())) {
                constructor.setAccessible(true);
            }

            snackbar = constructor.newInstance(findSuitableParent(view));
            snackbar.setText(text);
            snackbar.setDuration(duration);
        } catch (Exception e) {
            e.printStackTrace();
        }

        shadowOf(snackbar).text = text.toString();

        shadowSnackbars.add(shadowOf(snackbar));

        return snackbar;
    }

    //this code is fetched from the decompiled Snackbar.class. 
    private static ViewGroup findSuitableParent(View view) {
        ViewGroup fallback = null;

        do {
            if (view instanceof CoordinatorLayout) {
                return (ViewGroup) view;
            }

            if (view instanceof FrameLayout) {
//                if(view.getId() == 16908290) {
//                    return (ViewGroup)view;
//                }

                fallback = (ViewGroup) view;
            }

            if (view != null) {
                ViewParent parent = view.getParent();
                view = parent instanceof View ? (View) parent : null;
            }
        } while (view != null);

        return fallback;
    }

    // this is one of the methods which your actual Android code might invoke
    @Implementation
    public static Snackbar make(@NonNull View view, @StringRes int resId, int duration) {
        return make(view, view.getResources().getText(resId), duration);
    }


    //just a facilitator to get the shadow
    static ShadowSnackbar shadowOf(Snackbar bar) {
        return (ShadowSnackbar) ShadowExtractor.extract(bar);
    }

    //handy for when running tests, empty the list of snackbars
    public static void reset() {
        shadowSnackbars.clear();
    }

    //some non-Android related facilitators
    public static int shownSnackbarCount() {
        return shadowSnackbars.isEmpty() ? 0 : shadowSnackbars.size();

    }

    //taken from the modus-operandus of the ShadowToast
    //a facilitator to get the text of the latest created Snackbar
    public static String getTextOfLatestSnackbar() {
        if (!shadowSnackbars.isEmpty())
            return shadowSnackbars.get(shadowSnackbars.size() - 1).text;

        return null;
    }

    //retrieve the latest snackbar that was created
    public static Snackbar getLatestSnackbar() {
        if (!shadowSnackbars.isEmpty())
            return shadowSnackbars.get(shadowSnackbars.size() - 1).snackbar;

        return null;
    }
}

Robolectric Test Runner 

Add this ShadowClass to the custom build runner, otherwise you'll not be able to use the shadow or it won't be invoked when the code is tested.

RobolectricGradleTestRunner.java

package be.acuzio.mrta;

import android.support.design.widget.Snackbar;

import org.junit.runners.model.InitializationError;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.internal.bytecode.InstrumentationConfiguration;
import org.robolectric.manifest.AndroidManifest;
import org.robolectric.res.FileFsFile;
import org.robolectric.res.FsFile;

public class RobolectricGradleTestRunner extends RobolectricTestRunner {

    public RobolectricGradleTestRunner(Class testClass) throws InitializationError {
        super(testClass);
    }

    @Override
    protected AndroidManifest getAppManifest(Config config) {
        AndroidManifest appManifest = super.getAppManifest(config);
        FsFile androidManifestFile = appManifest != null ? appManifest.getAndroidManifestFile() : null;

        if (null != androidManifestFile && androidManifestFile.exists()) {
            return appManifest;
        } else {
            String moduleRoot = getModuleRootPath(config);

            androidManifestFile = FileFsFile.from(moduleRoot, "src/main/AndroidManifest.xml");
            FsFile resDirectory = FileFsFile.from(moduleRoot, "src/main/res/");
            FsFile assetDirectory = FileFsFile.from(moduleRoot, "src/main/assets");

            return new AndroidManifest(androidManifestFile, resDirectory, assetDirectory, "be.acuzio.mrta");
        }
    }

    private String getModuleRootPath(Config config) {
        String moduleRoot = config.constants().getResource("").toString().replace("file:", "");
        return moduleRoot.substring(0, moduleRoot.indexOf("/build"));
    }

    //override this method to add the Snackbar as instrumented class
    @Override
    public InstrumentationConfiguration createClassLoaderConfig() {
        InstrumentationConfiguration.Builder builder = InstrumentationConfiguration.newBuilder();
        builder.addInstrumentedClass(Snackbar.class.getName());

        return builder.build();
    }
}

Using the ShadowClass

Once you configured the ShadowClass, you'll be configure the test. Add the ShadowClass to the @Config annotation. You'll now be able to validate the invocation of the Snackbar objects via the ShadowSnackbar class.

package be.acuzio.mrta.test.view.activity;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Notification;
import android.content.Intent;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;

import com.squareup.okhttp.mockwebserver.MockResponse;
import com.squareup.okhttp.mockwebserver.MockWebServer;

import junit.framework.Assert;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.shadows.ShadowToast;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.List;

...

@Config(sdk = 21,
        constants = be.acuzio.mrta.BuildConfig.class,
        manifest = Config.NONE,
        shadows = {ShadowSnackbar.class}
)
@RunWith(RobolectricGradleTestRunner.class)
public class EnrolmentActivitiesTest {
...
}

Geen opmerkingen:

Een reactie plaatsen