Robolectric与test.R.java的使用方法

4
我有一个使用robolectric 3.0在API21上的库项目,使用了com.android.tools.build:gradle:1.3.1
我想在robolectric测试中使用测试资源(就像在src/androidTest/res/...下一样),即com.mypackage.test.R.java(与生产中的com.mypackage.R.java不同)。
目前为止我的做法是:
目录结构是:
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

然后在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")
}

现在我的测试成功使用test.R.java进行了 编译

然而,在运行时,它们失败了,因为Robolectric无法找到我的资源文件,因为它们现在位于${project.buildDir}/intermediates/assets/androidTest/debug,而以前它们位于${project.buildDir}/intermediates/assets/debug。 我怀疑robo还会找不到资源文件,因为它们也被移动到了androidTest(构建变体?)目录下。

所以有两个问题:1)有更好的方法吗?2)如果没有,如何告诉Robolectric在哪里查找资产文件?

我已经尝试过@Config(assetDir="build/intermediates/assets/androidTest/debug")@Config(assetDir="../build/intermediates/assets/androidTest/debug"),但都没有成功。


Jon,你能解释一下为什么要使用不同的R文件吗? - Eugen Martynov
我们主要是在现有代码上进行测试改造。偶尔会有一些东西,比如我们的自定义动画框架,实际上会消耗资源。这个框架并没有考虑可测试性,我们不想修改它(至少现在不想)因为没有测试。测试这个遗留代码最简单的方法是提供精简的、仅用于测试的资源。 - Jon O
我看到你需要创建自定义测试运行器,但我在这方面并不是专家。 - Eugen Martynov
如果我理解正确的话,你的库需要一些特定的资源才能工作,但这些资源只能由使用你的库的项目提供?那么一个更简单的方法是:使用构建类型或口味来使用必要的测试资源。另一种方法是创建一个应用模块,使用你的库,并在那里编写带有必要资源的测试。我认为使用androidTest资源无法获得稳定的设置。 - nenick
这是专门为测试而设的。我们不希望在生产版本中包含这些资源。您是否建议我们制作一个仅用于测试的版本?与使用androidTest进行仪器化测试而非使用Robolectric进行单元测试有何区别?(这不是一项仪器化测试。) - Jon O
2个回答

4
你可以创建一个自定义的Robolectric测试运行器,例如:
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));
    }
}

在变量 asset 中,您可以定义类似于 "../build/intermediates/assets/androidTest/debug" 的内容。

这个差不多可以了。你给了我灵感,所以我会给你致谢,但我也会发布自己的答案。 - Jon O

2

结合@motou的答案和这个帖子,我找到了一个方案,它有点 有问题,但并非 完全 错误。

以下是我的测试运行器集合。

第一个测试运行器仅仅是为了修复先前工作正常的测试,在弄乱gradle构建后需要使用该运行器来进行修复。

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;
      }
    }
  }


}

这个类中的关键点是我使用"androidTest"来覆盖默认值,以强制查看androidTest构建输出目录。这也是在我将compileDebugAndroidTestSources作为先决任务并将test.R.java包含在我的编译路径中的时候,一切都被放置的地方。

当然,这样做会破坏很多其他东西(尤其是因为发布版本没有任何内容在androidTest输出目录中),因此我在此添加了一些备用逻辑测试路径。如果存在构建变体/类型组合目录,则使用它;否则回退到只有类型。我还必须在资产周围添加第二层逻辑,因为它发现了一个构建/中间资产目录,并尝试使用它,而能够起作用的目录位于bundles文件夹中。

实际上使我能够使用测试资源的是这个派生运行程序:

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;
      }
    };
  }
}

当然,你会问:“为什么不总是使用派生的测试运行器?”答案是“因为它会破坏Android的默认资源加载。”我遇到的例子是,由于在主题初始化期间找不到字体系列的默认值,所以默认的TextView字体类型返回null。

我感觉我离完全可行的东西非常接近了,但我需要更深入地了解为什么Android突然无法在其文本视图主题中找到com.android.internal.R.whatever的值,而我现在没有多余的带宽来做这件事。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接