Open-source BuildViewTestCase and the infrastructure required by it, as well as
the tests under analysis/actions. They don't run yet, because the mock client
setup is still missing.

--
MOS_MIGRATED_REVID=87149625
diff --git a/src/test/java/BUILD b/src/test/java/BUILD
index 51b5ab5..c249559 100644
--- a/src/test/java/BUILD
+++ b/src/test/java/BUILD
@@ -167,6 +167,35 @@
     ],
 )
 
+# TODO(bazel-team): Make these tests actually run. Also fix the duplicate compilation of
+# actions/util.
+java_library(
+    name = "analysis_actions_test",
+    srcs = glob([
+        "com/google/devtools/build/lib/actions/util/*.java",
+        "com/google/devtools/build/lib/analysis/**/*.java",
+        "com/google/devtools/build/lib/exec/util/*.java",
+        "com/google/devtools/build/lib/packages/util/*.java",
+    ]),
+    data = [
+        "//src/main/native:libunix.dylib",
+        "//src/main/native:libunix.so",
+    ],
+    deps = [
+        ":foundations_testutil",
+        ":test_runner",
+        ":testutil",
+        "//src/main/java:bazel-core",
+        "//src/main/protobuf:proto_extra_actions_base",
+        "//third_party:guava",
+        "//third_party:guava-testlib",
+        "//third_party:jsr305",
+        "//third_party:junit4",
+        "//third_party:mockito",
+        "//third_party:truth",
+    ],
+)
+
 cc_binary(
     name = "com/google/devtools/build/lib/shell/killmyself",
     srcs = ["com/google/devtools/build/lib/shell/killmyself.cc"],
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTestCase.java b/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTestCase.java
new file mode 100644
index 0000000..7566f2f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTestCase.java
@@ -0,0 +1,1341 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis;
+
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.common.eventbus.EventBus;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactOwner;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.MapBasedActionGraph;
+import com.google.devtools.build.lib.actions.MiddlemanFactory;
+import com.google.devtools.build.lib.actions.MutableActionGraph;
+import com.google.devtools.build.lib.actions.ResourceManager;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.Root;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.analysis.BuildView.AnalysisResult;
+import com.google.devtools.build.lib.analysis.actions.SpawnAction;
+import com.google.devtools.build.lib.analysis.buildinfo.BuildInfoFactory.BuildInfoKey;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.analysis.config.BuildConfiguration;
+import com.google.devtools.build.lib.analysis.config.BuildConfigurationCollection;
+import com.google.devtools.build.lib.analysis.config.BuildConfigurationKey;
+import com.google.devtools.build.lib.analysis.config.BuildOptions;
+import com.google.devtools.build.lib.analysis.config.ConfigMatchingProvider;
+import com.google.devtools.build.lib.analysis.config.ConfigurationFactory;
+import com.google.devtools.build.lib.analysis.config.InvalidConfigurationException;
+import com.google.devtools.build.lib.analysis.util.AnalysisMock;
+import com.google.devtools.build.lib.analysis.util.AnalysisTestUtil;
+import com.google.devtools.build.lib.buildtool.BuildRequest;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.events.StoredEventHandler;
+import com.google.devtools.build.lib.exec.ExecutionOptions;
+import com.google.devtools.build.lib.packages.Attribute.ConfigurationTransition;
+import com.google.devtools.build.lib.packages.AttributeMap;
+import com.google.devtools.build.lib.packages.ConstantRuleVisibility;
+import com.google.devtools.build.lib.packages.NoSuchPackageException;
+import com.google.devtools.build.lib.packages.NoSuchTargetException;
+import com.google.devtools.build.lib.packages.OutputFile;
+import com.google.devtools.build.lib.packages.PackageFactory;
+import com.google.devtools.build.lib.packages.PackageFactory.EnvironmentExtension;
+import com.google.devtools.build.lib.packages.PackageIdentifier;
+import com.google.devtools.build.lib.packages.PackageSpecification;
+import com.google.devtools.build.lib.packages.Preprocessor;
+import com.google.devtools.build.lib.packages.Rule;
+import com.google.devtools.build.lib.packages.Target;
+import com.google.devtools.build.lib.packages.util.MockToolsConfig;
+import com.google.devtools.build.lib.pkgcache.LoadingPhaseRunner;
+import com.google.devtools.build.lib.pkgcache.LoadingPhaseRunner.LoadingResult;
+import com.google.devtools.build.lib.pkgcache.PackageCacheOptions;
+import com.google.devtools.build.lib.pkgcache.PackageManager;
+import com.google.devtools.build.lib.pkgcache.PathPackageLocator;
+import com.google.devtools.build.lib.pkgcache.TransitivePackageLoader;
+import com.google.devtools.build.lib.rules.test.BaselineCoverageAction;
+import com.google.devtools.build.lib.skyframe.ConfiguredTargetKey;
+import com.google.devtools.build.lib.skyframe.DiffAwareness;
+import com.google.devtools.build.lib.skyframe.PrecomputedValue;
+import com.google.devtools.build.lib.skyframe.SequencedSkyframeExecutor;
+import com.google.devtools.build.lib.syntax.Label;
+import com.google.devtools.build.lib.syntax.Label.SyntaxException;
+import com.google.devtools.build.lib.testutil.FoundationTestCase;
+import com.google.devtools.build.lib.testutil.TestConstants;
+import com.google.devtools.build.lib.testutil.TestRuleClassProvider;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.util.FileType;
+import com.google.devtools.build.lib.util.StringUtil;
+import com.google.devtools.build.lib.util.io.TimestampGranularityMonitor;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.ModifiedFileSet;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.skyframe.SkyFunction;
+import com.google.devtools.build.skyframe.SkyFunctionName;
+import com.google.devtools.common.options.Options;
+import com.google.devtools.common.options.OptionsParser;
+import com.google.devtools.common.options.OptionsParsingException;
+
+import org.mockito.Mockito;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Common test code that creates a BuildView instance.
+ */
+public abstract class BuildViewTestCase extends FoundationTestCase {
+  protected static final int LOADING_PHASE_THREADS = 20;
+
+  protected ConfiguredRuleClassProvider ruleClassProvider;
+  protected ConfigurationFactory configurationFactory;
+  protected BuildView view;
+
+  private SequencedSkyframeExecutor skyframeExecutor;
+
+  protected BlazeDirectories directories;
+  protected BinTools binTools;
+
+  // Note that these configurations are virtual (they use only VFS)
+  protected BuildConfigurationCollection masterConfig;
+  protected BuildConfiguration targetConfig;  // "target" or "build" config
+
+  protected OptionsParser optionsParser;
+  private PackageCacheOptions packageCacheOptions;
+
+  protected MockToolsConfig mockToolsConfig;
+
+  protected WorkspaceStatusAction.Factory workspaceStatusActionFactory;
+
+  private MutableActionGraph mutableActionGraph;
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+    AnalysisMock mock = getAnalysisMock();
+    directories = new BlazeDirectories(outputBase, outputBase, rootDirectory);
+    binTools = BinTools.forUnitTesting(directories, TestConstants.EMBEDDED_TOOLS);
+    mockToolsConfig = new MockToolsConfig(rootDirectory, false);
+    mock.setupMockClient(mockToolsConfig);
+    configurationFactory = mock.createConfigurationFactory();
+    packageCacheOptions = parsePackageCacheOptions();
+    workspaceStatusActionFactory =
+        new AnalysisTestUtil.DummyWorkspaceStatusActionFactory(directories);
+    mutableActionGraph = new MapBasedActionGraph();
+    ruleClassProvider = getRuleClassProvider();
+    skyframeExecutor = SequencedSkyframeExecutor.create(reporter,
+        new PackageFactory(ruleClassProvider, getEnvironmentExtensions()),
+        new TimestampGranularityMonitor(BlazeClock.instance()), directories,
+        workspaceStatusActionFactory,
+        ruleClassProvider.getBuildInfoFactories(),
+        ImmutableSet.<Path>of(),
+        ImmutableList.<DiffAwareness.Factory>of(),
+        Predicates.<PathFragment>alwaysFalse(),
+        getPreprocessorFactorySupplier(),
+        ImmutableMap.<SkyFunctionName, SkyFunction>of(),
+        getPrecomputedValues()
+    );
+    skyframeExecutor.preparePackageLoading(
+        new PathPackageLocator(rootDirectory), ConstantRuleVisibility.PUBLIC, true, "",
+        UUID.randomUUID());
+    useConfiguration();
+    setUpSkyframe();
+    // Also initializes ResourceManager.
+    ResourceManager.instance().setAvailableResources(getStartingResources());
+  }
+
+  protected AnalysisMock getAnalysisMock() {
+    try {
+      Class<?> providerClass = Class.forName(TestConstants.TEST_ANALYSIS_MOCK);
+      Field instanceField = providerClass.getField("INSTANCE");
+      return (AnalysisMock) instanceField.get(null);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /** Creates or retrieves the rule class provider used in this test. */
+  protected ConfiguredRuleClassProvider getRuleClassProvider() {
+    return TestRuleClassProvider.getRuleClassProvider();
+  }
+
+  protected Iterable<EnvironmentExtension> getEnvironmentExtensions() {
+    return ImmutableList.<EnvironmentExtension>of();
+  }
+
+  protected ImmutableList<PrecomputedValue.Injected> getPrecomputedValues() {
+    return ImmutableList.of();
+  }
+
+  protected Preprocessor.Factory.Supplier getPreprocessorFactorySupplier() {
+    return Preprocessor.Factory.Supplier.NullSupplier.INSTANCE;
+  }
+
+  protected ResourceSet getStartingResources() {
+    // Effectively disable ResourceManager by default.
+    return ResourceSet.createWithRamCpuIo(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE);
+  }
+
+  protected final BuildConfigurationCollection createConfigurations(String... args)
+      throws Exception {
+    optionsParser = OptionsParser.newOptionsParser(Iterables.concat(Arrays.asList(
+          ExecutionOptions.class,
+          BuildRequest.BuildRequestOptions.class),
+          ruleClassProvider.getConfigurationOptions()));
+    try {
+      List<String> configurationArgs = new ArrayList<>();
+      configurationArgs.add("--experimental_extended_sanity_checks");
+      configurationArgs.addAll(getAnalysisMock().getOptionOverrides());
+
+      optionsParser.parse(configurationArgs);
+      optionsParser.parse(args);
+
+      configurationFactory.forbidSanityCheck();
+      BuildOptions buildOptions = ruleClassProvider.createBuildOptions(optionsParser);
+      ensureTargetsVisited(buildOptions.getAllLabels().values());
+      BuildConfigurationKey key = new BuildConfigurationKey(
+          buildOptions, directories,
+          ImmutableMap.<String, String>of());
+      skyframeExecutor.invalidateConfigurationCollection();
+      return skyframeExecutor.createConfigurations(configurationFactory, key);
+    } catch (InvalidConfigurationException | OptionsParsingException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  protected Target getTarget(String label)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    return getTarget(Label.parseAbsolute(label));
+  }
+
+  protected Target getTarget(Label label)
+      throws NoSuchPackageException, NoSuchTargetException, InterruptedException {
+    return getPackageManager().getTarget(reporter, label);
+  }
+
+  private void setUpSkyframe() {
+    PathPackageLocator pkgLocator = PathPackageLocator.create(
+        packageCacheOptions.packagePath, reporter, rootDirectory, rootDirectory);
+    skyframeExecutor.preparePackageLoading(pkgLocator,
+        packageCacheOptions.defaultVisibility, true,
+        ruleClassProvider.getDefaultsPackageContent(optionsParser),
+        UUID.randomUUID());
+    skyframeExecutor.setDeletedPackages(ImmutableSet.copyOf(packageCacheOptions.deletedPackages));
+  }
+
+  protected void setPackageCacheOptions(String... options) throws Exception {
+    packageCacheOptions = parsePackageCacheOptions(options);
+    setUpSkyframe();
+  }
+
+  private PackageCacheOptions parsePackageCacheOptions(String... options) throws Exception {
+    OptionsParser parser = OptionsParser.newOptionsParser(PackageCacheOptions.class);
+    parser.parse("--default_visibility=public");
+    parser.parse(options);
+    return parser.getOptions(PackageCacheOptions.class);
+  }
+
+  /** Used by skyframe-only tests. */
+  protected SequencedSkyframeExecutor getSkyframeExecutor() {
+    return Preconditions.checkNotNull(skyframeExecutor);
+  }
+
+  protected PackageManager getPackageManager() {
+    return skyframeExecutor.getPackageManager();
+  }
+
+  protected AnalysisHooks getAnalysisHooks() {
+    return new AnalysisHooks() {
+      @Override
+      public PackageManager getPackageManager() {
+        return BuildViewTestCase.this.getPackageManager();
+      }
+
+      @Override
+      public ConfiguredTarget getExistingConfiguredTarget(Target target,
+          BuildConfiguration configuration) {
+        return view.getExistingConfiguredTarget(target, configuration);
+      }
+
+    };
+  }
+
+  /**
+   * Invalidates all existing packages.
+   * @throws InterruptedException
+   */
+  protected void invalidatePackages() throws InterruptedException {
+    skyframeExecutor.invalidateFilesUnderPathForTesting(ModifiedFileSet.EVERYTHING_MODIFIED,
+        rootDirectory);
+  }
+
+  /**
+   * Sets host and target configuration using the specified options, falling back to the default
+   * options for unspecified ones, and recreates the build view.
+   *
+   * @throws IllegalArgumentException
+   */
+  protected final void useConfiguration(String... args) throws Exception {
+    masterConfig = createConfigurations(args);
+    targetConfig = getTargetConfiguration();
+    createBuildView();
+  }
+
+  /**
+   * Creates BuildView using current hostConfig/targetConfig values.
+   * Ensures that hostConfig is either identical to the targetConfig or has
+   * 'host' short name.
+   */
+  protected final void createBuildView() throws Exception {
+    Preconditions.checkNotNull(masterConfig);
+    Preconditions.checkState(getHostConfiguration() == getTargetConfiguration()
+        || getHostConfiguration().getShortName().equals("host"),
+        "Host configuration %s does not have name 'host' "
+        + "and does not match target configuration %s",
+        getHostConfiguration(), getTargetConfiguration());
+
+    String defaultsPackageContent = ruleClassProvider.getDefaultsPackageContent(optionsParser);
+    skyframeExecutor.setupDefaultPackage(defaultsPackageContent);
+    skyframeExecutor.dropConfiguredTargets();
+
+    view = new BuildView(directories, getPackageManager(), ruleClassProvider, skyframeExecutor,
+        binTools, null);
+    view.setConfigurationsForTesting(masterConfig);
+
+    view.setArtifactRoots(
+        ImmutableMap.of(PackageIdentifier.createInDefaultRepo(""), rootDirectory));
+    simulateLoadingPhase();
+  }
+
+  protected CachingAnalysisEnvironment getTestAnalysisEnvironment() {
+    return new CachingAnalysisEnvironment(view.getArtifactFactory(),
+        ArtifactOwner.NULL_OWNER, /*isSystemEnv=*/true, /*extendedSanityChecks*/false, reporter,
+        /*skyframeEnv=*/ null, /*actionsEnabled=*/true, binTools);
+  }
+
+  /**
+   * Allows access to the prerequisites of a configured target. This is currently used in some tests
+   * to reach into the internals of RuleCT for white box testing. In principle, this should not be
+   * used; instead tests should only assert on properties of the exposed provider instances and / or
+   * the action graph.
+   */
+  protected Iterable<ConfiguredTarget> getDirectPrerequisites(ConfiguredTarget target) {
+    return view.getDirectPrerequisites(target);
+  }
+
+  /**
+   * Creates and returns a rule context that is equivalent to the one that was used to create the
+   * given configured target.
+   */
+  protected RuleContext getRuleContext(ConfiguredTarget target) {
+    return new RuleContext.Builder(
+        new StubAnalysisEnvironment(), (Rule) target.getTarget(),
+        target.getConfiguration(), ruleClassProvider.getPrerequisiteValidator())
+        .setVisibility(NestedSetBuilder.<PackageSpecification>create(
+            Order.STABLE_ORDER, PackageSpecification.EVERYTHING))
+        .setPrerequisites(view.getPrerequisiteMapForTesting(target))
+        .setConfigConditions(ImmutableSet.<ConfigMatchingProvider>of())
+        .build();
+  }
+
+  /**
+   * Creates and returns a rule context to use for Skylark tests that is equivalent to the one
+   * that was used to create the given configured target.
+   */
+  protected RuleContext getRuleContextForSkylark(ConfiguredTarget target) {
+    // TODO(bazel-team): we need this horrible workaround because CachingAnalysisEnvironment
+    // only works with StoredErrorEventListener despite the fact it accepts the interface
+    // ErrorEventListener, so it's not possible to create it with reporter.
+    // See BuildView.getRuleContextForTesting().
+    StoredEventHandler eventHandler = new StoredEventHandler() {
+      @Override
+      public synchronized void handle(Event e) {
+        super.handle(e);
+        reporter.handle(e);
+      }
+    };
+    return view.getRuleContextForTesting(target, eventHandler);
+  }
+
+  /**
+   * Allows access to the prerequisites of a configured target. This is currently used in some tests
+   * to reach into the internals of RuleCT for white box testing. In principle, this should not be
+   * used; instead tests should only assert on properties of the exposed provider instances and / or
+   * the action graph.
+   */
+  protected List<? extends TransitiveInfoCollection> getPrerequisites(ConfiguredTarget target,
+      String attributeName) {
+    return getRuleContext(target).getConfiguredTargetMap().get(attributeName);
+  }
+
+  /**
+   * Allows access to the prerequisites of a configured target. This is currently used in some tests
+   * to reach into the internals of RuleCT for white box testing. In principle, this should not be
+   * used; instead tests should only assert on properties of the exposed provider instances and / or
+   * the action graph.
+   */
+  protected <C extends TransitiveInfoProvider> Iterable<C> getPrerequisites(
+      ConfiguredTarget target, String attributeName, Class<C> classType) {
+    return AnalysisUtils.getProviders(getPrerequisites(target, attributeName), classType);
+  }
+
+  /**
+   * Allows access to the prerequisites of a configured target. This is currently used in some tests
+   * to reach into the internals of RuleCT for white box testing. In principle, this should not be
+   * used; instead tests should only assert on properties of the exposed provider instances and / or
+   * the action graph.
+   */
+  protected ImmutableList<Artifact> getPrerequisiteArtifacts(
+      ConfiguredTarget target, String attributeName) {
+    Set<Artifact> result = new LinkedHashSet<>();
+    for (FileProvider provider : getPrerequisites(target, attributeName, FileProvider.class)) {
+      Iterables.addAll(result, provider.getFilesToBuild());
+    }
+    return ImmutableList.copyOf(result);
+  }
+
+  protected final Action getGeneratingAction(Artifact artifact) {
+    Preconditions.checkNotNull(artifact);
+    Action action = mutableActionGraph.getGeneratingAction(artifact);
+    if (action != null) {
+      return action;
+    }
+    return view.getActionGraph().getGeneratingAction(artifact);
+  }
+
+  protected void simulateLoadingPhase() {
+    try {
+      ensureTargetsVisited(targetConfig.getAllLabels().values());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  protected ActionsTestUtil actionsTestUtil() {
+    return new ActionsTestUtil(view.getActionGraph());
+  }
+
+  private Set<Target> getTargets(Iterable<Label> labels) throws InterruptedException,
+      NoSuchTargetException, NoSuchPackageException{
+    Set<Target> targets = Sets.newHashSet();
+    for (Label label : labels) {
+      targets.add(skyframeExecutor.getPackageManager().getTarget(reporter, label));
+    }
+    return targets;
+  }
+
+  // Get a MutableActionGraph for testing purposes.
+  protected MutableActionGraph getMutableActionGraph() {
+    return mutableActionGraph;
+  }
+
+  protected TransitivePackageLoader makeVisitor() {
+    setUpSkyframe();
+    return skyframeExecutor.pkgLoader();
+  }
+
+  /**
+   * Construct the containing package of the specified labels, and all of its transitive
+   * dependencies.  This must be done prior to configuration, as the latter is intolerant of
+   * NoSuchTargetExceptions.
+   */
+  protected boolean ensureTargetsVisited(TransitivePackageLoader visitor,
+      Collection<Label> targets, Collection<Label> labels, boolean keepGoing)
+          throws InterruptedException, NoSuchTargetException, NoSuchPackageException {
+    boolean success = visitor.sync(reporter,
+        ImmutableSet.copyOf(getTargets(targets)),
+        ImmutableSet.copyOf(labels),
+        keepGoing,
+        /*parallelThreads=*/4,
+        /*maxDepth=*/Integer.MAX_VALUE);
+    return success;
+  }
+
+  protected boolean ensureTargetsVisited(Collection<Label> labels)
+      throws InterruptedException, NoSuchTargetException, NoSuchPackageException {
+    return ensureTargetsVisited(makeVisitor(), ImmutableSet.<Label>of(), labels,
+        /*keepGoing=*/false);
+  }
+
+  protected boolean ensureTargetsVisited(Label label)
+      throws InterruptedException, NoSuchTargetException, NoSuchPackageException {
+    return ensureTargetsVisited(ImmutableList.of(label));
+  }
+
+  protected boolean ensureTargetsVisited(String... labels)
+      throws InterruptedException, NoSuchTargetException, NoSuchPackageException, SyntaxException {
+    List<Label> actualLabels = new ArrayList<>();
+    for (String label : labels) {
+      actualLabels.add(Label.parseAbsolute(label));
+    }
+    return ensureTargetsVisited(actualLabels);
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified label, configured for the
+   * "build" (aka "target") configuration.
+   */
+  protected ConfiguredTarget getConfiguredTarget(String label)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    return getConfiguredTarget(label, targetConfig);
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified label, using the
+   * given build configuration.
+   */
+  protected ConfiguredTarget getConfiguredTarget(String label, BuildConfiguration config)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    ensureTargetsVisited(label);
+    return view.getConfiguredTargetForTesting(getTarget(label), config);
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified label, using the
+   * given build configuration.
+   */
+  protected ConfiguredTarget getConfiguredTarget(Label label, BuildConfiguration config)
+      throws NoSuchPackageException, NoSuchTargetException, InterruptedException {
+    ensureTargetsVisited(label);
+    return view.getConfiguredTargetForTesting(getTarget(label), config);
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified file label, configured for
+   * the "build" (aka "target") configuration.
+   */
+  protected FileConfiguredTarget getFileConfiguredTarget(String label)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    return (FileConfiguredTarget) getConfiguredTarget(label, targetConfig);
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified label, configured for
+   * the "host" configuration.
+   */
+  protected ConfiguredTarget getHostConfiguredTarget(String label)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    return getConfiguredTarget(label, getHostConfiguration());
+  }
+
+  /**
+   * Returns the ConfiguredTarget for the specified file label, configured for
+   * the "host" configuration.
+   */
+  protected FileConfiguredTarget getHostFileConfiguredTarget(String label)
+      throws NoSuchPackageException, NoSuchTargetException,
+             Label.SyntaxException, InterruptedException {
+    return (FileConfiguredTarget) getHostConfiguredTarget(label);
+  }
+
+  /**
+   * Create and return a configured scratch rule.
+   *
+   * @param packageName the package name ofthe rule.
+   * @param ruleName the name of the rule.
+   * @param lines the text of the rule.
+   * @return the configured target instance for the created rule.
+   * @throws IOException
+   * @throws Exception
+   */
+  protected ConfiguredTarget scratchConfiguredTarget(String packageName,
+                                                     String ruleName,
+                                                     String... lines)
+      throws IOException, Exception {
+    return scratchConfiguredTarget(packageName, ruleName, targetConfig, lines);
+  }
+
+  /**
+   * Create and return a scratch rule.
+   *
+   * @param packageName the package name of the rule.
+   * @param ruleName the name of the rule.
+   * @param lines the text of the rule.
+   * @return the rule instance for the created rule.
+   * @throws IOException
+   * @throws Exception
+   */
+  protected Rule scratchRule(String packageName, String ruleName, String... lines)
+      throws Exception {
+    scratchFile("/" + TestConstants.TEST_WORKSPACE_DIRECTORY + "/" + packageName + "/BUILD", lines);
+    return (Rule) getTarget("//" + packageName + ":" + ruleName);
+  }
+
+  /**
+   * Create and return a configured scratch rule.
+   *
+   * @param packageName the package name of the rule.
+   * @param ruleName the name of the rule.
+   * @param config the configuration to use to construct the configured rule.
+   * @param lines the text of the rule.
+   * @return the configured target instance for the created rule.
+   * @throws IOException
+   * @throws Exception
+   */
+  protected ConfiguredTarget scratchConfiguredTarget(String packageName,
+                                                     String ruleName,
+                                                     BuildConfiguration config,
+                                                     String... lines)
+      throws IOException, Exception {
+    Target rule = scratchRule(packageName, ruleName, lines);
+    if (ensureTargetsVisited(rule.getLabel())) {
+      return view.getConfiguredTargetForTesting(rule, config);
+    } else {
+      return null;
+    }
+  }
+
+  /**
+   * Check that configuration of the target named 'ruleName' in the
+   * specified BUILD file fails with an error message ending in
+   * 'expectedErrorMessage'.
+   *
+   * @param packageName the package name of the generated BUILD file
+   * @param ruleName the rule name for the rule in the generated BUILD file
+   * @param expectedErrorMessage the expected error message.
+   * @param lines the text of the rule.
+   * @return the found error.
+   */
+  protected Event checkError(String packageName,
+                             String ruleName,
+                             String expectedErrorMessage,
+                             String... lines) throws Exception {
+    eventCollector.clear();
+    reporter.removeHandler(failFastHandler); // expect errors
+    ConfiguredTarget target = scratchConfiguredTarget(packageName, ruleName, lines);
+    if (target != null) {
+      assertTrue("Rule '" + "//" + packageName + ":" + ruleName + "' did not contain an error",
+          view.hasErrors(target));
+    }
+    return assertContainsEvent(expectedErrorMessage);
+  }
+
+  /**
+   * Check that configuration of the target named 'ruleName' in the
+   * specified BUILD file reports a warning message ending in
+   * 'expectedWarningMessage', and that no errors were reported.
+   *
+   * @param packageName the package name of the generated BUILD file
+   * @param ruleName the rule name for the rule in the generated BUILD file
+   * @param expectedWarningMessage the expected warning message.
+   * @param lines the text of the rule.
+   * @return the found error.
+   */
+  protected Event checkWarning(String packageName,
+                               String ruleName,
+                               String expectedWarningMessage,
+                               String... lines) throws Exception {
+    eventCollector.clear();
+    ConfiguredTarget target = scratchConfiguredTarget(packageName, ruleName,
+                                                     lines);
+    assertFalse("Rule '" + "//" + packageName + ":" + ruleName
+        + "' did contain an error",
+        view.hasErrors(target));
+    return assertContainsEvent(expectedWarningMessage);
+  }
+
+  /**
+   * Given a collection of Artifacts, returns a corresponding set of strings of
+   * the form "[root] [relpath]", such as "bin x/libx.a".  Such strings make
+   * assertions easier to write.
+   *
+   * <p>The returned set preserves the order of the input.
+   */
+  protected Set<String> artifactsToStrings(Iterable<Artifact> artifacts) {
+    return AnalysisTestUtil.artifactsToStrings(masterConfig, artifacts);
+  }
+
+  /**
+   * Asserts that targetName's outputs are exactly expectedOuts.
+   *
+   * @param targetName The label of a rule.
+   * @param expectedOuts The labels of the expected outputs of the rule.
+   */
+  protected void assertOuts(String targetName, String... expectedOuts) throws Exception {
+    Rule ruleTarget = (Rule) getTarget(targetName);
+    for (String expectedOut : expectedOuts) {
+      Target outTarget = getTarget(expectedOut);
+      if (!(outTarget instanceof OutputFile)) {
+        fail("Target " + outTarget + " is not an output");
+        assertSame(ruleTarget, ((OutputFile) outTarget).getGeneratingRule());
+        // This ensures that the output artifact is wired up in the action graph
+        getConfiguredTarget(expectedOut);
+      }
+    }
+
+    Collection<OutputFile> outs = ruleTarget.getOutputFiles();
+    assertEquals("Mismatched outputs: " + outs, expectedOuts.length, outs.size());
+  }
+
+  /**
+   * Asserts that there exists a configured target file for the given label.
+   */
+  protected void assertConfiguredTargetExists(String label) throws Exception {
+    assertNotNull(getFileConfiguredTarget(label));
+  }
+
+  /**
+   * Assert that the first label and the second label are both generated
+   * by the same command.
+   */
+  protected void assertSameGeneratingAction(String labelA, String labelB)
+      throws Exception {
+    assertSame(
+        "Action for " + labelA + " did not match " + labelB,
+        getGeneratingActionForLabel(labelA),
+        getGeneratingActionForLabel(labelB));
+  }
+
+  protected Artifact getSourceArtifact(PathFragment rootRelativePath, Root root) {
+    return view.getArtifactFactory().getSourceArtifact(rootRelativePath, root);
+  }
+
+  protected Artifact getSourceArtifact(String name) throws IOException {
+    return getSourceArtifact(new PathFragment(name),
+        Root.asSourceRoot(scratchDir("/" + TestConstants.TEST_WORKSPACE_DIRECTORY)));
+  }
+
+  /**
+   * Gets a derived artifact, creating it if necessary. {@code ArtifactOwner} should be a genuine
+   * {@link LabelAndConfiguration} corresponding to a {@link ConfiguredTarget}. If called from a
+   * test that does not exercise the analysis phase, the convenience methods {@link
+   * #getBinArtifactWithNoOwner} or {@link #getGenfilesArtifactWithNoOwner} should be used instead.
+   */
+  protected Artifact getDerivedArtifact(PathFragment rootRelativePath, Root root,
+      ArtifactOwner owner) {
+    return view.getArtifactFactory().getDerivedArtifact(rootRelativePath, root, owner);
+  }
+
+  /**
+   * Gets a derived Artifact for testing with path of the form
+   * root/owner.getPackageFragment()/packageRelativePath.
+   *
+   * @see #getDerivedArtifact(PathFragment, Root, ArtifactOwner)
+   */
+  private Artifact getPackageRelativeDerivedArtifact(String packageRelativePath, Root root,
+      ArtifactOwner owner) {
+    return getDerivedArtifact(
+        owner.getLabel().getPackageFragment().getRelative(packageRelativePath),
+        root, owner);
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the {@link BuildConfiguration#getBinDirectory()}. This
+   * method should only be used for tests that do no analysis, and so there is no ConfiguredTarget
+   * to own this artifact. If the test runs the analysis phase, {@link
+   * #getBinArtifact(String, ArtifactOwner)} or its convenience methods should be
+   * used instead.
+   */
+  protected Artifact getBinArtifactWithNoOwner(String rootRelativePath) {
+    return getDerivedArtifact(new PathFragment(rootRelativePath), targetConfig.getBinDirectory(),
+        ActionsTestUtil.NULL_ARTIFACT_OWNER);
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getBinDirectory()} corresponding to the package of {@code owner}. So
+   * to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should just
+   * be "foo.o".
+   */
+  protected Artifact getBinArtifact(String packageRelativePath, String owner) {
+    return getBinArtifact(packageRelativePath, makeLabelAndConfiguration(owner));
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getBinDirectory()} corresponding to the package of {@code owner}. So
+   * to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should just
+   * be "foo.o".
+   */
+  protected Artifact getBinArtifact(String packageRelativePath, ConfiguredTarget owner) {
+    return getPackageRelativeDerivedArtifact(packageRelativePath,
+        owner.getConfiguration().getBinDirectory(), new ConfiguredTargetKey(owner));
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getBinDirectory()} corresponding to the package of {@code owner}. So
+   * to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should just
+   * be "foo.o".
+   */
+  private Artifact getBinArtifact(String packageRelativePath, ArtifactOwner owner) {
+    return getPackageRelativeDerivedArtifact(packageRelativePath, targetConfig.getBinDirectory(),
+        owner);
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the {@link BuildConfiguration#getGenfilesDirectory()}.
+   * This method should only be used for tests that do no analysis, and so there is no
+   * ConfiguredTarget to own this artifact. If the test runs the analysis phase, {@link
+   * #getGenfilesArtifact(String, ArtifactOwner)} or its convenience methods should be used instead.
+   */
+  protected Artifact getGenfilesArtifactWithNoOwner(String rootRelativePath) {
+    return getDerivedArtifact(new PathFragment(rootRelativePath),
+        targetConfig.getGenfilesDirectory(), ActionsTestUtil.NULL_ARTIFACT_OWNER);
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getGenfilesDirectory()} corresponding to the package of {@code owner}.
+   * So to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should
+   * just be "foo.o".
+   */
+  protected Artifact getGenfilesArtifact(String packageRelativePath, String owner) {
+    return getGenfilesArtifact(packageRelativePath, makeLabelAndConfiguration(owner));
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getGenfilesDirectory()} corresponding to the package of {@code owner}.
+   * So to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should
+   * just be "foo.o".
+   */
+  protected Artifact getGenfilesArtifact(String packageRelativePath, ConfiguredTarget owner) {
+    return getGenfilesArtifact(packageRelativePath, new ConfiguredTargetKey(owner));
+  }
+
+  /**
+   * Gets a derived Artifact for testing in the subdirectory of the {@link
+   * BuildConfiguration#getGenfilesDirectory()} corresponding to the package of {@code owner}.
+   * So to specify a file foo/foo.o owned by target //foo:foo, {@code packageRelativePath} should
+   * just be "foo.o".
+   */
+  private Artifact getGenfilesArtifact(String packageRelativePath, ArtifactOwner owner) {
+    return getPackageRelativeDerivedArtifact(packageRelativePath,
+        targetConfig.getGenfilesDirectory(),
+        owner);
+  }
+
+  protected Action getGeneratingActionForLabel(String label) throws Exception {
+    return getGeneratingAction(getFileConfiguredTarget(label).getArtifact());
+  }
+
+  protected String fileName(Artifact artifact) {
+    return artifact.getExecPathString();
+  }
+
+  protected String fileName(FileConfiguredTarget target) {
+    return fileName(target.getArtifact());
+  }
+
+  protected String fileName(String name) throws Exception {
+    return fileName(getFileConfiguredTarget(name));
+  }
+
+  protected Path getOutputPath() {
+    return directories.getOutputPath();
+  }
+
+  /**
+   * Verifies whether the rule checks the 'srcs' attribute validity.
+   *
+   * <p>At the call site it expects the {@code packageName} to contain:
+   * <ol>
+   *   <li>{@code :gvalid} - genrule that outputs a valid file</li>
+   *   <li>{@code :ginvalid} - genrule that outputs an invalid file</li>
+   *   <li>{@code :gmix} - genrule that outputs a mix of valid and invalid
+   *       files</li>
+   *   <li>{@code :valid} - rule of type {@code ruleType} that has a valid
+   *       file, {@code :gvalid} and {@code :gmix} in the srcs</li>
+   *   <li>{@code :invalid} - rule of type {@code ruleType} that has an invalid
+   *       file, {@code :ginvalid} in the srcs</li>
+   *   <li>{@code :mix} - rule of type {@code ruleType} that has a valid and an
+   *       invalid file in the srcs</li>
+   * </ol>
+   *
+   * @param packageName the package where the rules under test are located
+   * @param ruleType rules under test types
+   * @param expectedTypes expected file types
+   */
+  protected void assertSrcsValidityForRuleType(String packageName, String ruleType,
+      String expectedTypes) throws Exception {
+    reporter.removeHandler(failFastHandler);
+    String descriptionSingle = ruleType + " srcs file (expected " + expectedTypes + ")";
+    String descriptionPlural = ruleType + " srcs files (expected " + expectedTypes + ")";
+    String descriptionPluralFile = "(expected " + expectedTypes + ")";
+    assertSrcsValidity(ruleType, packageName + ":valid", false,
+        "need at least one " + descriptionSingle,
+        "'" + packageName + ":gvalid' does not produce any " + descriptionPlural,
+        "'" + packageName + ":gmix' does not produce any " + descriptionPlural);
+    assertSrcsValidity(ruleType, packageName + ":invalid", true,
+        "file '" + packageName + ":a.foo' is misplaced here " + descriptionPluralFile,
+        "'" + packageName + ":ginvalid' does not produce any " + descriptionPlural);
+    assertSrcsValidity(ruleType, packageName + ":mix", true,
+        "'" + packageName + ":a.foo' does not produce any " + descriptionPlural);
+  }
+
+  protected void assertSrcsValidity(String ruleType, String targetName, boolean expectedError,
+      String... expectedMessages) throws Exception{
+    ConfiguredTarget target = getConfiguredTarget(targetName);
+    if (expectedError) {
+      assertTrue(view.hasErrors(target));
+      for (String expectedMessage : expectedMessages) {
+        String message = "in srcs attribute of " + ruleType + " rule " + targetName + ": "
+            + expectedMessage;
+        assertContainsEvent(message);
+      }
+    } else {
+      assertFalse(view.hasErrors(target));
+      for (String expectedMessage : expectedMessages) {
+        String message = "in srcs attribute of " + ruleType + " rule " + target.getLabel() + ": "
+            + expectedMessage;
+        assertDoesNotContainEvent(message);
+      }
+    }
+  }
+
+  private static Label makeLabel(String label) {
+    try {
+      return Label.parseAbsolute(label);
+    } catch (SyntaxException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private ConfiguredTargetKey makeLabelAndConfiguration(String label) {
+    return new ConfiguredTargetKey(makeLabel(label), targetConfig);
+  }
+
+  protected static List<String> actionInputsToPaths(Iterable<? extends ActionInput> actionInputs) {
+    return ImmutableList.copyOf(
+        Iterables.transform(actionInputs, new Function<ActionInput, String>() {
+          @Override
+          public String apply(ActionInput actionInput) {
+            return actionInput.getExecPathString();
+          }
+        }));
+  }
+
+  protected String readContentAsLatin1String(Artifact artifact) throws IOException {
+    return new String(FileSystemUtils.readContentAsLatin1(artifact.getPath()));
+  }
+
+  /**
+   * Asserts that the predecessor closure of the given Artifact contains the same elements as those
+   * in expectedPredecessors, plus the given common predecessors.  Only looks at predecessors of
+   * the given file type.
+   */
+  public void assertPredecessorClosureSameContents(
+      Artifact artifact, FileType fType, Iterable<String> common, String... expectedPredecessors) {
+    assertSameContentsWithCommonElements(
+        actionsTestUtil().predecessorClosureAsCollection(artifact, fType),
+        expectedPredecessors, common);
+  }
+
+  /**
+   * Utility method for asserting that the contents of one collection are the
+   * same as those in a second plus some set of common elements.
+   */
+  protected void assertSameContentsWithCommonElements(Iterable<Artifact> artifacts,
+      Iterable<String> common, String... expectedInputs) {
+    assertSameContents(Iterables.concat(Lists.newArrayList(expectedInputs), common),
+        ActionsTestUtil.prettyArtifactNames(artifacts));
+  }
+
+  /**
+   * Utility method for asserting that the contents of one collection are the
+   * same as those in a second plus some set of common elements.
+   */
+  protected void assertSameContentsWithCommonElements(Iterable<String> artifacts,
+      String[] expectedInputs, Iterable<String> common) {
+    assertSameContents(Iterables.concat(Lists.newArrayList(expectedInputs), common), artifacts);
+  }
+
+  /**
+   * Utility method for asserting that a list contains the elements of a
+   * sublist This is useful for checking that a list of arguments contains a
+   * particular set of arguments.
+   */
+  protected void assertContainsSublist(List<String> list, List<String> sublist) {
+    assertContainsSublist(null, list, sublist);
+  }
+
+  /**
+   * Utility method for asserting that a list contains the elements of a
+   * sublist This is useful for checking that a list of arguments contains a
+   * particular set of arguments.
+   */
+  protected void assertContainsSublist(String message, List<String> list, List<String> sublist) {
+    if (Collections.indexOfSubList(list, sublist) == -1) {
+      fail((message == null ? "" : (message + ' '))
+          + "expected: <" + list + "> to contain sublist: <" + sublist + ">");
+    }
+  }
+
+  protected void assertContainsSelfEdgeEvent(String label) {
+    assertContainsEvent(label + " [self-edge]");
+  }
+
+  protected Iterable<Artifact> collectRunfiles(ConfiguredTarget target) {
+    RunfilesProvider runfilesProvider = target.getProvider(RunfilesProvider.class);
+    if (runfilesProvider != null) {
+      return runfilesProvider.getDefaultRunfiles().getAllArtifacts();
+    } else {
+      return Runfiles.EMPTY.getAllArtifacts();
+    }
+  }
+
+  protected NestedSet<Artifact> getFilesToBuild(TransitiveInfoCollection target) {
+    return target.getProvider(FileProvider.class).getFilesToBuild();
+  }
+
+  /**
+   * Returns all extra actions for that target (no transitive actions), no duplicate actions.
+   */
+  protected ImmutableList<Action> getExtraActionActions(ConfiguredTarget target) {
+    LinkedHashSet<Action> result = new LinkedHashSet<>();
+    for (Artifact artifact : getExtraActionArtifacts(target)) {
+      result.add(getGeneratingAction(artifact));
+    }
+    return ImmutableList.copyOf(result);
+  }
+
+  protected ImmutableList<Action> getFilesToBuildActions(ConfiguredTarget target) {
+    List<Action> result = new ArrayList<>();
+    for (Artifact artifact : getFilesToBuild(target)) {
+      Action action = getGeneratingAction(artifact);
+      if (action != null) {
+        result.add(action);
+      }
+    }
+    return ImmutableList.copyOf(result);
+  }
+
+  protected NestedSet<Artifact> getOutputGroup(
+      TransitiveInfoCollection target, String outputGroup) {
+    TopLevelArtifactProvider provider = target.getProvider(TopLevelArtifactProvider.class);
+    return provider == null
+        ? NestedSetBuilder.<Artifact>emptySet(Order.STABLE_ORDER)
+        : provider.getOutputGroup(outputGroup);
+  }
+
+  protected ImmutableList<Artifact> getExtraActionArtifacts(ConfiguredTarget target) {
+    return target.getProvider(ExtraActionArtifactsProvider.class).getExtraActionArtifacts();
+  }
+
+  protected Artifact getExecutable(String label) throws Exception {
+    return getConfiguredTarget(label).getProvider(FilesToRunProvider.class).getExecutable();
+  }
+
+  protected Artifact getExecutable(TransitiveInfoCollection target) {
+    return target.getProvider(FilesToRunProvider.class).getExecutable();
+  }
+
+  protected ImmutableList<Artifact> getFilesToRun(TransitiveInfoCollection target) {
+    return target.getProvider(FilesToRunProvider.class).getFilesToRun();
+  }
+
+  protected ImmutableList<Artifact> getFilesToRun(Label label) throws Exception {
+    return getConfiguredTarget(label, targetConfig)
+        .getProvider(FilesToRunProvider.class).getFilesToRun();
+  }
+
+  protected ImmutableList<Artifact> getFilesToRun(String label) throws Exception {
+    return getConfiguredTarget(label).getProvider(FilesToRunProvider.class).getFilesToRun();
+  }
+
+  protected RunfilesSupport getRunfilesSupport(String label) throws Exception {
+    return getConfiguredTarget(label).getProvider(FilesToRunProvider.class).getRunfilesSupport();
+  }
+
+  protected RunfilesSupport getRunfilesSupport(TransitiveInfoCollection target) {
+    return target.getProvider(FilesToRunProvider.class).getRunfilesSupport();
+  }
+
+  protected static Runfiles getDefaultRunfiles(ConfiguredTarget target) {
+    return target.getProvider(RunfilesProvider.class).getDefaultRunfiles();
+  }
+
+  protected static Runfiles getDataRunfiles(ConfiguredTarget target) {
+    return target.getProvider(RunfilesProvider.class).getDataRunfiles();
+  }
+
+  protected BuildConfiguration getTargetConfiguration() {
+    return Iterables.getOnlyElement(masterConfig.getTargetConfigurations());
+  }
+
+  protected BuildConfiguration getDataConfiguration() {
+    return getTargetConfiguration().getConfiguration(ConfigurationTransition.DATA);
+  }
+
+  protected BuildConfiguration getHostConfiguration() {
+    return getTargetConfiguration().getConfiguration(ConfigurationTransition.HOST);
+  }
+
+  /**
+   * Returns an attribute value retriever for the given rule for the target configuration.
+
+   */
+  protected AttributeMap attributes(RuleConfiguredTarget ct) {
+    return ConfiguredAttributeMapper.of(ct);
+  }
+
+  protected AttributeMap attributes(ConfiguredTarget rule) {
+    return attributes((RuleConfiguredTarget) rule);
+  }
+
+  protected AnalysisResult update(List<String> targets,
+      boolean keepGoing,
+      int loadingPhaseThreads,
+      boolean doAnalysis,
+      EventBus eventBus) throws Exception {
+
+    LoadingPhaseRunner.Options loadingOptions =
+        Options.getDefaults(LoadingPhaseRunner.Options.class);
+    loadingOptions.loadingPhaseThreads = loadingPhaseThreads;
+
+    BuildView.Options viewOptions = Options.getDefaults(BuildView.Options.class);
+    viewOptions.keepGoing = keepGoing;
+
+    LoadingPhaseRunner runner = new LoadingPhaseRunner(getPackageManager(),
+        Collections.unmodifiableSet(ruleClassProvider.getRuleClassMap().keySet()));
+    LoadingResult loadingResult = runner.execute(reporter, eventBus, targets, loadingOptions,
+        getTargetConfiguration().getAllLabels(),
+        viewOptions.keepGoing, /*determineTests=*/false, /*callback=*/null);
+    if (!doAnalysis) {
+      // TODO(bazel-team): What's supposed to happen in this case?
+      return null;
+    }
+    return view.update(loadingResult, masterConfig, viewOptions,
+        TopLevelArtifactContext.DEFAULT, reporter, eventBus);
+  }
+
+  protected static Predicate<Artifact> artifactNamed(final String name) {
+    return new Predicate<Artifact>() {
+      @Override
+      public boolean apply(Artifact input) {
+        return name.equals(input.prettyPrint());
+      }
+    };
+  }
+
+  /**
+   * Utility method for tests. Converts an array of strings into a set of labels.
+   *
+   * @param strings the set of strings to be converted to labels.
+   * @throws SyntaxException if there are any syntax errors in the strings.
+   */
+  public static Set<Label> asLabelSet(String... strings) throws SyntaxException {
+    return asLabelSet(ImmutableList.copyOf(strings));
+  }
+
+  /**
+   * Utility method for tests. Converts an array of strings into a set of labels.
+   *
+   * @param strings the set of strings to be converted to labels.
+   * @throws SyntaxException if there are any syntax errors in the strings.
+   */
+  public static Set<Label> asLabelSet(Iterable<String> strings) throws SyntaxException {
+    Set<Label> result = Sets.newTreeSet();
+    for (String s : strings) {
+      result.add(Label.parseAbsolute(s));
+    }
+    return result;
+  }
+
+  protected SpawnAction getGeneratingAction(ConfiguredTarget target,
+      String outputName) {
+    Artifact found = Iterables.find(getFilesToBuild(target),
+        artifactNamed(outputName));
+    return (SpawnAction) getGeneratingAction(found);
+  }
+
+  protected String getErrorMsgSingleFile(String attrName, String ruleType, String ruleName,
+      String depRuleName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": '"
+        + depRuleName + "' must produce a single file";
+  }
+
+  protected String getErrorMsgNoGoodFiles(String attrName, String ruleType, String ruleName,
+      String depRuleName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": '"
+        + depRuleName + "' does not produce any " + ruleType + " " + attrName + " files";
+  }
+
+  protected String getErrorMsgMisplacedFiles(String attrName, String ruleType, String ruleName,
+      String fileName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": file '"
+        + fileName + "' is misplaced here";
+  }
+
+  protected String getErrorNonExistingTarget(String attrName, String ruleType, String ruleName,
+      String targetName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": target '"
+        + targetName + "' does not exist";
+  }
+
+  protected String getErrorNonExistingRule(String attrName, String ruleType, String ruleName,
+      String targetName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": rule '"
+        + targetName + "' does not exist";
+  }
+
+  protected String getErrorMsgMisplacedRules(String attrName, String ruleType, String ruleName,
+      String depRuleType, String depRuleName) {
+    return "in " + attrName + " attribute of " + ruleType + " rule " + ruleName + ": "
+        + depRuleType + " rule '" + depRuleName + "' is misplaced here";
+  }
+
+  public static String getErrorMsgNonEmptyList(String attrName, String ruleType, String ruleName) {
+    return "non empty attribute '" + attrName + "' in '" + ruleType
+        + "' rule '" + ruleName + "' has to have at least one value";
+  }
+
+  protected String getErrorMsgMandatoryMissing(String attrName, String ruleType) {
+    return "missing value for mandatory attribute '" + attrName + "' in '" + ruleType + "' rule";
+  }
+
+  protected String getErrorMsgWrongAttributeValue(String value, String... expected) {
+    return String.format("has to be one of %s instead of '%s'",
+          StringUtil.joinEnglishList(ImmutableSet.copyOf(expected), "or", "'"), value);
+  }
+
+  private class StubAnalysisEnvironment implements AnalysisEnvironment {
+
+    @Override
+    public void registerAction(Action... action) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean hasErrors() {
+      return false;
+    }
+
+    @Override
+    public Artifact getEmbeddedToolArtifact(String embeddedPath) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Artifact getConstantMetadataArtifact(PathFragment rootRelativePath, Root root) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public EventHandler getEventHandler() {
+      return reporter;
+    }
+
+    @Override
+    public MiddlemanFactory getMiddlemanFactory() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Action getLocalGeneratingAction(Artifact artifact) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Iterable<Action> getRegisteredActions() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public SkyFunction.Environment getSkyframeEnv() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Artifact getFilesetArtifact(PathFragment rootRelativePath, Root root) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Artifact getDerivedArtifact(PathFragment rootRelativePath, Root root) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Artifact getStableWorkspaceStatusArtifact() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Artifact getVolatileWorkspaceStatusArtifact() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ImmutableList<Artifact> getBuildInfo(RuleContext ruleContext, BuildInfoKey key) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ArtifactOwner getOwner() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ImmutableSet<Artifact> getOrphanArtifacts() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  protected Iterable<String> baselineCoverageArtifactBasenames(ConfiguredTarget target)
+      throws Exception {
+    Artifact baselineCoverage = Iterables.getOnlyElement(target
+        .getProvider(TopLevelArtifactProvider.class)
+        .getOutputGroup(TopLevelArtifactProvider.BASELINE_COVERAGE));
+    BaselineCoverageAction baselineAction =
+        (BaselineCoverageAction) getGeneratingAction(baselineCoverage);
+
+    EventBus eventBus = new EventBus();
+    Executor mockExecutor = Mockito.mock(Executor.class);
+    Mockito.when(mockExecutor.getEventBus()).thenReturn(eventBus);
+
+    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    baselineAction.newDeterministicWriter(reporter, mockExecutor).writeOutputFile(bytes);
+
+    ImmutableList.Builder<String> basenames = ImmutableList.builder();
+    for (String line : new String(bytes.toByteArray(), StandardCharsets.UTF_8).split("\n")) {
+      if (line.startsWith("SF:")) {
+        String basename = line.substring(line.lastIndexOf("/") + 1);
+        basenames.add(basename);
+      }
+    }
+
+    return basenames.build();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/BinaryFileWriteActionTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/BinaryFileWriteActionTest.java
new file mode 100644
index 0000000..22e6bcf
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/BinaryFileWriteActionTest.java
@@ -0,0 +1,50 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import com.google.common.io.ByteSource;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+
+import java.nio.charset.StandardCharsets;
+
+public class BinaryFileWriteActionTest extends FileWriteActionTestCase {
+  @Override
+  protected Action createAction(
+      ActionOwner actionOwner, Artifact outputArtifact, String data, boolean makeExecutable) {
+    return new BinaryFileWriteAction(actionOwner, outputArtifact,
+        ByteSource.wrap(data.getBytes(StandardCharsets.UTF_8)), makeExecutable);
+  }
+
+  public void testNoInputs() {
+    checkNoInputsByDefault();
+  }
+
+  public void testDestinationArtifactIsOutput() {
+    checkDestinationArtifactIsOutput();
+  }
+
+  public void testCanWriteNonExecutableFile() throws Exception {
+    checkCanWriteNonExecutableFile();
+  }
+
+  public void testCanWriteExecutableFile() throws Exception {
+    checkCanWriteExecutableFile();
+  }
+
+  public void testComputesConsistentKeys() throws Exception {
+    checkComputesConsistentKeys();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/CustomCommandLineTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/CustomCommandLineTest.java
new file mode 100644
index 0000000..d3b8723
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/CustomCommandLineTest.java
@@ -0,0 +1,78 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.analysis.BuildViewTestCase;
+import com.google.devtools.build.lib.vfs.PathFragment;
+
+public class CustomCommandLineTest extends BuildViewTestCase {
+  public void testAddBeforeEachPath() {
+    CustomCommandLine commandLine = new CustomCommandLine.Builder()
+        .add("foo")
+        .addBeforeEachPath(
+            "-I", ImmutableList.of(new PathFragment("/path1"), new PathFragment("/path2")))
+        .add("bar")
+        .addBeforeEachPath("-I", ImmutableList.<PathFragment>of())
+        .add("baz")
+        .build();
+    assertThat(commandLine.arguments())
+        .containsExactly("foo", "-I", "/path1", "-I", "/path2", "bar", "baz")
+        .inOrder();
+  }
+
+  public void testAddBeforeEach() {
+    CustomCommandLine commandLine = new CustomCommandLine.Builder()
+        .add("foo")
+        .addBeforeEach("-D", ImmutableList.<String>of())
+        .add("bar")
+        .addBeforeEach("-D", ImmutableList.of("DEBUG=42", "ENABLE_QUANTUM", "__OBJC__"))
+        .add("baz")
+        .build();
+    assertThat(commandLine.arguments())
+        .containsExactly(
+            "foo", "bar", "-D", "DEBUG=42", "-D", "ENABLE_QUANTUM", "-D", "__OBJC__", "baz")
+        .inOrder();
+  }
+
+  public void testAddBeforeEachExecPath() throws Exception {
+    CustomCommandLine commandLine = new CustomCommandLine.Builder()
+        .add("foo")
+        .addBeforeEachExecPath("-l",
+            ImmutableList.of(getSourceArtifact("pkg/util.a"), getSourceArtifact("pkg2/extra.a")))
+        .add("bar")
+        .addBeforeEachExecPath("-l", ImmutableList.<Artifact>of())
+        .add("baz")
+        .build();
+    assertThat(commandLine.arguments())
+        .containsExactly("foo", "-l", "pkg/util.a", "-l", "pkg2/extra.a", "bar", "baz")
+        .inOrder();
+  }
+
+  public void testAddFormatEach() {
+    CustomCommandLine commandLine = new CustomCommandLine.Builder()
+        .add("foo")
+        .addFormatEach("-X'%s'", ImmutableList.<String>of())
+        .add("bar")
+        .addFormatEach("-X'%s'", ImmutableList.of("42", "1011"))
+        .add("baz")
+        .build();
+    assertThat(commandLine.arguments())
+        .containsExactly("foo", "bar", "-X'42'", "-X'1011'", "baz")
+        .inOrder();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTest.java
new file mode 100644
index 0000000..1bc136f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTest.java
@@ -0,0 +1,46 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+
+public class FileWriteActionTest extends FileWriteActionTestCase {
+
+  @Override
+  protected FileWriteAction createAction(
+      ActionOwner actionOwner, Artifact outputArtifact, String data, boolean makeExecutable) {
+    return new FileWriteAction(actionOwner, outputArtifact, data, makeExecutable);
+  }
+
+  public void testNoInputs() {
+    checkNoInputsByDefault();
+  }
+
+  public void testDestinationArtifactIsOutput() {
+    checkDestinationArtifactIsOutput();
+  }
+
+  public void testCanWriteNonExecutableFile() throws Exception {
+    checkCanWriteNonExecutableFile();
+  }
+
+  public void testCanWriteExecutableFile() throws Exception {
+    checkCanWriteExecutableFile();
+  }
+
+  public void testComputesConsistentKeys() throws Exception {
+    checkComputesConsistentKeys();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTestCase.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTestCase.java
new file mode 100644
index 0000000..ff0ed2f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/FileWriteActionTestCase.java
@@ -0,0 +1,93 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.analysis.BuildViewTestCase;
+import com.google.devtools.build.lib.analysis.util.ActionTester;
+import com.google.devtools.build.lib.exec.util.TestExecutorBuilder;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+import java.util.Collection;
+
+public abstract class FileWriteActionTestCase extends BuildViewTestCase {
+
+  private Action action;
+  private Artifact outputArtifact;
+  private Path output;
+  private Executor executor;
+  private ActionExecutionContext context;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    outputArtifact = getBinArtifactWithNoOwner("destination.txt");
+    output = outputArtifact.getPath();
+    FileSystemUtils.createDirectoryAndParents(output.getParentDirectory());
+    action = createAction(NULL_ACTION_OWNER, outputArtifact, "Hello World", false);
+    executor = new TestExecutorBuilder(directories, binTools).build();
+    context = new ActionExecutionContext(executor, null, null, new FileOutErr(), null);
+  }
+
+  protected abstract Action createAction(
+      ActionOwner actionOwner, Artifact outputArtifact, String data, boolean makeExecutable);
+
+  protected void checkNoInputsByDefault() {
+    assertThat(action.getInputs()).isEmpty();
+    assertNull(action.getPrimaryInput());
+  }
+
+  protected void checkDestinationArtifactIsOutput() {
+    Collection<Artifact> outputs = action.getOutputs();
+    assertEquals(asSet(outputArtifact), asSet(outputs));
+    assertEquals(outputArtifact, action.getPrimaryOutput());
+  }
+
+  protected void checkCanWriteNonExecutableFile() throws Exception {
+    action.execute(context);
+    String content = new String(FileSystemUtils.readContentAsLatin1(output));
+    assertEquals("Hello World", content);
+    assertFalse(output.isExecutable());
+  }
+
+  protected void checkCanWriteExecutableFile() throws Exception {
+    Artifact outputArtifact = getBinArtifactWithNoOwner("hello");
+    Path output = outputArtifact.getPath();
+    Action action = createAction(NULL_ACTION_OWNER, outputArtifact, "echo 'Hello World'", true);
+    action.execute(context);
+    String content = new String(FileSystemUtils.readContentAsLatin1(output));
+    assertEquals("echo 'Hello World'", content);
+    assertTrue(output.isExecutable());
+  }
+
+  protected void checkComputesConsistentKeys() throws Exception {
+    ActionTester.runTest(4, new ActionTester.ActionCombinationFactory() {
+      @Override
+      public Action generate(int i) {
+        return createAction(NULL_ACTION_OWNER, outputArtifact,
+            (i & 1) == 0 ? "0" : "1",
+            (i & 2) == 0);
+      }
+    });
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/SpawnActionTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/SpawnActionTest.java
new file mode 100644
index 0000000..6ff4a8d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/SpawnActionTest.java
@@ -0,0 +1,377 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.util.Arrays.asList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.actions.AbstractAction;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ParameterFile.ParameterFileType;
+import com.google.devtools.build.lib.actions.extra.EnvironmentVariable;
+import com.google.devtools.build.lib.actions.extra.ExtraActionInfo;
+import com.google.devtools.build.lib.actions.extra.SpawnInfo;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.analysis.BuildViewTestCase;
+import com.google.devtools.build.lib.analysis.util.ActionTester;
+import com.google.devtools.build.lib.analysis.util.ActionTester.ActionCombinationFactory;
+import com.google.devtools.build.lib.analysis.util.AnalysisTestUtil;
+import com.google.devtools.build.lib.vfs.PathFragment;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tests {@link SpawnAction}.
+ */
+public class SpawnActionTest extends BuildViewTestCase {
+  private Artifact welcomeArtifact;
+  private Artifact destinationArtifact;
+  private Artifact jarArtifact;
+  private AnalysisTestUtil.CollectingAnalysisEnvironment collectingAnalysisEnvironment;
+
+  private SpawnAction.Builder builder() {
+    return new SpawnAction.Builder();
+  }
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+
+    collectingAnalysisEnvironment = new AnalysisTestUtil.CollectingAnalysisEnvironment(
+        getTestAnalysisEnvironment());
+    welcomeArtifact = getSourceArtifact("pkg/welcome.txt");
+    jarArtifact = getSourceArtifact("pkg/exe.jar");
+    destinationArtifact = getBinArtifactWithNoOwner("dir/destination.txt");
+  }
+
+  private SpawnAction createCopyFromWelcomeToDestination() {
+    PathFragment cp = new PathFragment("/bin/cp");
+    List<String> arguments = asList(welcomeArtifact.getExecPath().getPathString(),
+        destinationArtifact.getExecPath().getPathString());
+
+    Action[] actions = builder()
+        .addInput(welcomeArtifact)
+        .addOutput(destinationArtifact)
+        .setExecutionInfo(ImmutableMap.<String, String>of("local", ""))
+        .setExecutable(cp)
+        .addArguments(arguments)
+        .setProgressMessage("hi, mom!")
+        .setMnemonic("Dummy")
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    return (SpawnAction) actions[0];
+  }
+
+  public void testWelcomeArtifactIsInput() {
+    SpawnAction copyFromWelcomeToDestination = createCopyFromWelcomeToDestination();
+    Iterable<Artifact> inputs = copyFromWelcomeToDestination.getInputs();
+    assertEquals(asSet(welcomeArtifact), asSet(inputs));
+  }
+
+  public void testDestinationArtifactIsOutput() {
+    SpawnAction copyFromWelcomeToDestination = createCopyFromWelcomeToDestination();
+    Collection<Artifact> outputs = copyFromWelcomeToDestination.getOutputs();
+    assertEquals(asSet(destinationArtifact), asSet(outputs));
+  }
+
+  public void testBuilder() throws Exception {
+    Artifact input = getSourceArtifact("input");
+    Artifact output = getBinArtifactWithNoOwner("output");
+    Action[] actions = builder()
+        .addInput(input)
+        .addOutput(output)
+        .setExecutable(scratchFile("/bin/xxx").asFragment())
+        .setProgressMessage("Test")
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    assertEquals(ActionsTestUtil.NULL_ACTION_OWNER.getLabel(),
+        action.getOwner().getLabel());
+    assertSameContents(asList(input), action.getInputs());
+    assertSameContents(asList(output), action.getOutputs());
+    assertEquals(AbstractAction.DEFAULT_RESOURCE_SET, action.getSpawn().getLocalResources());
+    assertSameContents(asList("/bin/xxx"), action.getArguments());
+    assertEquals("Test", action.getProgressMessage());
+  }
+
+  public void testBuilderWithExecutable() throws Exception {
+    Action[] actions = builder()
+        .setExecutable(welcomeArtifact)
+        .addOutput(destinationArtifact)
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    assertSameContents(asList(welcomeArtifact.getExecPath().getPathString()),
+        action.getArguments());
+  }
+
+  public void testBuilderWithJavaExecutable() throws Exception {
+    Action[] actions = builder()
+        .addOutput(destinationArtifact)
+        .setJavaExecutable(scratchFile("/bin/java").asFragment(),
+            jarArtifact, "MyMainClass", asList("-jvmarg"))
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass"), action.getArguments());
+  }
+
+  public void testBuilderWithJavaExecutableAndParameterFile() throws Exception {
+    useConfiguration("--min_param_file_size=0");
+    collectingAnalysisEnvironment = new AnalysisTestUtil.CollectingAnalysisEnvironment(
+        getTestAnalysisEnvironment());
+    Artifact output = getBinArtifactWithNoOwner("output");
+    Artifact paramFile = getBinArtifactWithNoOwner("output-2.params");
+    Action[] actions = builder()
+        .addOutput(output)
+        .setJavaExecutable(
+            scratchFile("/bin/java").asFragment(), jarArtifact, "MyMainClass", asList("-jvmarg"))
+        .addArgument("-X")
+        .useParameterFile(ParameterFileType.UNQUOTED)
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    if (getMutableActionGraph() != null) {
+      // Otherwise, CachingAnalysisEnvironment.registerAction() registers the action. We cannot
+      // use STUB_ANALYSIS_ENVIRONMENT here because we also need a BuildConfiguration.
+      collectingAnalysisEnvironment.registerWith(getMutableActionGraph());
+    }
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass", "@" + paramFile.getExecPathString()),
+        action.getArguments());
+    assertEquals(ImmutableList.of("-X"),
+        ImmutableList.copyOf(
+            ((ParameterFileWriteAction) getGeneratingAction(paramFile)).getContents()));
+    assertContainsSublist(actionInputsToPaths(action.getSpawn().getInputFiles()),
+        "pkg/exe.jar");
+  }
+
+  public void testBuilderWithJavaExecutableAndParameterFileAndParameterFileFlag() throws Exception {
+    useConfiguration("--min_param_file_size=0");
+    collectingAnalysisEnvironment = new AnalysisTestUtil.CollectingAnalysisEnvironment(
+        getTestAnalysisEnvironment());
+
+    Artifact output = getBinArtifactWithNoOwner("output");
+    Artifact paramFile = getBinArtifactWithNoOwner("output-2.params");
+    Action[] actions = builder()
+        .addOutput(output)
+        .setJavaExecutable(
+            scratchFile("/bin/java").asFragment(), jarArtifact, "MyMainClass", asList("-jvmarg"))
+        .addArgument("-X")
+        .useParameterFile(ParameterFileType.UNQUOTED, ISO_8859_1, "--flagfile=")
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    if (getMutableActionGraph() != null) {
+      // Otherwise, CachingAnalysisEnvironment.registerAction() registers the action. We cannot
+      // use STUB_ANALYSIS_ENVIRONMENT here because we also need a BuildConfiguration.
+      collectingAnalysisEnvironment.registerWith(getMutableActionGraph());
+    }
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass", "--flagfile=" + paramFile.getExecPathString()),
+        ImmutableList.copyOf(action.getArguments()));
+    assertEquals(Arrays.asList("-X"),
+        ImmutableList.copyOf(
+            ((ParameterFileWriteAction) getGeneratingAction(paramFile)).getContents()));
+    assertContainsSublist(actionInputsToPaths(action.getSpawn().getInputFiles()),
+        "pkg/exe.jar");
+  }
+
+  public void testBuilderWithExtraExecutableArguments() throws Exception {
+    Action[] actions = builder()
+        .addOutput(destinationArtifact)
+        .setJavaExecutable(
+            scratchFile("/bin/java").asFragment(), jarArtifact, "MyMainClass", asList("-jvmarg"))
+        .addExecutableArguments("execArg1", "execArg2")
+        .addArguments("arg1")
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass", "execArg1", "execArg2", "arg1"),
+        action.getArguments());
+  }
+
+  public void testBuilderWithExtraExecutableArgumentsAndParameterFile() throws Exception {
+    useConfiguration("--min_param_file_size=0");
+    collectingAnalysisEnvironment = new AnalysisTestUtil.CollectingAnalysisEnvironment(
+        getTestAnalysisEnvironment());
+    Artifact output = getBinArtifactWithNoOwner("output");
+    Artifact paramFile = getBinArtifactWithNoOwner("output-2.params");
+    Action[] actions = builder()
+        .addOutput(output)
+        .setJavaExecutable(
+            scratchFile("/bin/java").asFragment(), jarArtifact, "MyMainClass", asList("-jvmarg"))
+        .addExecutableArguments("execArg1", "execArg2")
+        .addArguments("arg1", "arg2", "arg3")
+        .useParameterFile(ParameterFileType.UNQUOTED)
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    if (getMutableActionGraph() != null) {
+      // Otherwise, CachingAnalysisEnvironment.registerAction() registers the action. We cannot
+      // use STUB_ANALYSIS_ENVIRONMENT here because we also need a BuildConfiguration.
+      collectingAnalysisEnvironment.registerWith(getMutableActionGraph());
+    }
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass", "execArg1", "execArg2",
+        "@" + paramFile.getExecPathString()), action.getSpawn().getArguments());
+    assertEquals(asList("/bin/java", "-Xverify:none", "-jvmarg", "-cp",
+        "pkg/exe.jar", "MyMainClass", "execArg1", "execArg2",
+        "@" + paramFile.getExecPathString()), ImmutableList.copyOf(action.getArguments()));
+    assertEquals(Arrays.asList("arg1", "arg2", "arg3"),
+        ImmutableList.copyOf(
+            ((ParameterFileWriteAction) getGeneratingAction(paramFile)).getContents()));
+  }
+
+  public void testParameterFiles() throws Exception {
+    Artifact output1 = getBinArtifactWithNoOwner("output1");
+    Artifact output2 = getBinArtifactWithNoOwner("output2");
+    Artifact paramFile = getBinArtifactWithNoOwner("output1-2.params");
+    PathFragment executable = new PathFragment("/bin/executable");
+
+    useConfiguration("--min_param_file_size=500");
+
+    String longOption = Strings.repeat("x", 1000);
+    SpawnAction spawnAction = ((SpawnAction) builder()
+        .addOutput(output1)
+        .setExecutable(executable)
+        .useParameterFile(ParameterFileType.UNQUOTED)
+        .addArgument(longOption)
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig)[0]);
+    assertThat(spawnAction.getRemainingArguments()).containsExactly(
+        "@" + paramFile.getExecPathString()).inOrder();
+
+    useConfiguration("--min_param_file_size=1500");
+    spawnAction = ((SpawnAction) builder()
+        .addOutput(output2)
+        .setExecutable(executable)
+        .useParameterFile(ParameterFileType.UNQUOTED)
+        .addArgument(longOption)
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig)[0]);
+    assertThat(spawnAction.getRemainingArguments()).containsExactly(longOption).inOrder();
+  }
+
+  public void testExtraActionInfo() throws Exception {
+    SpawnAction copyFromWelcomeToDestination = createCopyFromWelcomeToDestination();
+    ExtraActionInfo.Builder builder = copyFromWelcomeToDestination.getExtraActionInfo();
+    ExtraActionInfo info = builder.build();
+    assertEquals("Dummy", info.getMnemonic());
+
+    SpawnInfo spawnInfo = info.getExtension(SpawnInfo.spawnInfo);
+    assertNotNull(spawnInfo);
+
+    assertSameContents(copyFromWelcomeToDestination.getArguments(), spawnInfo.getArgumentList());
+
+    Iterable<String> inputPaths = Artifact.toExecPaths(
+        copyFromWelcomeToDestination.getInputs());
+    Iterable<String> outputPaths = Artifact.toExecPaths(
+        copyFromWelcomeToDestination.getOutputs());
+
+    assertSameContents(inputPaths, spawnInfo.getInputFileList());
+    assertSameContents(outputPaths, spawnInfo.getOutputFileList());
+    Map<String, String> environment = copyFromWelcomeToDestination.getEnvironment();
+    assertEquals(environment.size(), spawnInfo.getVariableCount());
+
+    for (EnvironmentVariable variable : spawnInfo.getVariableList()) {
+      assertEquals(variable.getValue(), environment.get(variable.getName()));
+    }
+  }
+
+  public void testInputManifest() throws Exception {
+    Artifact manifest = getSourceArtifact("MANIFEST");
+    Action[] actions = builder()
+        .addInput(manifest)
+        .addInputManifest(manifest, new PathFragment("/destination/"))
+        .addOutput(getBinArtifactWithNoOwner("output"))
+        .setExecutable(scratchFile("/bin/xxx").asFragment())
+        .setProgressMessage("Test")
+        .build(ActionsTestUtil.NULL_ACTION_OWNER, collectingAnalysisEnvironment, targetConfig);
+    collectingAnalysisEnvironment.registerAction(actions);
+    SpawnAction action = (SpawnAction) actions[0];
+    List<String> inputFiles = actionInputsToPaths(action.getSpawn().getInputFiles());
+    assertTrue(inputFiles.isEmpty());
+  }
+
+  public void testComputeKey() throws Exception {
+    final Artifact artifactA = getSourceArtifact("a");
+    final Artifact artifactB = getSourceArtifact("b");
+
+    ActionTester.runTest(64, new ActionCombinationFactory() {
+      @Override
+      public Action generate(int i) {
+        SpawnAction.Builder builder = builder();
+        builder.addOutput(destinationArtifact);
+
+        PathFragment executable = (i & 1) == 0 ? artifactA.getExecPath() : artifactB.getExecPath();
+        if ((i & 2) == 0) {
+          builder.setExecutable(executable);
+        } else {
+          builder.setJavaExecutable(executable, jarArtifact, "Main", ImmutableList.<String>of());
+        }
+
+        builder.setMnemonic((i & 4) == 0 ? "a" : "b");
+
+        if ((i & 8) == 0) {
+          builder.addInputManifest(artifactA, new PathFragment("a"));
+        } else {
+          builder.addInputManifest(artifactB, new PathFragment("a"));
+        }
+
+        if ((i & 16) == 0) {
+          builder.addInputManifest(artifactA, new PathFragment("aa"));
+        } else {
+          builder.addInputManifest(artifactA, new PathFragment("ab"));
+        }
+
+        Map<String, String> env = new HashMap<>();
+        if ((i & 32) == 0) {
+          env.put("foo", "bar");
+        }
+        builder.setEnvironment(env);
+
+        Action[] actions = builder.build(ActionsTestUtil.NULL_ACTION_OWNER,
+            collectingAnalysisEnvironment, targetConfig);
+        collectingAnalysisEnvironment.registerAction(actions);
+        return actions[0];
+      }
+    });
+  }
+
+  public void testMnemonicMustNotContainSpaces() {
+    SpawnAction.Builder builder = builder();
+    try {
+      builder.setMnemonic("contains space");
+      fail("Expected exception");
+    } catch (IllegalArgumentException expected) {}
+    try {
+      builder.setMnemonic("contains\nnewline");
+      fail("Expected exception");
+    } catch (IllegalArgumentException expected) {}
+    try {
+      builder.setMnemonic("contains/slash");
+      fail("Expected exception");
+    } catch (IllegalArgumentException expected) {}
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/SymlinkActionTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/SymlinkActionTest.java
new file mode 100644
index 0000000..4ab2c12
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/SymlinkActionTest.java
@@ -0,0 +1,92 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionExecutionException;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.analysis.BuildViewTestCase;
+import com.google.devtools.build.lib.exec.util.TestExecutorBuilder;
+import com.google.devtools.build.lib.testutil.MoreAsserts;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+/**
+ * Tests {@link SymlinkAction}.
+ */
+public class SymlinkActionTest extends BuildViewTestCase {
+
+  private Path input;
+  private Artifact inputArtifact;
+  private Path output;
+  private Artifact outputArtifact;
+  private SymlinkAction action;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    input = scratchFile("/workspace/input.txt", "Hello, world.");
+    inputArtifact = getSourceArtifact("input.txt");
+    Path linkedInput = directories.getExecRoot().getRelative("input.txt");
+    FileSystemUtils.createDirectoryAndParents(linkedInput.getParentDirectory());
+    linkedInput.createSymbolicLink(input);
+    outputArtifact = getBinArtifactWithNoOwner("destination.txt");
+    output = outputArtifact.getPath();
+    FileSystemUtils.createDirectoryAndParents(output.getParentDirectory());
+    action = new SymlinkAction(NULL_ACTION_OWNER,
+        inputArtifact, outputArtifact, "Symlinking test");
+  }
+
+  public void testInputArtifactIsInput() {
+    Iterable<Artifact> inputs = action.getInputs();
+    assertEquals(asSet(inputArtifact), asSet(inputs));
+  }
+
+  public void testDestinationArtifactIsOutput() {
+    Iterable<Artifact> outputs = action.getOutputs();
+    assertEquals(asSet(outputArtifact), asSet(outputs));
+  }
+
+  public void testSymlink() throws Exception {
+    Executor executor = new TestExecutorBuilder(directories, null).build();
+    action.execute(new ActionExecutionContext(executor, null, null, null, null));
+    assertTrue(output.isSymbolicLink());
+    assertEquals(input, output.resolveSymbolicLinks());
+    assertEquals(inputArtifact, action.getPrimaryInput());
+    assertEquals(outputArtifact, action.getPrimaryOutput());
+  }
+
+  public void testExecutableSymlink() throws Exception {
+    Executor executor = new TestExecutorBuilder(directories, null).build();
+    outputArtifact = getBinArtifactWithNoOwner("destination2.txt");
+    output = outputArtifact.getPath();
+    action = new ExecutableSymlinkAction(NULL_ACTION_OWNER, inputArtifact, outputArtifact);
+    assertFalse(input.isExecutable());
+    ActionExecutionContext actionExecutionContext =
+        new ActionExecutionContext(executor, null, null, null, null);
+    try {
+      action.execute(actionExecutionContext);
+      fail("Expected ActionExecutionException");
+    } catch (ActionExecutionException e) {
+      MoreAsserts.assertContainsRegex("'input.txt' is not executable", e.getMessage());
+    }
+    input.setExecutable(true);
+    action.execute(actionExecutionContext);
+    assertTrue(output.isSymbolicLink());
+    assertEquals(input, output.resolveSymbolicLinks());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/actions/TemplateExpansionActionTest.java b/src/test/java/com/google/devtools/build/lib/analysis/actions/TemplateExpansionActionTest.java
new file mode 100644
index 0000000..ec8935e
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/actions/TemplateExpansionActionTest.java
@@ -0,0 +1,169 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.actions;
+
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.NULL_ACTION_OWNER;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.Root;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction.Substitution;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction.Template;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.exec.util.TestExecutorBuilder;
+import com.google.devtools.build.lib.testutil.FoundationTestCase;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+import java.util.List;
+
+/**
+ * Tests {@link TemplateExpansionAction}.
+ */
+public class TemplateExpansionActionTest extends FoundationTestCase {
+
+  private static final String TEMPLATE = Joiner.on('\n').join("key=%key%", "value=%value%");
+
+  private Root outputRoot;
+  private Artifact inputArtifact;
+  private Artifact outputArtifact;
+  private Path output;
+  private List<Substitution> substitutions;
+  private BlazeDirectories directories;
+  private BinTools binTools;
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+    Root workspace = Root.asSourceRoot(scratchDir("/workspace"));
+    outputRoot = Root.asDerivedRoot(scratchDir("/workspace"), scratchDir("/workspace/out"));
+    Path input = scratchFile("/workspace/input.txt", TEMPLATE);
+    inputArtifact = new Artifact(input, workspace);
+    output = scratchFS().getPath("/workspace/out/destination.txt");
+    outputArtifact = new Artifact(output, outputRoot);
+    substitutions = Lists.newArrayList();
+    substitutions.add(Substitution.of("%key%", "foo"));
+    substitutions.add(Substitution.of("%value%", "bar"));
+    directories = new BlazeDirectories(
+        scratchFS().getPath("/install"),
+        scratchFS().getPath("/base"),
+        scratchFS().getPath("/workspace"));
+    binTools = BinTools.empty(directories);
+  }
+
+  private TemplateExpansionAction create() {
+    TemplateExpansionAction result = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact, Template.forString(TEMPLATE), substitutions, false);
+    return result;
+  }
+
+  public void testInputsIsEmpty() {
+    assertTrue(Iterables.isEmpty(create().getInputs()));
+  }
+
+  public void testDestinationArtifactIsOutput() {
+    assertEquals(ImmutableSet.of(outputArtifact), create().getOutputs());
+  }
+
+  public void testExpansion() throws Exception {
+    Executor executor = new TestExecutorBuilder(directories, binTools).build();
+    create().execute(createContext(executor));
+    String content = new String(FileSystemUtils.readContentAsLatin1(output));
+    String expected = Joiner.on('\n').join("key=foo", "value=bar");
+    assertEquals(expected, content);
+  }
+
+  public void testKeySameIfSame() throws Exception {
+    Artifact outputArtifact2 = new Artifact(scratchFS().getPath("/workspace/out/destination.txt"),
+        outputRoot);
+    TemplateExpansionAction a = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    TemplateExpansionAction b = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact2, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    assertEquals(a.computeKey(), b.computeKey());
+  }
+
+  public void testKeyDiffersForSubstitution() throws Exception {
+    Artifact outputArtifact2 = new Artifact(scratchFS().getPath("/workspace/out/destination.txt"),
+        outputRoot);
+    TemplateExpansionAction a = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    TemplateExpansionAction b = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact2, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo2")), false);
+    assertFalse(a.computeKey().equals(b.computeKey()));
+  }
+
+  public void testKeyDiffersForExecutable() throws Exception {
+    Artifact outputArtifact2 = new Artifact(scratchFS().getPath("/workspace/out/destination.txt"),
+        outputRoot);
+    TemplateExpansionAction a = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    TemplateExpansionAction b = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact2, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), true);
+    assertFalse(a.computeKey().equals(b.computeKey()));
+  }
+
+  public void testKeyDiffersForTemplates() throws Exception {
+    Artifact outputArtifact2 = new Artifact(scratchFS().getPath("/workspace/out/destination.txt"),
+        outputRoot);
+    TemplateExpansionAction a = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact, Template.forString(TEMPLATE),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    TemplateExpansionAction b = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         outputArtifact2, Template.forString(TEMPLATE + " "),
+         ImmutableList.of(Substitution.of("%key%", "foo")), false);
+    assertFalse(a.computeKey().equals(b.computeKey()));
+  }
+
+  private TemplateExpansionAction createWithArtifact() {
+    TemplateExpansionAction result = new TemplateExpansionAction(NULL_ACTION_OWNER,
+         inputArtifact, outputArtifact, substitutions, false);
+    return result;
+  }
+
+  public void testArtifactTemplateHasInput() {
+    assertEquals(ImmutableList.of(inputArtifact), createWithArtifact().getInputs());
+  }
+
+  public void testArtifactTemplateHasOutput() {
+    assertEquals(ImmutableSet.of(outputArtifact), createWithArtifact().getOutputs());
+  }
+
+  public void testArtifactTemplateExpansion() throws Exception {
+    Executor executor = new TestExecutorBuilder(directories, binTools).build();
+    createWithArtifact().execute(createContext(executor));
+    String content = new String(FileSystemUtils.readContentAsLatin1(output));
+    // The trailing "" is needed because scratchFile implicitly appends "\n".
+    String expected = Joiner.on('\n').join("key=foo", "value=bar", "");
+    assertEquals(expected, content);
+  }
+
+  private ActionExecutionContext createContext(Executor executor) {
+    return new ActionExecutionContext(executor, null, null, new FileOutErr(), null);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/util/ActionTester.java b/src/test/java/com/google/devtools/build/lib/analysis/util/ActionTester.java
new file mode 100644
index 0000000..2dfa0ea
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/util/ActionTester.java
@@ -0,0 +1,76 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.Actions;
+
+/**
+ * Test helper for testing {@link Action} implementations.
+ */
+public class ActionTester {
+
+  /**
+   * A generator for action instances.
+   */
+  public interface ActionCombinationFactory {
+
+    /**
+     * Returns a new action instance. The parameter {@code i} is used to vary the parameters used to
+     * create the action. Implementations should do something like this:
+     * <code><pre>
+     * return new MyAction(owner, inputs, outputs, configuration,
+     *     (i & 1) == 0 ? a1 : a2,
+     *     (i & 2) == 0 ? b1 : b2,
+     *     (i & 4) == 0 ? c1 : c2);
+     *     (i & 16) == 0 ? d1 : d2);
+     * </pre></code>
+     *
+     * <p>The wrap-around (in this case at 32) is intentional and is checked for by the testing
+     * method.
+     *
+     * <p>To reduce the combinatorial complexity of testing an action class, all elements that are
+     * only used to change the executed command line should go into a single parameter, and the key
+     * computation should take the generated command line into account.
+     *
+     * <p>Furthermore, when called with identical parameters, this method should return different
+     * instances (i.e. according to {@code ==}), but they should have the same key.
+     */
+    Action generate(int i);
+  }
+
+  /**
+   * Tests that different actions have different keys. The count should specify how many different
+   * permutations the {@link ActionCombinationFactory} can generate.
+   */
+  public static void runTest(int count, ActionCombinationFactory factory) throws Exception {
+    Action[] actions = new Action[count];
+    for (int i = 0; i < actions.length; i++) {
+      actions[i] = factory.generate(i);
+    }
+    // Sanity check that the count is correct.
+    assertThat(Actions.canBeShared(actions[0], factory.generate(count))).isTrue();
+
+    for (int i = 0; i < actions.length; i++) {
+      assertThat(Actions.canBeShared(actions[i], factory.generate(i))).isTrue();
+      for (int j = i + 1; j < actions.length; j++) {
+        assertWithMessage(i + " and " + j).that(Actions.canBeShared(actions[i], actions[j]))
+            .isFalse();
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisMock.java b/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisMock.java
new file mode 100644
index 0000000..c411695
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisMock.java
@@ -0,0 +1,59 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.util;
+
+import com.google.devtools.build.lib.analysis.config.ConfigurationFactory;
+import com.google.devtools.build.lib.packages.util.MockToolsConfig;
+
+import java.io.IOException;
+import java.util.Collection;
+
+/**
+ * Create a mock client for the analysis phase, as well as a configuration factory.
+ */
+public abstract class AnalysisMock {
+
+  /**
+   * This is called from test setup to create the mock directory layout needed to create the
+   * configuration.
+   */
+  public abstract void setupMockClient(MockToolsConfig mockToolsConfig) throws IOException;
+
+  public abstract ConfigurationFactory createConfigurationFactory();
+
+  public abstract Collection<String> getOptionOverrides();
+
+  public static class Delegate extends AnalysisMock {
+    private final AnalysisMock delegate;
+
+    public Delegate(AnalysisMock delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public void setupMockClient(MockToolsConfig mockToolsConfig) throws IOException {
+      delegate.setupMockClient(mockToolsConfig);
+    }
+
+    @Override
+    public ConfigurationFactory createConfigurationFactory() {
+      return delegate.createConfigurationFactory();
+    }
+
+    @Override
+    public Collection<String> getOptionOverrides() {
+      return delegate.getOptionOverrides();
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisTestUtil.java b/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisTestUtil.java
new file mode 100644
index 0000000..3d75939
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/util/AnalysisTestUtil.java
@@ -0,0 +1,430 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.analysis.util;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.actions.ActionContextProvider;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionExecutionException;
+import com.google.devtools.build.lib.actions.ActionGraph;
+import com.google.devtools.build.lib.actions.ActionInputFileCache;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactFactory;
+import com.google.devtools.build.lib.actions.ArtifactOwner;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.Executor.ActionContext;
+import com.google.devtools.build.lib.actions.ExecutorInitException;
+import com.google.devtools.build.lib.actions.MiddlemanFactory;
+import com.google.devtools.build.lib.actions.MutableActionGraph;
+import com.google.devtools.build.lib.actions.MutableActionGraph.ActionConflictException;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.Root;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.analysis.AnalysisEnvironment;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
+import com.google.devtools.build.lib.analysis.BuildInfoHelper;
+import com.google.devtools.build.lib.analysis.RuleContext;
+import com.google.devtools.build.lib.analysis.WorkspaceStatusAction;
+import com.google.devtools.build.lib.analysis.WorkspaceStatusAction.Key;
+import com.google.devtools.build.lib.analysis.buildinfo.BuildInfoFactory.BuildInfoKey;
+import com.google.devtools.build.lib.analysis.config.BuildConfiguration;
+import com.google.devtools.build.lib.analysis.config.BuildConfigurationCollection;
+import com.google.devtools.build.lib.events.EventHandler;
+import com.google.devtools.build.lib.packages.Attribute.ConfigurationTransition;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.skyframe.SkyFunction;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+public final class AnalysisTestUtil {
+
+  /**
+   * An {@link AnalysisEnvironment} implementation that collects the actions registered.
+   */
+  public static class CollectingAnalysisEnvironment implements AnalysisEnvironment {
+    private final List<Action> actions = new ArrayList<>();
+    private final AnalysisEnvironment original;
+
+    public CollectingAnalysisEnvironment(AnalysisEnvironment original) {
+      this.original = original;
+    }
+
+    public void clear() {
+      actions.clear();
+    }
+
+    @Override
+    public void registerAction(Action... actions) {
+      for (Action action : actions) {
+        this.actions.add(action);
+      }
+      original.registerAction(actions);
+    }
+
+    /** Calls {@link MutableActionGraph#registerAction} for all collected actions. */
+    public void registerWith(MutableActionGraph actionGraph) {
+      for (Action action : actions) {
+        try {
+          actionGraph.registerAction(action);
+        } catch (ActionConflictException e) {
+          throw new ActionsTestUtil.UncheckedActionConflictException(e);
+        }
+      }
+    }
+
+    @Override
+    public EventHandler getEventHandler() {
+      return original.getEventHandler();
+    }
+
+    @Override
+    public boolean hasErrors() {
+      return original.hasErrors();
+    }
+
+    @Override
+    public Artifact getDerivedArtifact(PathFragment rootRelativePath, Root root) {
+      return original.getDerivedArtifact(rootRelativePath, root);
+    }
+
+    @Override
+    public Artifact getConstantMetadataArtifact(PathFragment rootRelativePath, Root root) {
+      return original.getConstantMetadataArtifact(rootRelativePath, root);
+    }
+
+    @Override
+    public Artifact getFilesetArtifact(PathFragment rootRelativePath, Root root) {
+      return original.getFilesetArtifact(rootRelativePath, root);
+    }
+
+    @Override
+    public Artifact getEmbeddedToolArtifact(String embeddedPath) {
+      return original.getEmbeddedToolArtifact(embeddedPath);
+    }
+
+    @Override
+    public MiddlemanFactory getMiddlemanFactory() {
+      return original.getMiddlemanFactory();
+    }
+
+    @Override
+    public Action getLocalGeneratingAction(Artifact artifact) {
+      return original.getLocalGeneratingAction(artifact);
+    }
+
+    @Override
+    public Iterable<Action> getRegisteredActions() {
+      return original.getRegisteredActions();
+    }
+
+    @Override
+    public SkyFunction.Environment getSkyframeEnv() {
+      return null;
+    }
+
+    @Override
+    public Artifact getStableWorkspaceStatusArtifact() {
+      return original.getStableWorkspaceStatusArtifact();
+    }
+
+    @Override
+    public Artifact getVolatileWorkspaceStatusArtifact() {
+      return original.getVolatileWorkspaceStatusArtifact();
+    }
+
+    @Override
+    public ImmutableList<Artifact> getBuildInfo(RuleContext ruleContext, BuildInfoKey key) {
+      return original.getBuildInfo(ruleContext, key);
+    }
+
+    @Override
+    public ArtifactOwner getOwner() {
+      return original.getOwner();
+    }
+
+    @Override
+    public ImmutableSet<Artifact> getOrphanArtifacts() {
+      return original.getOrphanArtifacts();
+    }
+  }
+
+  public static class DummyWorkspaceStatusAction extends WorkspaceStatusAction {
+    private final String key;
+    private final Artifact stableStatus;
+    private final Artifact volatileStatus;
+
+    public DummyWorkspaceStatusAction(String key,
+        Artifact stableStatus, Artifact volatileStatus) {
+      super(
+          BuildInfoHelper.BUILD_INFO_ACTION_OWNER,
+          ImmutableList.<Artifact>of(),
+          ImmutableList.of(stableStatus, volatileStatus));
+      this.key = key;
+      this.stableStatus = stableStatus;
+      this.volatileStatus = volatileStatus;
+    }
+
+    @Override
+    public String describeStrategy(Executor executor) {
+      return "";
+    }
+
+    @Override
+    public void execute(ActionExecutionContext actionExecutionContext)
+        throws ActionExecutionException {
+      try {
+        FileSystemUtils.writeContent(stableStatus.getPath(), new byte[] {});
+        FileSystemUtils.writeContent(volatileStatus.getPath(), new byte[] {});
+      } catch (IOException e) {
+        throw new ActionExecutionException(e, this, true);
+      }
+    }
+
+    @Override
+    public String getMnemonic() {
+      return "DummyBuildInfoAction" + key;
+    }
+
+    @Override
+    public ResourceSet estimateResourceConsumption(Executor executor) {
+      return ResourceSet.ZERO;
+    }
+
+    @Override
+    public String computeKey() {
+      return "";
+    }
+
+    @Override
+    public Artifact getVolatileStatus() {
+      return volatileStatus;
+    }
+
+    @Override
+    public Artifact getStableStatus() {
+      return stableStatus;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof DummyWorkspaceStatusAction)) {
+        return false;
+      }
+
+      DummyWorkspaceStatusAction that = (DummyWorkspaceStatusAction) o;
+      return that.key.equals(this.key);
+    }
+  }
+
+  @ExecutionStrategy(contextType = WorkspaceStatusAction.Context.class)
+  public static class DummyWorkspaceStatusActionContext implements WorkspaceStatusAction.Context {
+    @Override
+    public ImmutableMap<String, Key> getStableKeys() {
+      return ImmutableMap.of();
+    }
+
+    @Override
+    public ImmutableMap<String, Key> getVolatileKeys() {
+      return ImmutableMap.of();
+    }
+  }
+
+  public static class DummyWorkspaceActionContextProvider implements ActionContextProvider {
+    @Override
+    public Iterable<ActionContext> getActionContexts() {
+      return ImmutableList.<ActionContext>of(new DummyWorkspaceStatusActionContext());
+    }
+
+    @Override
+    public void executorCreated(Iterable<ActionContext> usedContexts) throws ExecutorInitException {
+    }
+
+    @Override
+    public void executionPhaseStarting(ActionInputFileCache actionInputFileCache,
+        ActionGraph actionGraph,
+        Iterable<Artifact> topLevelArtifacts) throws ExecutorInitException, InterruptedException {
+    }
+
+    @Override
+    public void executionPhaseEnding() {
+    }
+  }
+
+  /**
+   * A workspace status action factory that does not do any interaction with the environment.
+   */
+  public static class DummyWorkspaceStatusActionFactory implements WorkspaceStatusAction.Factory {
+    private final BlazeDirectories directories;
+    private String key;
+
+    public DummyWorkspaceStatusActionFactory(BlazeDirectories directories) {
+      this.directories = directories;
+      this.key = "";
+    }
+
+    public void setKey(String key) {
+      this.key = key;
+    }
+
+    @Override
+    public WorkspaceStatusAction createWorkspaceStatusAction(
+        ArtifactFactory artifactFactory, ArtifactOwner artifactOwner, Supplier<UUID> buildId) {
+      Artifact stableStatus = artifactFactory.getDerivedArtifact(
+          new PathFragment("build-info.txt"),
+          directories.getBuildDataDirectory(), artifactOwner);
+      Artifact volatileStatus = artifactFactory.getConstantMetadataArtifact(
+          new PathFragment("build-changelist.txt"),
+          directories.getBuildDataDirectory(), artifactOwner);
+      return new DummyWorkspaceStatusAction(key, stableStatus, volatileStatus);
+    }
+
+    @Override
+    public Map<String, String> createDummyWorkspaceStatus() {
+      return ImmutableMap.of();
+    }
+  }
+
+  public static final AnalysisEnvironment STUB_ANALYSIS_ENVIRONMENT = new AnalysisEnvironment() {
+    @Override
+    public void registerAction(Action... action) {
+    }
+
+    @Override
+    public boolean hasErrors() {
+      return false;
+    }
+
+    @Override
+    public Artifact getEmbeddedToolArtifact(String embeddedPath) {
+      return null;
+    }
+
+    @Override
+    public Artifact getConstantMetadataArtifact(PathFragment rootRelativePath, Root root) {
+      return null;
+    }
+
+    @Override
+    public EventHandler getEventHandler() {
+      return null;
+    }
+
+    @Override
+    public MiddlemanFactory getMiddlemanFactory() {
+      return null;
+    }
+
+    @Override
+    public Action getLocalGeneratingAction(Artifact artifact) {
+      return null;
+    }
+
+    @Override
+    public Iterable<Action> getRegisteredActions() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public SkyFunction.Environment getSkyframeEnv() {
+      return null;
+    }
+
+    @Override
+    public Artifact getFilesetArtifact(PathFragment rootRelativePath, Root root) {
+      return null;
+    }
+
+    @Override
+    public Artifact getDerivedArtifact(PathFragment rootRelativePath, Root root) {
+      return null;
+    }
+
+    @Override
+    public Artifact getStableWorkspaceStatusArtifact() {
+      return null;
+    }
+
+    @Override
+    public Artifact getVolatileWorkspaceStatusArtifact() {
+      return null;
+    }
+
+    @Override
+    public ImmutableList<Artifact> getBuildInfo(RuleContext ruleContext, BuildInfoKey key) {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ArtifactOwner getOwner() {
+      return ArtifactOwner.NULL_OWNER;
+    }
+
+    @Override
+    public ImmutableSet<Artifact> getOrphanArtifacts() {
+      return ImmutableSet.<Artifact>of();
+    }
+  };
+
+  /**
+   * Given a collection of Artifacts, returns a corresponding set of strings of
+   * the form "{root} {relpath}", such as "bin x/libx.a".  Such strings make
+   * assertions easier to write.
+   *
+   * <p>The returned set preserves the order of the input.
+   */
+  public static Set<String> artifactsToStrings(BuildConfigurationCollection configurations,
+      Iterable<Artifact> artifacts) {
+    Map<Root, String> rootMap = new HashMap<>();
+    BuildConfiguration targetConfiguration =
+        Iterables.getOnlyElement(configurations.getTargetConfigurations());
+    BuildConfiguration hostConfiguration =
+        targetConfiguration.getConfiguration(ConfigurationTransition.HOST);
+    rootMap.put(targetConfiguration.getBinDirectory(), "bin");
+    rootMap.put(targetConfiguration.getGenfilesDirectory(), "genfiles");
+    rootMap.put(targetConfiguration.getMiddlemanDirectory(), "internal");
+    rootMap.put(hostConfiguration.getBinDirectory(), "bin(host)");
+    rootMap.put(hostConfiguration.getGenfilesDirectory(), "genfiles(host)");
+    rootMap.put(hostConfiguration.getMiddlemanDirectory(), "internal(host)");
+
+    Set<String> files = new LinkedHashSet<>();
+    for (Artifact artifact : artifacts) {
+      Root root = artifact.getRoot();
+      if (root.isSourceRoot()) {
+        files.add("src " + artifact.getRootRelativePath());
+      } else {
+        String name = rootMap.get(root);
+        if (name == null) {
+          name = "/";
+        }
+        files.add(name + " " + artifact.getRootRelativePath());
+      }
+    }
+    return files;
+  }
+
+}
diff --git a/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java b/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java
new file mode 100644
index 0000000..1743349
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java
@@ -0,0 +1,111 @@
+// Copyright 2009 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.exec.util;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.eventbus.EventBus;
+import com.google.devtools.build.lib.actions.ActionContextProvider;
+import com.google.devtools.build.lib.actions.BlazeExecutor;
+import com.google.devtools.build.lib.actions.Executor.ActionContext;
+import com.google.devtools.build.lib.actions.ExecutorInitException;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.exec.ExecutionOptions;
+import com.google.devtools.build.lib.exec.FileWriteStrategy;
+import com.google.devtools.build.lib.exec.SourceManifestActionContextImpl;
+import com.google.devtools.build.lib.exec.SymlinkTreeStrategy;
+import com.google.devtools.build.lib.runtime.CommonCommandOptions;
+import com.google.devtools.build.lib.testutil.TestConstants;
+import com.google.devtools.build.lib.util.BlazeClock;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.common.options.OptionsBase;
+import com.google.devtools.common.options.OptionsParser;
+import com.google.devtools.common.options.OptionsParsingException;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Builder for the test instance of the {@link BlazeExecutor} class.
+ */
+public class TestExecutorBuilder {
+  public static final List<Class<? extends OptionsBase>> DEFAULT_OPTIONS = ImmutableList.of(
+      ExecutionOptions.class, CommonCommandOptions.class);
+  private final BlazeDirectories directories;
+  private Reporter reporter = new Reporter();
+  private EventBus bus = new EventBus();
+  private OptionsParser optionsParser = OptionsParser.newOptionsParser(DEFAULT_OPTIONS);
+  private List<ActionContext> strategies = new ArrayList<>();
+  private Map<String, SpawnActionContext> spawnStrategyMap = new HashMap<>();
+
+  public TestExecutorBuilder(BlazeDirectories directories, BinTools binTools) {
+    this.directories = directories;
+
+    strategies.add(new SourceManifestActionContextImpl(
+        new PathFragment(TestConstants.RUNFILES_PREFIX)));
+    strategies.add(new FileWriteStrategy());
+    strategies.add(new SymlinkTreeStrategy(null, binTools));
+  }
+
+  public TestExecutorBuilder setReporter(Reporter reporter) {
+    this.reporter = reporter;
+    return this;
+  }
+
+  public TestExecutorBuilder setBus(EventBus bus) {
+    this.bus = bus;
+    return this;
+  }
+
+  public TestExecutorBuilder setOptionsParser(OptionsParser optionsParser) {
+    this.optionsParser = optionsParser;
+    return this;
+  }
+
+  public TestExecutorBuilder parseOptions(String... options) throws OptionsParsingException {
+    this.optionsParser.parse(options);
+    return this;
+  }
+
+  public TestExecutorBuilder addStrategy(ActionContext strategy) {
+    strategies.add(strategy);
+    return this;
+  }
+
+  public TestExecutorBuilder addStrategyFactory(ActionContextProvider factory) {
+    Iterables.addAll(strategies, factory.getActionContexts());
+    return this;
+  }
+
+  public TestExecutorBuilder setExecution(String mnemonic, SpawnActionContext strategy) {
+    spawnStrategyMap.put(mnemonic, strategy);
+    strategies.add(strategy);
+    return this;
+  }
+
+  public BlazeExecutor build() throws ExecutorInitException {
+    return new BlazeExecutor(directories.getExecRoot(), directories.getOutputPath(), reporter, bus,
+        BlazeClock.instance(), optionsParser,
+        optionsParser.getOptions(ExecutionOptions.class).verboseFailures,
+        optionsParser.getOptions(ExecutionOptions.class).showSubcommands,
+        strategies,
+        ImmutableMap.copyOf(spawnStrategyMap), ImmutableList.<ActionContextProvider>of());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/packages/util/MockToolsConfig.java b/src/test/java/com/google/devtools/build/lib/packages/util/MockToolsConfig.java
new file mode 100644
index 0000000..fcf9b6f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/packages/util/MockToolsConfig.java
@@ -0,0 +1,137 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.packages.util;
+
+import com.google.common.base.Preconditions;
+import com.google.devtools.build.lib.testutil.BlazeTestUtils;
+import com.google.devtools.build.lib.testutil.TestConstants;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+
+import java.io.IOException;
+
+import javax.annotation.Nullable;
+
+/**
+ * Configuration for the mock client setup that we use for testing.
+ */
+public final class MockToolsConfig {
+
+  private final Path rootDirectory;
+  private final boolean realFileSystem;
+
+  // Allow the injection of the runfiles directory where actual tools are found.
+  // TestUtil.getRunfilesDir() caches the value of the "TEST_SRCDIR" system property, which makes
+  // it impossible to change if it doesn't get set early in test configuration setup.
+  private final Path runfilesDirectory;
+
+  public MockToolsConfig(Path rootDirectory) {
+    this(rootDirectory, false, null);
+  }
+
+  public MockToolsConfig(Path rootDirectory, boolean realFileSystem) {
+    this(rootDirectory, realFileSystem, null);
+  }
+
+  public MockToolsConfig(Path rootDirectory, boolean realFileSystem,
+      @Nullable Path runfilesDirectoryOpt) {
+    this.rootDirectory = rootDirectory;
+    this.realFileSystem = realFileSystem;
+    if (runfilesDirectoryOpt == null) {
+      this.runfilesDirectory = rootDirectory.getRelative(BlazeTestUtils.runfilesDir());
+    } else {
+      this.runfilesDirectory = runfilesDirectoryOpt;
+    }
+  }
+
+  public boolean isRealFileSystem() {
+    return realFileSystem;
+  }
+
+  public Path getPath(String relativePath) {
+    return rootDirectory.getRelative(relativePath);
+  }
+
+  public Path create(String relativePath, String... lines) throws IOException {
+    Path path = rootDirectory.getRelative(relativePath);
+    if (!path.exists()) {
+      FileSystemUtils.writeIsoLatin1(path, lines);
+    } else if (lines.length > 0) {
+      String existingContent = new String(FileSystemUtils.readContentAsLatin1(path));
+
+      StringBuilder newContent = new StringBuilder();
+      for (String line : lines) {
+        newContent.append(line);
+        newContent.append("\n");
+      }
+
+      if (!newContent.toString().equals(existingContent)) {
+        throw new IOException("Conflict: '" + relativePath + "':\n'" + newContent + "'\n vs \n'"
+            + existingContent + "'");
+      }
+    }
+    return path;
+  }
+
+  /**
+   * Links a tool into the workspace by creating a symbolic link to a real file. The target location
+   * in the workspace uses the same relative path as the given path to the tool in the runfiles
+   * tree. Use this if you do not need to rename or relocate the file, i.e., if the location in the
+   * workspace and the runfiles tree matches. Otherwise use {@link #linkTool(String, String)}.
+   *
+   * @param relativePath the relative path within the runfiles tree of the current test
+   * @throws IOException
+   */
+  public void linkTool(String relativePath) throws IOException {
+    Preconditions.checkState(realFileSystem);
+    linkTool(relativePath, relativePath);
+  }
+
+  /**
+   * Links a tool into the workspace by creating a symbolic link to a real file.
+   *
+   * @param relativePath the relative path within the runfiles tree of the current test
+   * @param dest the relative path in the mock client
+   * @throws IOException
+   */
+  public void linkTool(String relativePath, String dest) throws IOException {
+    Preconditions.checkState(realFileSystem);
+    Path target = runfilesDirectory.getRelative(TestConstants.RUNFILES_PREFIX + "/" + relativePath);
+    if (!target.exists()) {
+      // In some cases we run tests in a special client with a ../READONLY/ path where we may also
+      // find the runfiles. Try that, too.
+      Path readOnlyClientPath = rootDirectory.getRelative(
+          "../READONLY/" + TestConstants.RUNFILES_PREFIX + "/" + relativePath);
+      if (!readOnlyClientPath.exists()) {
+        throw new IOException("target does not exist " + target);
+      } else {
+        target = readOnlyClientPath;
+      }
+    }
+    Path path = rootDirectory.getRelative(dest);
+    FileSystemUtils.createDirectoryAndParents(path.getParentDirectory());
+    path.delete();
+    path.createSymbolicLink(target);
+  }
+
+  /**
+   * Convenience method to link multiple tools. Same as calling {@link #linkTool(String)} for each
+   * parameter.
+   */
+  public void linkTools(String... tools) throws IOException {
+    for (String tool : tools) {
+      linkTool(tool);
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java b/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java
index 48e948c..28ca6bf 100644
--- a/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java
+++ b/src/test/java/com/google/devtools/build/lib/testutil/TestConstants.java
@@ -42,6 +42,11 @@
   public static final String RUNFILES_PREFIX = "DOES-NOT-WORK-YET";
 
   /**
+   * Name of a class with an INSTANCE field of type AnalysisMock to be used for analysis tests.
+   */
+  public static final String TEST_ANALYSIS_MOCK = "DOES-NOT-WORK-YET";
+
+  /**
    * Directory where we can find bazel's Java tests, relative to a test's runfiles directory.
    */
   public static final String JAVATESTS_ROOT = "src/test/java/";