Merge "Add support for TF to discover test cases specified in env vars"
diff --git a/src/com/android/tradefed/config/ConfigurationFactory.java b/src/com/android/tradefed/config/ConfigurationFactory.java
index 506af37..6c9acb3 100644
--- a/src/com/android/tradefed/config/ConfigurationFactory.java
+++ b/src/com/android/tradefed/config/ConfigurationFactory.java
@@ -22,7 +22,9 @@
 import com.android.tradefed.util.ClassPathScanner;
 import com.android.tradefed.util.ClassPathScanner.IClassPathFilter;
 import com.android.tradefed.util.DirectedGraph;
+import com.android.tradefed.util.FileUtil;
 import com.android.tradefed.util.StreamUtil;
+import com.android.tradefed.util.SystemUtil;
 import com.android.tradefed.util.keystore.DryRunKeyStore;
 import com.android.tradefed.util.keystore.IKeyStoreClient;
 
@@ -40,6 +42,7 @@
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.List;
 import java.util.Map;
@@ -183,9 +186,51 @@
     }
 
     /**
-     * Implementation of {@link IConfigDefLoader} that tracks the included
-     * configurations from one root config, and throws an exception on circular
-     * includes.
+     * Get a list of {@link File} of the test cases directories
+     *
+     * <p>The wrapper function is for unit test to mock the system calls.
+     *
+     * @return a list of {@link File} of directories of the test cases folder of build output, based
+     *     on the value of environment variables.
+     */
+    @VisibleForTesting
+    List<File> getTestCasesDirs() {
+        return SystemUtil.getTestCasesDirs();
+    }
+
+    /**
+     * Get the path to the config file for a test case.
+     *
+     * <p>The given name in a test config can be the name of a test case located in an out directory
+     * defined in the following environment variables:
+     *
+     * <p>ANDROID_TARGET_OUT_TESTCASES
+     *
+     * <p>ANDROID_HOST_OUT_TESTCASES
+     *
+     * <p>This method tries to locate the test config name in these directories. If no config is
+     * found, return null.
+     *
+     * @param name Name of a config file.
+     * @return A File object of the config file for the given test case.
+     */
+    @VisibleForTesting
+    File getTestCaseConfigPath(String name) {
+        String[] possibleConfigFileNames = {name + ".xml", name + ".config", name + ".dynamic"};
+        for (File testCasesDir : getTestCasesDirs()) {
+            for (String configFileName : possibleConfigFileNames) {
+                File config = FileUtil.findFile(testCasesDir, configFileName);
+                if (config != null) {
+                    return config;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Implementation of {@link IConfigDefLoader} that tracks the included configurations from one
+     * root config, and throws an exception on circular includes.
      */
     class ConfigLoader implements IConfigDefLoader {
 
@@ -207,6 +252,15 @@
             String configName = name;
             if (!isBundledConfig(name)) {
                 configName = getAbsolutePath(null, name);
+                // If the config file does not exist in the default location, try to locate it from
+                // test cases directories defined by environment variables.
+                File configFile = new File(configName);
+                if (!configFile.exists()) {
+                    configFile = getTestCaseConfigPath(name);
+                    if (configFile != null) {
+                        configName = configFile.getAbsolutePath();
+                    }
+                }
             }
 
             final ConfigId configId = new ConfigId(name, templateMap);
@@ -281,6 +335,9 @@
                         // check that 'name' maps to a local file that exists
                         File localConfig = new File(name);
                         if (!localConfig.exists()) {
+                            localConfig = getTestCaseConfigPath(name);
+                        }
+                        if (localConfig == null) {
                             throw new ConfigurationException(String.format(
                                     "Bundled config '%s' is including a config '%s' that's neither "
                                             + "local nor bundled.",
@@ -558,11 +615,29 @@
         return cpScanner.getClassPathEntries(new ConfigClasspathFilter(subPath));
     }
 
+    /** Helper to get the test config files from test cases directories from build output. */
+    @VisibleForTesting
+    Set<String> getConfigNamesFromTestCases() {
+
+        Set<String> configNames = new HashSet<String>();
+        for (File testCasesDir : getTestCasesDirs()) {
+            try {
+                configNames.addAll(FileUtil.findFiles(testCasesDir, ".*.config"));
+                configNames.addAll(FileUtil.findFiles(testCasesDir, ".*.xml"));
+                configNames.addAll(FileUtil.findFiles(testCasesDir, ".*.dynamic"));
+            } catch (IOException e) {
+                CLog.w(
+                        "Failed to get test config files from directory %s",
+                        testCasesDir.getAbsolutePath());
+            }
+        }
+        return configNames;
+    }
+
     /**
-     * Loads all configurations found in classpath.
+     * Loads all configurations found in classpath and test cases directories.
      *
-     * @param discardExceptions true if any ConfigurationException should be
-     *            ignored.
+     * @param discardExceptions true if any ConfigurationException should be ignored.
      * @throws ConfigurationException
      */
     public void loadAllConfigs(boolean discardExceptions) throws ConfigurationException {
@@ -570,6 +645,9 @@
         PrintStream ps = new PrintStream(baos);
         boolean failed = false;
         Set<String> configNames = getConfigSetFromClasspath(null);
+        // TODO: split the the configs into two lists, one from the jar packages and one from test
+        // cases directories.
+        configNames.addAll(getConfigNamesFromTestCases());
         for (String configName : configNames) {
             final ConfigId configId = new ConfigId(configName);
             try {
diff --git a/src/com/android/tradefed/targetprep/PushFilePreparer.java b/src/com/android/tradefed/targetprep/PushFilePreparer.java
index 95c06ce..edf774c 100644
--- a/src/com/android/tradefed/targetprep/PushFilePreparer.java
+++ b/src/com/android/tradefed/targetprep/PushFilePreparer.java
@@ -23,11 +23,14 @@
 import com.android.tradefed.config.OptionClass;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.SystemUtil;
 
 import java.io.File;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
 
 /**
  * A {@link ITargetPreparer} that attempts to push any number of files from any host path to any
@@ -73,27 +76,6 @@
     private Collection<String> mFilesPushed = null;
 
     /**
-     * Set abort on failure.  Exposed for testing.
-     */
-    void setAbortOnFailure(boolean value) {
-        mAbortOnFailure = value;
-    }
-
-    /**
-     * Set pushspecs.  Exposed for testing.
-     */
-    void setPushSpecs(Collection<String> pushspecs) {
-        mPushSpecs = pushspecs;
-    }
-
-    /**
-     * Set post-push commands.  Exposed for testing.
-     */
-    void setPostPushCommands(Collection<String> commands) {
-        mPostPushCommands = commands;
-    }
-
-    /**
      * Helper method to only throw if mAbortOnFailure is enabled.  Callers should behave as if this
      * method may return.
      */
@@ -107,14 +89,36 @@
     }
 
     /**
-     * Resolve relative file path via {@link IBuildInfo}
+     * Get a list of {@link File} of the test cases directories
+     *
+     * <p>The wrapper function is for unit test to mock the system calls.
+     *
+     * @return a list of {@link File} of directories of the test cases folder of build output, based
+     *     on the value of environment variables.
+     */
+    List<File> getTestCasesDirs() {
+        return SystemUtil.getTestCasesDirs();
+    }
+
+    /**
+     * Resolve relative file path via {@link IBuildInfo} and test cases directories.
      *
      * @param buildInfo the build artifact information
      * @param fileName relative file path to be resolved
-     * @return the file from the build info
+     * @return the file from the build info or test cases directories
      */
     public File resolveRelativeFilePath(IBuildInfo buildInfo, String fileName) {
-        return buildInfo.getFile(fileName);
+        File src = buildInfo.getFile(fileName);
+        if (src != null && src.exists()) {
+            return src;
+        }
+        for (File dir : getTestCasesDirs()) {
+            src = FileUtil.getFileForPath(dir, fileName);
+            if (src != null && src.exists()) {
+                return src;
+            }
+        }
+        return null;
     }
 
     /**
diff --git a/src/com/android/tradefed/targetprep/TestAppInstallSetup.java b/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
index 1d31b21..59651fa 100644
--- a/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
+++ b/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
@@ -29,6 +29,7 @@
 import com.android.tradefed.util.AaptParser;
 import com.android.tradefed.util.AbiFormatter;
 import com.android.tradefed.util.BuildTestsZipUtils;
+import com.android.tradefed.util.SystemUtil;
 
 import java.io.File;
 import java.io.IOException;
@@ -120,11 +121,21 @@
     }
 
     /**
-     * {@inheritDoc}
+     * Get a list of {@link File} of the test cases directories
+     *
+     * <p>The wrapper function is for unit test to mock the system calls.
+     *
+     * @return a list of {@link File} of directories of the test cases folder of build output, based
+     *     on the value of environment variables.
      */
+    List<File> getTestCasesDirs() {
+        return SystemUtil.getTestCasesDirs();
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public void setUp(ITestDevice device, IBuildInfo buildInfo) throws TargetSetupError,
-            DeviceNotAvailableException {
+    public void setUp(ITestDevice device, IBuildInfo buildInfo)
+            throws TargetSetupError, DeviceNotAvailableException {
         if (mTestFileNames == null || mTestFileNames.size() == 0) {
             CLog.i("No test apps to install, skipping");
             return;
@@ -132,6 +143,12 @@
         if (mCleanup) {
             mPackagesInstalled = new ArrayList<>();
         }
+
+        // Force to look for apk files in build ouput's test cases directory.
+        for (File testCasesDir : getTestCasesDirs()) {
+            setAltDir(testCasesDir);
+        }
+
         for (String testAppName : mTestFileNames) {
             if (testAppName == null || testAppName.trim().isEmpty()) {
                 continue;
diff --git a/src/com/android/tradefed/util/BuildTestsZipUtils.java b/src/com/android/tradefed/util/BuildTestsZipUtils.java
index a344157..310a9b9 100644
--- a/src/com/android/tradefed/util/BuildTestsZipUtils.java
+++ b/src/com/android/tradefed/util/BuildTestsZipUtils.java
@@ -61,6 +61,8 @@
                 dirs.add(FileUtil.getFileForPath(dir, "DATA", "priv-app", apkBase));
                 // Files in out dir will be in data/app/apk_name
                 dirs.add(FileUtil.getFileForPath(dir, "data", "app", apkBase));
+                // Files in testcases directory will be in //apkBase
+                dirs.add(FileUtil.getFileForPath(dir, apkBase));
             }
         }
         // reverse the order so ones provided via command line last can be searched first
diff --git a/src/com/android/tradefed/util/FileUtil.java b/src/com/android/tradefed/util/FileUtil.java
index 14eb17e..34d48e6 100644
--- a/src/com/android/tradefed/util/FileUtil.java
+++ b/src/com/android/tradefed/util/FileUtil.java
@@ -32,6 +32,8 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
@@ -944,4 +946,19 @@
         }
         return result;
     }
+
+    /*
+     * Get all file paths of files in the given directory with name matching the given filter
+     *
+     * @param dir {@link File} object of the directory to search for files recursively
+     * @param filter {@link String} of the regex to match file names
+     * @return a set of {@link String} of the file paths
+     */
+    public static Set<String> findFiles(File dir, String filter) throws IOException {
+        Set<String> files = new HashSet<String>();
+        Files.walk(Paths.get(dir.getAbsolutePath()))
+                .filter(path -> new File(path.toString()).getName().matches(filter))
+                .forEach(path -> files.add(path.toString()));
+        return files;
+    }
 }
diff --git a/src/com/android/tradefed/util/SystemUtil.java b/src/com/android/tradefed/util/SystemUtil.java
new file mode 100644
index 0000000..ed50023
--- /dev/null
+++ b/src/com/android/tradefed/util/SystemUtil.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * 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.android.tradefed.util;
+
+import com.android.tradefed.log.LogUtil.CLog;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Utility class for making system calls. */
+public class SystemUtil {
+
+    @VisibleForTesting static SystemUtil singleton = new SystemUtil();
+
+    // Environment variables for the test cases directory in target out directory and host out
+    // directory.
+    @VisibleForTesting
+    static final String ENV_ANDROID_TARGET_OUT_TESTCASES = "ANDROID_TARGET_OUT_TESTCASES";
+
+    @VisibleForTesting
+    static final String ENV_ANDROID_HOST_OUT_TESTCASES = "ANDROID_HOST_OUT_TESTCASES";
+
+    /**
+     * Get the value of an environment variable.
+     *
+     * <p>The wrapper function is created for mock in unit test.
+     *
+     * @param name the name of the environment variable.
+     * @return {@link String} value of the given environment variable.
+     */
+    @VisibleForTesting
+    String getEnv(String name) {
+        return System.getenv(name);
+    }
+
+    /**
+     * Get a list of {@link File} of the test cases directories
+     *
+     * @return a list of {@link File} of directories of the test cases folder of build output, based
+     *     on the value of environment variables.
+     */
+    public static List<File> getTestCasesDirs() {
+        List<File> testCasesDirs = new ArrayList<File>();
+        Set<String> testCasesDirNames =
+                new HashSet<String>(
+                        Arrays.asList(
+                                singleton.getEnv(ENV_ANDROID_TARGET_OUT_TESTCASES),
+                                singleton.getEnv(ENV_ANDROID_HOST_OUT_TESTCASES)));
+        for (String testCasesDirName : testCasesDirNames) {
+            if (testCasesDirName != null) {
+                File dir = new File(testCasesDirName);
+                if (dir.exists() && dir.isDirectory()) {
+                    testCasesDirs.add(dir);
+                } else {
+                    CLog.w(
+                            "Path %s for test cases directory does not exist or it's not a "
+                                    + "directory.",
+                            testCasesDirName);
+                }
+            }
+        }
+        return testCasesDirs;
+    }
+}
diff --git a/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java b/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
index 77ad103..d061645 100644
--- a/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
+++ b/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
@@ -24,6 +24,8 @@
 
 import junit.framework.TestCase;
 
+import org.mockito.Mockito;
+
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
@@ -32,6 +34,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 
@@ -68,18 +71,26 @@
      * Sanity test to ensure all config names on classpath are loadable
      */
     public void testLoadAllConfigs() throws ConfigurationException {
+        ConfigurationFactory spyFactory = Mockito.spy(mRealFactory);
+        Mockito.doReturn(new HashSet<String>()).when(spyFactory).getConfigNamesFromTestCases();
+
         // we dry-run the templates otherwise it will always fail.
-        mRealFactory.loadAllConfigs(false);
-        assertTrue(mRealFactory.getMapConfig().size() > 0);
+        spyFactory.loadAllConfigs(false);
+        assertTrue(spyFactory.getMapConfig().size() > 0);
+        Mockito.verify(spyFactory, Mockito.times(1)).getConfigNamesFromTestCases();
     }
 
     /**
      * Sanity test to ensure all configs on classpath can be fully loaded and parsed
      */
     public void testLoadAndPrintAllConfigs() throws ConfigurationException {
+        ConfigurationFactory spyFactory = Mockito.spy(mRealFactory);
+        Mockito.doReturn(new HashSet<String>()).when(spyFactory).getConfigNamesFromTestCases();
+
         // Printing the help involves more checks since it tries to resolve the config objects.
-        mRealFactory.loadAndPrintAllConfigs();
-        assertTrue(mRealFactory.getMapConfig().size() > 0);
+        spyFactory.loadAndPrintAllConfigs();
+        assertTrue(spyFactory.getMapConfig().size() > 0);
+        Mockito.verify(spyFactory, Mockito.times(1)).getConfigNamesFromTestCases();
     }
 
     /**
@@ -1251,4 +1262,39 @@
         // Check that mandatory option was properly set, otherwise it will throw.
         res.validateOptions();
     }
+
+    /**
+     * This unit test ensures that the code will search for missing test configs in directories
+     * specified in certain environment variables.
+     */
+    public void testSearchConfigFromEnvVar() throws ConfigurationException, IOException {
+        File externalConfig = FileUtil.createTempFile("external-config", ".config");
+        String configName = FileUtil.getBaseName(externalConfig.getName());
+        File tmpDir = externalConfig.getParentFile();
+
+        ConfigurationFactory spyFactory = Mockito.spy(mFactory);
+        Mockito.doReturn(Arrays.asList(tmpDir)).when(spyFactory).getTestCasesDirs();
+
+        try {
+            File config = spyFactory.getTestCaseConfigPath(configName);
+            assertEquals(config.getAbsolutePath(), externalConfig.getAbsolutePath());
+        } finally {
+            FileUtil.deleteFile(externalConfig);
+        }
+    }
+
+    /**
+     * This unit test ensures that the code will search for missing test configs in directories
+     * specified in certain environment variables, and fail as the test config still can't be found.
+     */
+    public void testSearchConfigFromEnvVarFailed() throws ConfigurationException, IOException {
+        File tmpDir = new File(System.getProperty("java.io.tmpdir"));
+
+        ConfigurationFactory spyFactory = Mockito.spy(mFactory);
+        Mockito.doReturn(Arrays.asList(tmpDir)).when(spyFactory).getTestCasesDirs();
+
+        File config = spyFactory.getTestCaseConfigPath("non-exist");
+        assertNull(config);
+        Mockito.verify(spyFactory, Mockito.times(1)).getTestCasesDirs();
+    }
 }
diff --git a/tests/src/com/android/tradefed/targetprep/PushFilePreparerTest.java b/tests/src/com/android/tradefed/targetprep/PushFilePreparerTest.java
index 74693fd..36f7b90 100644
--- a/tests/src/com/android/tradefed/targetprep/PushFilePreparerTest.java
+++ b/tests/src/com/android/tradefed/targetprep/PushFilePreparerTest.java
@@ -16,11 +16,13 @@
 
 package com.android.tradefed.targetprep;
 
+import com.android.tradefed.config.OptionSetter;
 import com.android.tradefed.device.ITestDevice;
 
 import junit.framework.TestCase;
 
 import org.easymock.EasyMock;
+import org.mockito.Mockito;
 
 import java.io.File;
 import java.util.Arrays;
@@ -32,6 +34,7 @@
 
     private PushFilePreparer mPreparer = null;
     private ITestDevice mMockDevice = null;
+    private OptionSetter mOptionSetter = null;
 
     /**
      * {@inheritDoc}
@@ -43,6 +46,7 @@
         EasyMock.expect(mMockDevice.getDeviceDescriptor()).andStubReturn(null);
         EasyMock.expect(mMockDevice.getSerialNumber()).andStubReturn("SERIAL");
         mPreparer = new PushFilePreparer();
+        mOptionSetter = new OptionSetter(mPreparer);
     }
 
     /**
@@ -54,8 +58,8 @@
     }
 
     public void testLocalNoExist() throws Exception {
-        mPreparer.setPushSpecs(Arrays.asList("/noexist->/data/"));
-        mPreparer.setPostPushCommands(Arrays.asList("ls /"));
+        mOptionSetter.setOptionValue("push", "/noexist->/data/");
+        mOptionSetter.setOptionValue("post-push", "ls /");
         EasyMock.replay(mMockDevice);
         try {
             // Should throw TargetSetupError and _not_ run any post-push command
@@ -67,8 +71,8 @@
     }
 
     public void testRemoteNoExist() throws Exception {
-        mPreparer.setPushSpecs(Arrays.asList("/bin/sh->/noexist/"));
-        mPreparer.setPostPushCommands(Arrays.asList("ls /"));
+        mOptionSetter.setOptionValue("push", "/bin/sh->/noexist/");
+        mOptionSetter.setOptionValue("post-push", "ls /");
         // expect a pushFile() call and return false (failed)
         EasyMock.expect(
                 mMockDevice.pushFile((File)EasyMock.anyObject(), EasyMock.eq("/noexist/")))
@@ -84,9 +88,10 @@
     }
 
     public void testWarnOnFailure() throws Exception {
-        mPreparer.setPushSpecs(Arrays.asList("/noexist->/data/", "/bin/sh->/noexist/"));
-        mPreparer.setPostPushCommands(Arrays.asList("ls /"));
-        mPreparer.setAbortOnFailure(false);
+        mOptionSetter.setOptionValue("push", "/bin/sh->/noexist/");
+        mOptionSetter.setOptionValue("push", "/noexist->/data/");
+        mOptionSetter.setOptionValue("post-push", "ls /");
+        mOptionSetter.setOptionValue("abort-on-push-failure", "false");
 
         // expect a pushFile() call and return false (failed)
         EasyMock.expect(
@@ -99,5 +104,18 @@
         // Don't expect any exceptions to be thrown
         mPreparer.setUp(mMockDevice, null);
     }
+
+    public void testPushFromTestCasesDir() throws Exception {
+        mOptionSetter.setOptionValue("push", "sh->/noexist/");
+        mOptionSetter.setOptionValue("post-push", "ls /");
+
+        PushFilePreparer spyPreparer = Mockito.spy(mPreparer);
+        Mockito.doReturn(Arrays.asList(new File("/bin"))).when(spyPreparer).getTestCasesDirs();
+
+        // expect a pushFile() call as /bin/sh should exist and return false (failed)
+        EasyMock.expect(mMockDevice.pushFile((File) EasyMock.anyObject(), EasyMock.eq("/noexist/")))
+                .andReturn(Boolean.FALSE);
+        EasyMock.replay(mMockDevice);
+    }
 }
 
diff --git a/tests/src/com/android/tradefed/util/FileUtilTest.java b/tests/src/com/android/tradefed/util/FileUtilTest.java
index f4de549..1b325a2 100644
--- a/tests/src/com/android/tradefed/util/FileUtilTest.java
+++ b/tests/src/com/android/tradefed/util/FileUtilTest.java
@@ -327,4 +327,37 @@
                 perms.remove(PosixFilePermission.OTHERS_EXECUTE) &&
                 perms.isEmpty());
     }
+
+    /** Test {@link FileUtil#findFiles()} can find files successfully. */
+    public void testFindFilesSuccess() throws IOException {
+        File tmpDir = FileUtil.createTempDir("find_files_test");
+        try {
+            File matchFile1 = FileUtil.createTempFile("test", ".config", tmpDir);
+            File subDir = new File(tmpDir, "subfolder");
+            subDir.mkdirs();
+            File matchFile2 = FileUtil.createTempFile("test", ".config", subDir);
+            File unmatchFile = FileUtil.createTempFile("test", ".xml", subDir);
+            Set<String> matchFiles = FileUtil.findFiles(tmpDir, ".*.config");
+            assertTrue(matchFiles.contains(matchFile1.getAbsolutePath()));
+            assertTrue(matchFiles.contains(matchFile2.getAbsolutePath()));
+            assertEquals(matchFiles.size(), 2);
+        } finally {
+            FileUtil.recursiveDelete(tmpDir);
+        }
+    }
+
+    /** Test {@link FileUtil#findFiles()} returns empty set if no file matches filter. */
+    public void testFindFilesFail() throws IOException {
+        File tmpDir = FileUtil.createTempDir("find_files_test");
+        try {
+            File file1 = FileUtil.createTempFile("test", ".config", tmpDir);
+            File subDir = new File(tmpDir, "subfolder");
+            subDir.mkdirs();
+            File file2 = FileUtil.createTempFile("test", ".config", subDir);
+            Set<String> matchFiles = FileUtil.findFiles(tmpDir, ".*.config_x");
+            assertEquals(matchFiles.size(), 0);
+        } finally {
+            FileUtil.recursiveDelete(tmpDir);
+        }
+    }
 }
diff --git a/tests/src/com/android/tradefed/util/SystemUtilTest.java b/tests/src/com/android/tradefed/util/SystemUtilTest.java
new file mode 100644
index 0000000..fe9cd98
--- /dev/null
+++ b/tests/src/com/android/tradefed/util/SystemUtilTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * 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.android.tradefed.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.Test;
+
+/** Unit tests for {@link SystemUtil} */
+@RunWith(JUnit4.class)
+public class SystemUtilTest {
+
+    /**
+     * test {@link SystemUtil#getTestCasesDirs()} to make sure it gets directories from environment
+     * variables.
+     */
+    @Test
+    public void testGetTestCasesDirs() throws IOException {
+        File targetOutDir = null;
+        File hostOutDir = null;
+        try {
+            targetOutDir = FileUtil.createTempDir("target_out_dir");
+            hostOutDir = FileUtil.createTempDir("host_out_dir");
+
+            SystemUtil.singleton = Mockito.mock(SystemUtil.class);
+            Mockito.when(SystemUtil.singleton.getEnv(SystemUtil.ENV_ANDROID_TARGET_OUT_TESTCASES))
+                    .thenReturn(targetOutDir.getAbsolutePath());
+            Mockito.when(SystemUtil.singleton.getEnv(SystemUtil.ENV_ANDROID_HOST_OUT_TESTCASES))
+                    .thenReturn(hostOutDir.getAbsolutePath());
+
+            Set<File> testCasesDirs = new HashSet<File>(SystemUtil.getTestCasesDirs());
+            assertTrue(testCasesDirs.contains(targetOutDir));
+            assertTrue(testCasesDirs.contains(hostOutDir));
+        } finally {
+            FileUtil.recursiveDelete(targetOutDir);
+            FileUtil.recursiveDelete(hostOutDir);
+        }
+    }
+
+    /**
+     * test {@link SystemUtil#getTestCasesDirs()} to make sure no exception thrown if no environment
+     * variable is set or the directory does not exist.
+     */
+    @Test
+    public void testGetTestCasesDirsNoDir() throws IOException {
+        File targetOutDir = new File("/path/not/exist_1");
+
+        SystemUtil.singleton = Mockito.mock(SystemUtil.class);
+        Mockito.when(SystemUtil.singleton.getEnv(SystemUtil.ENV_ANDROID_TARGET_OUT_TESTCASES))
+                .thenReturn(targetOutDir.getAbsolutePath());
+        Mockito.when(SystemUtil.singleton.getEnv(SystemUtil.ENV_ANDROID_HOST_OUT_TESTCASES))
+                .thenReturn(null);
+
+        Set<File> testCasesDirs = new HashSet<File>(SystemUtil.getTestCasesDirs());
+        assertEquals(testCasesDirs.size(), 0);
+    }
+}