4

I've got a library project using robolectric 3.0 at API21, with com.android.tools.build:gradle:1.3.1.

I want to use test resources (as if under src/androidTest/res/...), namely com.mypackage.test.R.java (as opposed to com.mypackage.R.java for production) in robolectric tests.

What I have so far:

Directory structure is

src/
  main/
    java
    res
  test/
    java
    // no res here because it's not picked up 
  androidTest/
    res    // picked up by androidTest build variant to generate test.R.java

Then in build.gradle:

android {
  compileSdkVersion 21
  buildToolsVersion = "22.0.1"

  defaultConfig {
    minSdkVersion 15
    targetSdkVersion 21
  }

  sourceSets {
    test {
        java {
            srcDir getTestRJavaDir()
        }
    }
  }
}

def private String getTestRJavaDir(){
  def myManifestRoot = (new XmlParser()).parse("${project.projectDir}/src/main/AndroidManifest.xml")
  def myPackageNamespace = myManifestRoot.@package
  def myPackagePath = myPackageNamespace.replaceAll("\\.", "/")

  return "${project.buildDir}/generated/source/r/androidTest/debug/${myPackagePath}/test"
}

afterEvaluate { project ->
  [tasks.compileDebugUnitTestSources, tasks.compileReleaseUnitTestSources]*.dependsOn("compileDebugAndroidTestSources")
}

My tests now successfully compile with test.R.java.

However, at runtime, they fail because robolectric now fails to find my asset files, because they are now located in ${project.buildDir}/intermediates/assets/androidTest/debug whereas previously they were in ${project.buildDir}/intermediates/assets/debug. My suspicion is that robo would also fail to find resource files because they have also been moved under the androidTest (build variant?) directory.

So two questions: 1) is there a better way to do this? 2) if there isn't, how can I tell robolectric where to look for the asset files?

I've tried @Config(assetDir="build/intermediates/assets/androidTest/debug") and @Config(assetDir="../build/intermediates/assets/androidTest/debug") to no avail.

Jon O
  • 6,532
  • 1
  • 46
  • 57
  • Jon, can you explain why would you want to use different R file? – Eugen Martynov Nov 06 '15 at 07:00
  • We're largely retrofitting tests onto existing code. Occasionally some things, like our custom animation framework, actually consume resources. This framework was not built with testability in mind, and we don't want to modify it (yet) because there are no tests. The simplest approach to testing this legacy code is feeding it pared-down, test-only resources. – Jon O Nov 06 '15 at 07:11
  • I see you need create custom tests runner, but I'm not big export here – Eugen Martynov Nov 06 '15 at 12:30
  • If I understand it correct then your library needs some specific resources to work but they are only given by a project using your library? Then a more simple approach could be: Use build types or flavours with the necessary test resources. Another approach is to create a application module which use you library and then write the test there with necessary resources. I don't think you get a stable setup by using androidTest resources. – nenick Nov 08 '15 at 08:35
  • This is specifically for testing. We don't want the resources in a production build. Are you suggesting we make a test-only build flavor? How is that different from using androidTest for instrumentation testing instead of robolectric for unit testing? (This isn't an instrumentation test.) – Jon O Nov 09 '15 at 14:46

2 Answers2

4

You can create a custom roboletric test runner like:

public class CustomRobolectricGradleTestRunner extends RobolectricGradleTestRunner {

    public CustomRobolectricGradleTestRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    // Fix for the NotFound error with openRawResource()
    @Override
    protected AndroidManifest getAppManifest(Config config) {
        String manifest = "src/main/AndroidManifest.xml";
        String res = String.format("../app/build/intermediates/res/merged/%1$s/%2$s", BuildConfig.FLAVOR, BuildConfig.BUILD_TYPE);
        String asset = "src/test/assets";
        return new AndroidManifest(Fs.fileFromPath(manifest), Fs.fileFromPath(res), Fs.fileFromPath(asset));
    }
}

In the variable asset you can define something like "../build/intermediates/assets/androidTest/debug"

motou
  • 726
  • 4
  • 12
  • This almost worked. You gave me the idea, so I'm giving you credit, but I'm going to post my own answer too. – Jon O Nov 09 '15 at 18:45
2

So with some combination of @motou's answer and this post, I worked out a solution that's merely sort of broken, and not completely broken.

Here's my set of test runners.

This first test runner was necessary just to fix my previously-working tests after mucking with my gradle build.

import com.google.common.base.Joiner;

import org.junit.runners.model.InitializationError;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.manifest.AndroidManifest;
import org.robolectric.res.FileFsFile;
import org.robolectric.res.FsFile;
import org.robolectric.util.Logger;
import org.robolectric.util.ReflectionHelpers;

import java.io.File;

/**
 * Extension of RobolectricGradleTestRunner to provide more robust path support since
 * we're kind of hacking androidTest resources into our build which moves stuff around.
 *
 * Most stuff is copy/pasted from the superclass and modified to suit our needs (with
 * an internal class to capture file info and a builder for clarity's sake).
 */
public class AssetLoadingRobolectricGradleTestRunner extends RobolectricGradleTestRunner {

  public AssetLoadingRobolectricGradleTestRunner( Class<?> klass ) throws InitializationError {
    super( klass );
  }

  private static final String BUILD_OUTPUT = "build/intermediates";

  @Override
  protected AndroidManifest getAppManifest( Config config ) {
    if ( config.constants() == Void.class ) {
      Logger.error( "Field 'constants' not specified in @Config annotation" );
      Logger.error( "This is required when using RobolectricGradleTestRunner!" );
      throw new RuntimeException( "No 'constants' field in @Config annotation!" );
    }

    final String type = getType( config );
    final String flavor = getFlavor( config );
    final String packageName = getPackageName( config );

    final FileFsFile res;
    final FileFsFile assets;
    final FileFsFile manifest;

    FileInfo.Builder builder = new FileInfo.Builder()
        .withFlavor( flavor )
        .withType( type );

    // res/merged added in Android Gradle plugin 1.3-beta1
    if ( FileFsFile.from( BUILD_OUTPUT, "res", "merged" ).exists() ) {
      res = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "res", "merged" )
                          .build() ) );
    } else if ( FileFsFile.from( BUILD_OUTPUT, "res" ).exists() ) {
      res = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "res" )
                          .build() ) );
    } else {
      res = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "bundles" )
                          .build() ),
          "res" );
    }

    FileFsFile tmpAssets = null;
    if ( FileFsFile.from( BUILD_OUTPUT, "assets" ).exists() ) {
      tmpAssets = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "assets" )
                          .build() ) );
    }

    if ( tmpAssets == null || !tmpAssets.exists() ) {
      assets = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "bundles" )
                          .build() ),
          "assets" );
    } else {
      assets = tmpAssets;
    }

    if ( FileFsFile.from( BUILD_OUTPUT, "manifests" ).exists() ) {
      manifest = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "manifests", "full" )
                          .build() ),
          "AndroidManifest.xml" );
    } else {
      manifest = FileFsFile.from(
          getPathWithFlavorAndType(
                  builder.withPrefix( BUILD_OUTPUT, "bundles" )
                          .build() ),
          "AndroidManifest.xml" );
    }

    Logger.debug( "Robolectric assets directory: " + assets.getPath() );
    Logger.debug( "   Robolectric res directory: " + res.getPath() );
    Logger.debug( "   Robolectric manifest path: " + manifest.getPath() );
    Logger.debug( "    Robolectric package name: " + packageName );
    return getAndroidManifest( manifest, res, assets, packageName );
  }

  protected String getType( Config config ) {
    try {
      return ReflectionHelpers.getStaticField( config.constants(), "BUILD_TYPE" );
    } catch ( Throwable e ) {
      return null;
    }
  }

  private String getPathWithFlavorAndType( FileInfo info ) {
    FileFsFile typeDir = FileFsFile.from( info.prefix, info.flavor, info.type );
    if ( typeDir.exists() ) {
      return typeDir.getPath();
    } else {
      // Try to find it without the flavor in the path
      return Joiner.on( File.separator ).join( info.prefix, info.type );
    }
  }

  protected String getFlavor( Config config ) {

    // TODO HACK!  Enormous, terrible hack!  Odds are this will barf our testing
    // if we ever want multiple flavors.
    return "androidTest";
  }

  protected String getPackageName( Config config ) {
    try {
      final String packageName = config.packageName();
      if ( packageName != null && !packageName.isEmpty() ) {
        return packageName;
      } else {
        return ReflectionHelpers.getStaticField( config.constants(), "APPLICATION_ID" );
      }
    } catch ( Throwable e ) {
      return null;
    }
  }

  // We want to be able to override this to load test resources in a child test runner
  protected AndroidManifest getAndroidManifest( FsFile manifest, FsFile res, FsFile asset, String packageName ) {
    return new AndroidManifest( manifest, res, asset, packageName );
  }

  public static class FileInfo {

    public String prefix;
    public String flavor;
    public String type;

    public static class Builder {

      private String prefix;
      private String flavor;
      private String type;

      public Builder withPrefix( String... strings ) {
        prefix = Joiner.on( File.separator ).join( strings );
        return this;
      }

      public Builder withFlavor( String flavor ) {
        this.flavor = flavor;
        return this;
      }

      public Builder withType( String type ) {
        this.type = type;
        return this;
      }

      public FileInfo build() {
        FileInfo info = new FileInfo();
        info.prefix = prefix;
        info.flavor = flavor;
        info.type = type;
        return info;
      }
    }
  }


}

The operative thing in this class is that I overrode the flavor with "androidTest" to force looking inside the androidTest build outputs directory, which is where everything gets put the moment that I force compileDebugAndroidTestSources as a prerequisite task so I can include test.R.java on my compile path.

Of course, this then breaks a bunch of other stuff (especially because the release buildtype doesn't have anything inside the androidTest outputs directory) so I added some fallback logic in the path testing here. If the build variant/type combination directory exists, use it; else fall back to the type alone. I also had to add a second level of logic around the assets because it was finding a build/intermediates/assets directory and attempting to use that, while the one that would work was in the bundles folder.

The thing that actually enabled me to use test resources was this other, derived runner:

import com.mypackage.BuildConfig;

import org.junit.runners.model.InitializationError;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.manifest.AndroidManifest;
import org.robolectric.res.Fs;
import org.robolectric.res.FsFile;
import org.robolectric.res.ResourcePath;

import java.util.List;

public class TestResLoadingRobolectricGradleTestRunner extends AssetLoadingRobolectricGradleTestRunner {

  public TestResLoadingRobolectricGradleTestRunner( Class<?> klass ) throws InitializationError {
    super(klass);
  }

  @Override
  protected AndroidManifest getAndroidManifest( FsFile manifest, FsFile res, FsFile asset, String packageName ) {
    return new AndroidManifest( manifest, res, asset, packageName ) {

      public String getTestRClassName() throws Exception {
        getRClassName(); // forces manifest parsing to be called
        // discard the value

        return getPackageName() + ".test.R";
      }

      public Class getTestRClass() {
        try {
          String rClassName = getTestRClassName();
          return Class.forName(rClassName);
        } catch (Exception e) {
          return null;
        }
      }

      @Override
      public List<ResourcePath> getIncludedResourcePaths() {
        List<ResourcePath> paths = super.getIncludedResourcePaths();

        paths.add(new ResourcePath(getTestRClass(), getPackageName(), Fs.fileFromPath("src/androidTest/res"), getAssetsDirectory()));
        return paths;
      }
    };
  }
}

Of course you're asking, "why not just always use the derived test runner?" and the answer is "because it breaks Android's default resource loading somehow." The example of this that I came across was that the default TextView typeface comes back null because it can't find the default value for font family during initialization from the theme.

I sense that I'm really close to something that works completely, here, but I'd have to dig more into why Android suddenly can't find a value at com.android.internal.R.whatever in its textview theming and I don't really have the spare bandwidth for that right now.

Community
  • 1
  • 1
Jon O
  • 6,532
  • 1
  • 46
  • 57