0

tl;dr: In Unit Testing, @Inject successfully injects the fields required but @InjectView leaves them as null, causing errors. @InjectView works in normal application running.


I have an Android application that utilises RoboGuice that I'm trying to Unit Test. One of the activities I'm trying to test looks like this:

@ContentView(R.layout.activity_login)
public class LoginActivity extends RoboActivity {

    /**
     * Our injected API class.
     */
    @Inject
    private Api apiConn;

    /**
     * Main application state class.
     */
    @Inject
    private Application appState;

    /**
     * Keep track of the login task to ensure we can cancel it if requested.
     */
    private UserLoginTask mAuthTask = null;

    /**
     * Logo header
     */
    @InjectView(R.id.logoHeader)
    private TextView mLogoHeader;

    /**
     * Signin button.
     */
    @InjectView(R.id.email_sign_in_button)
    private Button mEmailSignInButton;

    /**
     * Email text view.
     */
    @InjectView(R.id.email)
    private EditText mEmailView;

    /**
     * Password text view.
     */
    @InjectView(R.id.password)
    private EditText mPasswordView;

    /**
     * Login progress view.
     */
    @InjectView(R.id.login_progress)
    private View mProgressView;

    /**
     * Login form view.
     */
    @InjectView(R.id.login_form)
    private View mLoginFormView;

    /**
     * Fired when Activity has been requested.
     */
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Check if the server is actually online
        PingTask ping = new PingTask();
        ping.execute();

        // Check if the user is already logged in.
        if (appState.hasLoginToken() && appState.getLoginToken().isValid()) {
            // Hide everything!
            mLogoHeader.setVisibility(View.INVISIBLE);
            mEmailView.setVisibility(View.INVISIBLE);
            mPasswordView.setVisibility(View.INVISIBLE);
            mEmailSignInButton.setVisibility(View.INVISIBLE);

            // The user already has a token, let's validate it and pull in extra information.
            AuthenticateTask authenticate = new AuthenticateTask();
            authenticate.execute();
            return;
        }
        appState.clearSettings();

        // Set up the login form.
        mPasswordView.setOnEditorActionListener(new TextView.OnEditorActionListener() {
            @Override
            public boolean onEditorAction(TextView textView, int id, KeyEvent keyEvent) {
                if (id == R.id.login || id == EditorInfo.IME_NULL) {
                    attemptLogin();
                    return true;
                }
                return false;
            }
        });

        mEmailSignInButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                attemptLogin();
            }
        });
    }

    /**
     * Attempts to sign in or register the account specified by the login form.
     * If there are form errors (invalid email, missing fields, etc.), the
     * errors are presented and no actual login attempt is made.
     */
    void attemptLogin() {
        if (mAuthTask != null) {
            return;
        }

        // Reset errors.
        mEmailView.setError(null);
        mPasswordView.setError(null);

        // Store values at the time of the login attempt.
        String email = mEmailView.getText().toString();
        String password = mPasswordView.getText().toString();

        boolean cancel = false;
        View focusView = null;

        // Check for a valid password, if the user entered one.
        if (!TextUtils.isEmpty(password) && !isPasswordValid(password)) {
            mPasswordView.setError(getString(R.string.error_invalid_password));
            focusView = mPasswordView;
            cancel = true;
        }

        // Check for a valid email address.
        if (TextUtils.isEmpty(email)) {
            mEmailView.setError(getString(R.string.error_field_required));
            focusView = mEmailView;
            cancel = true;
        } else if (!isEmailValid(email)) {
            mEmailView.setError(getString(R.string.error_invalid_email));
            focusView = mEmailView;
            cancel = true;
        }

        if (cancel) {
            // There was an error; don't attempt login and focus the first
            // form field with an error.
            focusView.requestFocus();
        } else {
            // Show a progress spinner, and kick off a background task to
            // perform the user login attempt.
            showProgress(true);
            mAuthTask = new UserLoginTask(email, password);
            mAuthTask.execute((Void) null);
        }
    }

    /**
     * Checks if a given email address is valid.
     *
     * @param email Email address to check.
     *
     * @return Whether the email is valid or not.
     */
    private boolean isEmailValid(String email) {
        String ePattern = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$";

        Pattern p = Pattern.compile(ePattern);
        Matcher m = p.matcher(email);

        return m.matches();
    }

    /**
     * Checks if a given password is valid.
     *
     * @param password Password to check.
     *
     * @return Whether the password is valid or not.
     */
    private boolean isPasswordValid(CharSequence password) {
        //TODO: Is this needed?
        return password.length() > 4;
    }

    /**
     * Shows the progress UI and hides the login form.
     */
    @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR2)
    void showProgress(final boolean show) {
        // On Honeycomb MR2 we have the ViewPropertyAnimator APIs, which allow
        // for very easy animations. If available, use these APIs to fade-in
        // the progress spinner.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) {
            int shortAnimTime = getResources().getInteger(android.R.integer.config_shortAnimTime);

            mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
            mLoginFormView.animate().setDuration(shortAnimTime).alpha(
                    show ? 0 : 1).setListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
                }
            });

            mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
            mProgressView.animate().setDuration(shortAnimTime).alpha(
                    show ? 1 : 0).setListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
                }
            });
        } else {
            // The ViewPropertyAnimator APIs are not available, so simply show
            // and hide the relevant UI components.
            mProgressView.setVisibility(show ? View.VISIBLE : View.GONE);
            mLoginFormView.setVisibility(show ? View.GONE : View.VISIBLE);
        }
    }

    /**
     * Stop hardware back button functionality.
     */
    @Override
    public void onBackPressed() {
    }

    /**
     * User Login Task.
     */
    public class UserLoginTask extends AsyncTask<Void, Void, Boolean> {

        /**
         * Email to login with.
         */
        private final String mEmail;

        /**
         * Password to login with.
         */
        private final String mPassword;

        /**
         * Constructor.
         *
         * @param email    Email to login with.
         * @param password Password to login with.
         */
        public UserLoginTask(String email, String password) {
            mEmail = email;
            mPassword = password;
        }

        /**
         * Main task.
         *
         * @param params Parameters.
         *
         * @return Whether login was successful or not.
         */
        @Override
        protected Boolean doInBackground(Void... params) {
            JSONObject responseJson;
            boolean success;
            String token;
            JSONArray drivers;
            JSONArray moods;
            JSONArray vehicles;
            try {
                // Contact API.
                String response = apiConn.login(mEmail, mPassword);
                responseJson = new JSONObject(response);
                success = responseJson.getBoolean("success");
                JSONObject data = responseJson.getJSONObject("data");
                token = data.getString("token");
                drivers = data.getJSONArray("drivers");
                moods = data.getJSONArray("moods");
                vehicles = data.getJSONArray("vehicles");
            } catch (ApiException e) {
                return false;
            } catch (JSONException e) {
                return false;
            }

            if (!success) {
                return false;
            }

            // Persist data.
            appState.setLoginToken(token);
            appState.setDrivers(drivers);
            appState.setMoods(moods);
            appState.setVehicles(vehicles);
            return true;
        }

        /**
         * Fired after the task has executed.
         *
         * @param success Whether the task was successful.
         */
        @Override
        protected void onPostExecute(final Boolean success) {
            mAuthTask = null;
            showProgress(false);

            if (success) {
                // Start chooser activity
                Intent chooserIntent = new Intent(getApplicationContext(), ChooserActivity.class);
                startActivity(chooserIntent);

                // Close task.
                finish();
            } else {
                mPasswordView.setError(getString(R.string.error_incorrect_password));
                mPasswordView.requestFocus();
            }
        }

        /**
         * Fired when the task was cancelled.
         */
        @Override
        protected void onCancelled() {
            mAuthTask = null;
            showProgress(false);
        }

    }

    /**
     * Ping task to test if API is available.
     */
    public class PingTask extends AsyncTask<Void, Void, Boolean> {

        /**
         * Alert dialog.
         */
        private AlertDialog.Builder alertDialog;

        /**
         * Fired before executing task.
         */
        @Override
        protected void onPreExecute() {
            super.onPreExecute();

            alertDialog = new AlertDialog.Builder(LoginActivity.this);
        }

        /**
         * Main task.
         *
         * @param params Parameters.
         *
         * @return Whether the API is online or not.
         */
        @Override
        protected Boolean doInBackground(Void... params) {
            boolean webOnline = apiConn.isWebOnline();
            Log.d("API", String.valueOf(webOnline));

            return webOnline;
        }

        /**
         * Fired after task was executed.
         *
         * @param success Whether the task was successful.
         */
        @Override
        protected void onPostExecute(final Boolean success) {
            super.onPostExecute(success);

            Log.d("API", String.valueOf(success));
            if (!success) {
                alertDialog.setTitle(getString(R.string.cannot_connect));
                alertDialog.setMessage(getString(R.string.cannot_connect_long));

                AlertDialog dialog = alertDialog.create();
                dialog.show();
            }
        }
    }

    /**
     * Authenticate with existing token task.
     */
    class AuthenticateTask extends AsyncTask<Void, Void, Boolean> {

        /**
         * Array of drivers from Api.
         */
        private JSONArray drivers;

        /**
         * Array of moods from Api.
         */
        private JSONArray moods;

        /**
         * Array of vehicle from Api.
         */
        private JSONArray vehicles;

        /**
         * Main task.
         *
         * @param params Parameters.
         *
         * @return Whether the task was successful or not.
         */
        @Override
        protected Boolean doInBackground(Void... params) {
            JSONObject responseJson;
            boolean success;
            drivers = null;
            moods = null;
            vehicles = null;
            try {
                String response = apiConn.authenticate(appState.getLoginToken());
                responseJson = new JSONObject(response);
                success = responseJson.getBoolean("success");
                JSONObject data = responseJson.getJSONObject("data");
                drivers = data.getJSONArray("drivers");
                moods = data.getJSONArray("moods");
                vehicles = data.getJSONArray("vehicles");
            } catch (ApiException | JSONException e) {
                success = false;
            }

            return success;
        }

        /**
         * Fired after the task has been executed.
         *
         * @param success Whether the task was successful.
         */
        @Override
        protected void onPostExecute(Boolean success) {
            super.onPostExecute(success);

            if (!success) {
                Log.d("JWT", "Token Invalid");
                // Token is invalid! Clear and show login form.
                appState.clearSettings();

                // Show the form again.
                mLogoHeader.setVisibility(View.VISIBLE);
                mEmailView.setVisibility(View.VISIBLE);
                mPasswordView.setVisibility(View.VISIBLE);
                mEmailSignInButton.setVisibility(View.VISIBLE);

                return;
            }

            appState.setDrivers(drivers);
            appState.setMoods(moods);
            appState.setVehicles(vehicles);

            Toast toast = Toast.makeText(getApplicationContext(), getString(R.string.welcome_back), Toast.LENGTH_SHORT);
            toast.show();

            // Start chooser activity
            Intent chooserIntent = new Intent(LoginActivity.this, ChooserActivity.class);
            startActivity(chooserIntent);
        }
    }
}

The problem with this is the onCreate method crashes saying that mPasswordView is null at this line:

mPasswordView.setOnEditorActionListener(new TextView.OnEditorActionListener() {

It turns out that mPasswordView is actually null. It should've been set by RoboGuice's @InjectView annotation which works when I use the application on my device but doesn't work for Unit Tests. @Inject is however working...

How can I get my unit tests to resolve views via @InjectView?


Extra classes

Here is my LoginActivityTest file:

@Config(manifest = "app/src/main/AndroidManifest.xml", emulateSdk = 18, reportSdk = 18)
@RunWith(RobolectricGradleTestRunner.class)
public class LoginActivityTest {

    private final Api apiMock = mock(Api.class);

    private final Application appMock = mock(Application.class);

    private final ActivityController<LoginActivity> controller = buildActivity(LoginActivity.class);

    @Before
    public void setup() {
        RoboGuice.overrideApplicationInjector(Robolectric.application, new LoginTestModule());
    }

    @After
    public void teardown() {
        RoboGuice.Util.reset();
    }

    @Test
    public void createTriggersPing() {
        ActivityController controller = Robolectric.buildActivity(LoginActivity.class);
        controller.create();
        controller.start();
        verify(apiMock).isWebOnline();
    }

    private class LoginTestModule extends AbstractModule {

        @Override
        protected void configure() {
            bind(Api.class).toInstance(apiMock);
            bind(Application.class).toInstance(appMock);
        }
    }
}

and my RobolectricGradleTestRunner class:

public class RobolectricGradleTestRunner extends RobolectricTestRunner {

    public RobolectricGradleTestRunner(Class<?> testClass) throws org.junit.runners.model.InitializationError {
        super(testClass);
    }

    @Override
    protected AndroidManifest getAppManifest(Config config) {
        String manifestProperty = System.getProperty("android.manifest");
        if (config.manifest().equals(Config.DEFAULT) && manifestProperty != null) {
            String resProperty = System.getProperty("android.resources");
            String assetsProperty = System.getProperty("android.assets");

            return new AndroidManifest(Fs.fileFromPath(manifestProperty), Fs.fileFromPath(resProperty),
                    Fs.fileFromPath(assetsProperty));
        }
        return super.getAppManifest(config);
    }
}
Jamesking56
  • 3,683
  • 5
  • 30
  • 61

1 Answers1

0

As it turned out this had nothing to do with Roboguice per se. There were some misconfigurations that were particular to the OP's setup. Solved it over chat. The issues were using Roboguice's @ContentView (it does not play nice with tests apparently, the same happened to me in a previous occation) and a misconfigured XML file.

Emmanuel
  • 13,083
  • 4
  • 39
  • 53
  • Then what? I'm not after the `Activity`, I'm after all of the views being injected into my activity under test automatically. Fields with `@Inject` are being injected automatically but `@InjectView` fields are being left as `null`. – Jamesking56 Mar 03 '15 at 19:56
  • Yeah `roboguice.application.GuiceApplication` doesn't exist – Jamesking56 Mar 03 '15 at 20:03
  • What about [this](http://robolectric.blogspot.com/2013/04/the-test-lifecycle-in-20.html)? – Emmanuel Mar 03 '15 at 20:11
  • That doesn't really help me – Jamesking56 Mar 03 '15 at 20:16
  • I believe it might since the problem is that `Views` are not being injected because the `Context` is probably not there. On the first link they say that `Context` needs to be injected "manually". – Emmanuel Mar 03 '15 at 20:19
  • How do I put the `Context` there? – Jamesking56 Mar 03 '15 at 20:20
  • Look at the first link I posted. They explain it in detail better than I could here. – Emmanuel Mar 03 '15 at 20:23
  • I can't really follow their code since `GuiceApplication` doesn't exist, I must be really missing something as their guides don't work in my project. – Jamesking56 Mar 03 '15 at 20:25
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/72168/discussion-between-emmanuel-and-jamesking56). – Emmanuel Mar 03 '15 at 20:26