Merge "Move regression classes to contrib"
diff --git a/res/config/regression.xml b/res/config/regression.xml
new file mode 100644
index 0000000..6b27344
--- /dev/null
+++ b/res/config/regression.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2018 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.
+-->
+<configuration description="Runs a regression detection algorithm on two sets of metrics">
+
+    <option name="log-level" value="verbose" />
+    <option name="compress-files" value="false" />
+    <test class="com.android.regression.tests.DetectRegression" />
+    <logger class="com.android.tradefed.log.FileLogger" />
+    <result_reporter class="com.android.tradefed.result.ConsoleResultReporter" />
+
+</configuration>
diff --git a/src/com/android/regression/tests/DetectRegression.java b/src/com/android/regression/tests/DetectRegression.java
new file mode 100644
index 0000000..fee6a73
--- /dev/null
+++ b/src/com/android/regression/tests/DetectRegression.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import com.android.ddmlib.Log;
+import com.android.regression.tests.MetricsXmlParser.ParseException;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.testtype.IRemoteTest;
+import com.android.tradefed.testtype.suite.ModuleDefinition;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.MultiMap;
+import com.android.tradefed.util.Pair;
+import com.android.tradefed.util.TableBuilder;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Doubles;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** An algorithm to detect local metrics regression. */
+@OptionClass(alias = "regression")
+public class DetectRegression implements IRemoteTest {
+
+    @Option(
+        name = "pre-patch-metrics",
+        description = "Path to pre-patch metrics folder.",
+        mandatory = true
+    )
+    private File mPrePatchFolder;
+
+    @Option(
+        name = "post-patch-metrics",
+        description = "Path to post-patch metrics folder.",
+        mandatory = true
+    )
+    private File mPostPatchFolder;
+
+    @Option(
+        name = "strict-mode",
+        description = "When before/after metrics mismatch, true=throw exception, false=log error"
+    )
+    private boolean mStrict = false;
+
+    @Option(name = "blacklist-metrics", description = "Ignore metrics that match these names")
+    private Set<String> mBlacklistMetrics = new HashSet<>();
+
+    private static final String TITLE = "Metric Regressions";
+    private static final String PROLOG =
+            "\n====================Metrics Comparison Results====================\nTest Summary\n";
+    private static final String EPILOG =
+            "==================End Metrics Comparison Results==================\n";
+    private static final String[] TABLE_HEADER = {
+        "Metric Name", "Pre Avg", "Post Avg", "False Positive Probability"
+    };
+    /** Matches metrics xml filenames. */
+    private static final String METRICS_PATTERN = "metrics-.*\\.xml";
+
+    private static final int SAMPLES = 100000;
+    private static final double STD_DEV_THRESHOLD = 2.0;
+
+    private static final Set<String> DEFAULT_IGNORE =
+            ImmutableSet.of(
+                    ModuleDefinition.PREPARATION_TIME,
+                    ModuleDefinition.TEST_TIME,
+                    ModuleDefinition.TEAR_DOWN_TIME);
+
+    @VisibleForTesting
+    public static class TableRow {
+        String name;
+        double preAvg;
+        double postAvg;
+        double probability;
+
+        public String[] toStringArray() {
+            return new String[] {
+                name,
+                String.format("%.2f", preAvg),
+                String.format("%.2f", postAvg),
+                String.format("%.3f", probability)
+            };
+        }
+    }
+
+    public DetectRegression() {
+        mBlacklistMetrics.addAll(DEFAULT_IGNORE);
+    }
+
+    @Override
+    public void run(ITestInvocationListener listener) {
+        try {
+            // Load metrics from files, and validate them.
+            Metrics before =
+                    MetricsXmlParser.parse(
+                            mBlacklistMetrics, mStrict, getMetricsFiles(mPrePatchFolder));
+            Metrics after =
+                    MetricsXmlParser.parse(
+                            mBlacklistMetrics, mStrict, getMetricsFiles(mPostPatchFolder));
+            before.crossValidate(after);
+            runRegressionDetection(before, after);
+        } catch (IOException | ParseException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Computes metrics regression between pre-patch and post-patch.
+     *
+     * @param before pre-patch metrics
+     * @param after post-patch metrics
+     */
+    @VisibleForTesting
+    void runRegressionDetection(Metrics before, Metrics after) {
+        Set<String> runMetricsToCompare =
+                Sets.intersection(before.getRunMetrics().keySet(), after.getRunMetrics().keySet());
+        List<TableRow> runMetricsResult = new ArrayList<>();
+        for (String name : runMetricsToCompare) {
+            List<Double> beforeMetrics = before.getRunMetrics().get(name);
+            List<Double> afterMetrics = after.getRunMetrics().get(name);
+            if (computeRegression(beforeMetrics, afterMetrics)) {
+                runMetricsResult.add(getTableRow(name, beforeMetrics, afterMetrics));
+            }
+        }
+
+        Set<Pair<TestDescription, String>> testMetricsToCompare =
+                Sets.intersection(
+                        before.getTestMetrics().keySet(), after.getTestMetrics().keySet());
+        MultiMap<String, TableRow> testMetricsResult = new MultiMap<>();
+        for (Pair<TestDescription, String> id : testMetricsToCompare) {
+            List<Double> beforeMetrics = before.getTestMetrics().get(id);
+            List<Double> afterMetrics = after.getTestMetrics().get(id);
+            if (computeRegression(beforeMetrics, afterMetrics)) {
+                testMetricsResult.put(
+                        id.first.toString(), getTableRow(id.second, beforeMetrics, afterMetrics));
+            }
+        }
+        logResult(before, after, runMetricsResult, testMetricsResult);
+    }
+
+    /** Prints results to the console. */
+    @VisibleForTesting
+    void logResult(
+            Metrics before,
+            Metrics after,
+            List<TableRow> runMetricsResult,
+            MultiMap<String, TableRow> testMetricsResult) {
+        TableBuilder table = new TableBuilder(TABLE_HEADER.length);
+        table.addTitle(TITLE).addLine(TABLE_HEADER).addDoubleLineSeparator();
+
+        int totalRunMetrics =
+                Sets.intersection(before.getRunMetrics().keySet(), after.getRunMetrics().keySet())
+                        .size();
+        String runResult =
+                String.format(
+                        "Run Metrics (%d compared, %d changed)",
+                        totalRunMetrics, runMetricsResult.size());
+        table.addLine(runResult).addSingleLineSeparator();
+        runMetricsResult.stream().map(TableRow::toStringArray).forEach(table::addLine);
+        if (!runMetricsResult.isEmpty()) {
+            table.addSingleLineSeparator();
+        }
+
+        int totalTestMetrics =
+                Sets.intersection(before.getTestMetrics().keySet(), after.getTestMetrics().keySet())
+                        .size();
+        int changedTestMetrics =
+                testMetricsResult
+                        .keySet()
+                        .stream()
+                        .mapToInt(k -> testMetricsResult.get(k).size())
+                        .sum();
+        String testResult =
+                String.format(
+                        "Test Metrics (%d compared, %d changed)",
+                        totalTestMetrics, changedTestMetrics);
+        table.addLine(testResult).addSingleLineSeparator();
+        for (String test : testMetricsResult.keySet()) {
+            table.addLine("> " + test);
+            testMetricsResult
+                    .get(test)
+                    .stream()
+                    .map(TableRow::toStringArray)
+                    .forEach(table::addLine);
+            table.addBlankLineSeparator();
+        }
+        table.addDoubleLineSeparator();
+
+        StringBuilder sb = new StringBuilder(PROLOG);
+        sb.append(
+                String.format(
+                        "%d tests. %d sets of pre-patch metrics. %d sets of post-patch metrics.\n\n",
+                        before.getNumTests(), before.getNumRuns(), after.getNumRuns()));
+        sb.append(table.build()).append('\n').append(EPILOG);
+
+        CLog.logAndDisplay(Log.LogLevel.INFO, sb.toString());
+    }
+
+    private List<File> getMetricsFiles(File folder) throws IOException {
+        CLog.i("Loading metrics from: %s", mPrePatchFolder.getAbsolutePath());
+        return FileUtil.findFiles(folder, METRICS_PATTERN)
+                .stream()
+                .map(File::new)
+                .collect(Collectors.toList());
+    }
+
+    private static TableRow getTableRow(String name, List<Double> before, List<Double> after) {
+        TableRow row = new TableRow();
+        row.name = name;
+        row.preAvg = calcMean(before);
+        row.postAvg = calcMean(after);
+        row.probability = probFalsePositive(before.size(), after.size());
+        return row;
+    }
+
+    /** @return true if there is regression from before to after, false otherwise */
+    @VisibleForTesting
+    static boolean computeRegression(List<Double> before, List<Double> after) {
+        final double mean = calcMean(before);
+        final double stdDev = calcStdDev(before);
+        int regCount = 0;
+        for (double value : after) {
+            if (Math.abs(value - mean) > stdDev * STD_DEV_THRESHOLD) {
+                regCount++;
+            }
+        }
+        return regCount > after.size() / 2;
+    }
+
+    @VisibleForTesting
+    static double calcMean(List<Double> list) {
+        return list.stream().collect(Collectors.averagingDouble(x -> x));
+    }
+
+    @VisibleForTesting
+    static double calcStdDev(List<Double> list) {
+        final double mean = calcMean(list);
+        return Math.sqrt(
+                list.stream().collect(Collectors.averagingDouble(x -> Math.pow(x - mean, 2))));
+    }
+
+    private static double probFalsePositive(int priorRuns, int postRuns) {
+        int failures = 0;
+        Random rand = new Random();
+        for (int run = 0; run < SAMPLES; run++) {
+            double[] prior = new double[priorRuns];
+            for (int x = 0; x < priorRuns; x++) {
+                prior[x] = rand.nextGaussian();
+            }
+            double estMu = calcMean(Doubles.asList(prior));
+            double estStd = calcStdDev(Doubles.asList(prior));
+            int count = 0;
+            for (int y = 0; y < postRuns; y++) {
+                if (Math.abs(rand.nextGaussian() - estMu) > estStd * STD_DEV_THRESHOLD) {
+                    count++;
+                }
+            }
+            failures += count > postRuns / 2 ? 1 : 0;
+        }
+        return (double) failures / SAMPLES;
+    }
+}
diff --git a/src/com/android/regression/tests/Metrics.java b/src/com/android/regression/tests/Metrics.java
new file mode 100644
index 0000000..ce78b52
--- /dev/null
+++ b/src/com/android/regression/tests/Metrics.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.util.MultiMap;
+import com.android.tradefed.util.Pair;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/** A metrics object to hold run metrics and test metrics parsed by {@link MetricsXmlParser} */
+public class Metrics {
+    private int mNumRuns;
+    private int mNumTests = -1;
+    private final boolean mStrictMode;
+    private final MultiMap<String, Double> mRunMetrics = new MultiMap<>();
+    private final MultiMap<Pair<TestDescription, String>, Double> mTestMetrics = new MultiMap<>();
+
+    /** Throw when metrics validation fails in strict mode. */
+    public static class MetricsException extends RuntimeException {
+        MetricsException(String cause) {
+            super(cause);
+        }
+    }
+
+    /**
+     * Constructs an empty Metrics object.
+     *
+     * @param strictMode whether exception should be thrown when validation fails
+     */
+    public Metrics(boolean strictMode) {
+        mStrictMode = strictMode;
+    }
+
+    /**
+     * Sets the number of tests. This method also checks if each call sets the same number of test,
+     * since this number should be consistent across multiple runs.
+     *
+     * @param numTests the number of tests
+     * @throws MetricsException if subsequent calls set a different number.
+     */
+    public void setNumTests(int numTests) {
+        if (mNumTests == -1) {
+            mNumTests = numTests;
+        } else {
+            if (mNumTests != numTests) {
+                String msg =
+                        String.format(
+                                "Number of test entries differ: expect #%d actual #%d",
+                                mNumTests, numTests);
+                throw new MetricsException(msg);
+            }
+        }
+    }
+
+    /**
+     * Adds a run metric.
+     *
+     * @param name metric name
+     * @param value metric value
+     */
+    public void addRunMetric(String name, String value) {
+        try {
+            mRunMetrics.put(name, Double.parseDouble(value));
+        } catch (NumberFormatException e) {
+            // This is normal. We often get some string metrics like device name. Just log it.
+            CLog.w(String.format("Run metric \"%s\" is not a number: \"%s\"", name, value));
+        }
+    }
+
+    /**
+     * Adds a test metric.
+     *
+     * @param testId TestDescription of the metric
+     * @param name metric name
+     * @param value metric value
+     */
+    public void addTestMetric(TestDescription testId, String name, String value) {
+        Pair<TestDescription, String> metricId = new Pair<>(testId, name);
+        try {
+            mTestMetrics.put(metricId, Double.parseDouble(value));
+        } catch (NumberFormatException e) {
+            // This is normal. We often get some string metrics like device name. Just log it.
+            CLog.w(
+                    String.format(
+                            "Test %s metric \"%s\" is not a number: \"%s\"", testId, name, value));
+        }
+    }
+
+    /**
+     * Validates that the number of entries of each metric equals to the number of runs.
+     *
+     * @param numRuns number of runs
+     * @throws MetricsException when validation fails in strict mode
+     */
+    public void validate(int numRuns) {
+        mNumRuns = numRuns;
+        for (String name : mRunMetrics.keySet()) {
+            if (mRunMetrics.get(name).size() < mNumRuns) {
+                error(
+                        String.format(
+                                "Run metric \"%s\" too few entries: expected #%d actual #%d",
+                                name, mNumRuns, mRunMetrics.get(name).size()));
+            }
+        }
+        for (Pair<TestDescription, String> id : mTestMetrics.keySet()) {
+            if (mTestMetrics.get(id).size() < mNumRuns) {
+                error(
+                        String.format(
+                                "Test %s metric \"%s\" too few entries: expected #%d actual #%d",
+                                id.first, id.second, mNumRuns, mTestMetrics.get(id).size()));
+            }
+        }
+    }
+
+    /**
+     * Validates with after-patch Metrics object. Make sure two metrics object contain same run
+     * metric entries and test metric entries. Assume this object contains before-patch metrics.
+     *
+     * @param after a Metrics object containing after-patch metrics
+     * @throws MetricsException when cross validation fails in strict mode
+     */
+    public void crossValidate(Metrics after) {
+        if (mNumTests != after.mNumTests) {
+            error(
+                    String.format(
+                            "Number of test entries differ: before #%d after #%d",
+                            mNumTests, after.mNumTests));
+        }
+
+        for (String name : mRunMetrics.keySet()) {
+            if (!after.mRunMetrics.containsKey(name)) {
+                warn(String.format("Run metric \"%s\" only in before-patch run.", name));
+            }
+        }
+
+        for (String name : after.mRunMetrics.keySet()) {
+            if (!mRunMetrics.containsKey(name)) {
+                warn(String.format("Run metric \"%s\" only in after-patch run.", name));
+            }
+        }
+
+        for (Pair<TestDescription, String> id : mTestMetrics.keySet()) {
+            if (!after.mTestMetrics.containsKey(id)) {
+                warn(
+                        String.format(
+                                "Test %s metric \"%s\" only in before-patch run.",
+                                id.first, id.second));
+            }
+        }
+
+        for (Pair<TestDescription, String> id : after.mTestMetrics.keySet()) {
+            if (!mTestMetrics.containsKey(id)) {
+                warn(
+                        String.format(
+                                "Test %s metric \"%s\" only in after-patch run.",
+                                id.first, id.second));
+            }
+        }
+    }
+
+    @VisibleForTesting
+    void error(String msg) {
+        if (mStrictMode) {
+            throw new MetricsException(msg);
+        } else {
+            CLog.e(msg);
+        }
+    }
+
+    @VisibleForTesting
+    void warn(String msg) {
+        if (mStrictMode) {
+            throw new MetricsException(msg);
+        } else {
+            CLog.w(msg);
+        }
+    }
+
+    /**
+     * Gets the number of test runs stored in this object.
+     *
+     * @return number of test runs
+     */
+    public int getNumRuns() {
+        return mNumRuns;
+    }
+
+    /**
+     * Gets the number of tests stored in this object.
+     *
+     * @return number of tests
+     */
+    public int getNumTests() {
+        return mNumTests;
+    }
+
+    /**
+     * Gets all run metrics stored in this object.
+     *
+     * @return a {@link MultiMap} from test name String to Double
+     */
+    public MultiMap<String, Double> getRunMetrics() {
+        return mRunMetrics;
+    }
+
+    /**
+     * Gets all test metrics stored in this object.
+     *
+     * @return a {@link MultiMap} from (TestDescription, test name) pair to Double
+     */
+    public MultiMap<Pair<TestDescription, String>, Double> getTestMetrics() {
+        return mTestMetrics;
+    }
+}
diff --git a/src/com/android/regression/tests/MetricsXmlParser.java b/src/com/android/regression/tests/MetricsXmlParser.java
new file mode 100644
index 0000000..b78a982
--- /dev/null
+++ b/src/com/android/regression/tests/MetricsXmlParser.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import com.android.tradefed.result.MetricsXMLResultReporter;
+import com.android.tradefed.result.TestDescription;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Set;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+/** Parser that extracts test metrics result data generated by {@link MetricsXMLResultReporter}. */
+public class MetricsXmlParser {
+
+    /** Thrown when MetricsXmlParser fails to parse a metrics xml file. */
+    public static class ParseException extends Exception {
+        public ParseException(Throwable cause) {
+            super(cause);
+        }
+
+        public ParseException(String msg, Throwable cause) {
+            super(msg, cause);
+        }
+    }
+
+    /*
+     * Parses the xml format. Expected tags/attributes are:
+     * testsuite name="runname" tests="X"
+     *   runmetric name="metric1" value="1.0"
+     *   testcase classname="FooTest" testname="testMethodName"
+     *     testmetric name="metric2" value="1.0"
+     */
+    private static class MetricsXmlHandler extends DefaultHandler {
+
+        private static final String TESTSUITE_TAG = "testsuite";
+        private static final String TESTCASE_TAG = "testcase";
+        private static final String TIME_TAG = "time";
+        private static final String RUNMETRIC_TAG = "runmetric";
+        private static final String TESTMETRIC_TAG = "testmetric";
+
+        private TestDescription mCurrentTest = null;
+
+        private Metrics mMetrics;
+        private Set<String> mBlacklistMetrics;
+
+        public MetricsXmlHandler(Metrics metrics, Set<String> blacklistMetrics) {
+            mMetrics = metrics;
+            mBlacklistMetrics = blacklistMetrics;
+        }
+
+        @Override
+        public void startElement(String uri, String localName, String name, Attributes attributes)
+                throws SAXException {
+            if (TESTSUITE_TAG.equalsIgnoreCase(name)) {
+                // top level tag - maps to a test run in TF terminology
+                String testCount = getMandatoryAttribute(name, "tests", attributes);
+                mMetrics.setNumTests(Integer.parseInt(testCount));
+                mMetrics.addRunMetric(TIME_TAG, getMandatoryAttribute(name, TIME_TAG, attributes));
+            }
+            if (TESTCASE_TAG.equalsIgnoreCase(name)) {
+                // start of description of an individual test method
+                String testClassName = getMandatoryAttribute(name, "classname", attributes);
+                String methodName = getMandatoryAttribute(name, "testname", attributes);
+                mCurrentTest = new TestDescription(testClassName, methodName);
+            }
+            if (RUNMETRIC_TAG.equalsIgnoreCase(name)) {
+                String metricName = getMandatoryAttribute(name, "name", attributes);
+                String metricValue = getMandatoryAttribute(name, "value", attributes);
+                if (!mBlacklistMetrics.contains(metricName)) {
+                    mMetrics.addRunMetric(metricName, metricValue);
+                }
+            }
+            if (TESTMETRIC_TAG.equalsIgnoreCase(name)) {
+                String metricName = getMandatoryAttribute(name, "name", attributes);
+                String metricValue = getMandatoryAttribute(name, "value", attributes);
+                if (!mBlacklistMetrics.contains(metricName)) {
+                    mMetrics.addTestMetric(mCurrentTest, metricName, metricValue);
+                }
+            }
+        }
+
+        private String getMandatoryAttribute(String tagName, String attrName, Attributes attributes)
+                throws SAXException {
+            String value = attributes.getValue(attrName);
+            if (value == null) {
+                throw new SAXException(
+                        String.format(
+                                "Malformed XML, could not find '%s' attribute in '%s'",
+                                attrName, tagName));
+            }
+            return value;
+        }
+    }
+
+    /**
+     * Parses xml data contained in given input files.
+     *
+     * @param blacklistMetrics ignore the metrics with these names
+     * @param strictMode whether to throw an exception when metric validation fails
+     * @param metricXmlFiles a list of metric xml files
+     * @return a Metric object containing metrics from all metric files
+     * @throws ParseException if input could not be parsed
+     */
+    public static Metrics parse(
+            Set<String> blacklistMetrics, boolean strictMode, List<File> metricXmlFiles)
+            throws ParseException {
+        Metrics metrics = new Metrics(strictMode);
+        for (File xml : metricXmlFiles) {
+            try (InputStream is = new BufferedInputStream(new FileInputStream(xml))) {
+                parse(metrics, blacklistMetrics, is);
+            } catch (Exception e) {
+                throw new ParseException("Unable to parse " + xml.getPath(), e);
+            }
+        }
+        metrics.validate(metricXmlFiles.size());
+        return metrics;
+    }
+
+    @VisibleForTesting
+    public static Metrics parse(Metrics metrics, Set<String> blacklistMetrics, InputStream is)
+            throws ParseException {
+        try {
+            SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+            parserFactory.setNamespaceAware(true);
+            SAXParser parser = parserFactory.newSAXParser();
+            parser.parse(is, new MetricsXmlHandler(metrics, blacklistMetrics));
+            return metrics;
+        } catch (ParserConfigurationException | SAXException | IOException e) {
+            throw new ParseException(e);
+        }
+    }
+}
diff --git a/tests/.classpath b/tests/.classpath
index 328230f..3db2361 100644
--- a/tests/.classpath
+++ b/tests/.classpath
@@ -1,11 +1,15 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <classpath>
 	<classpathentry kind="src" path="src"/>
+	<classpathentry kind="src" path="objenesis"/>
+	<classpathentry kind="src" path="mockito"/>
 	<classpathentry kind="src" path="easymock"/>
 	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
 	<classpathentry combineaccessrules="false" kind="src" path="/tradefederation"/>
 	<classpathentry combineaccessrules="false" kind="src" path="/ddmlib"/>
 	<classpathentry kind="var" path="TRADEFED_ROOT/out/host/common/obj/JAVA_LIBRARIES/host-libprotobuf-java-full_intermediates/classes.jar"/>
 	<classpathentry combineaccessrules="false" kind="src" path="/tradefederation-contrib"/>
+	<classpathentry kind="var" path="TRADEFED_ROOT/out/soong/.intermediates/external/mockito/mockito-byte-buddy-agent/linux_glibc_common/combined/mockito-byte-buddy-agent.jar"/>
+	<classpathentry kind="var" path="TRADEFED_ROOT/out/soong/.intermediates/external/mockito/mockito-byte-buddy/linux_glibc_common/combined/mockito-byte-buddy.jar"/>
 	<classpathentry kind="output" path="bin"/>
 </classpath>
diff --git a/tests/.project b/tests/.project
index 1d1f462..93dc9ae 100644
--- a/tests/.project
+++ b/tests/.project
@@ -18,7 +18,17 @@
 		<link>
 			<name>easymock</name>
 			<type>2</type>
-			<location>TRADEFED_ROOT/external/easymock/src</location>
+			<locationURI>TRADEFED_ROOT/external/easymock/src</locationURI>
+		</link>
+		<link>
+			<name>mockito</name>
+			<type>2</type>
+			<locationURI>TRADEFED_ROOT/external/mockito/src/main/java</locationURI>
+		</link>
+		<link>
+			<name>objenesis</name>
+			<type>2</type>
+			<locationURI>TRADEFED_ROOT/external/objenesis/main/src/main/java</locationURI>
 		</link>
 	</linkedResources>
 </projectDescription>
diff --git a/tests/build/Android.mk b/tests/build/Android.mk
index b1b51c5..89f2bab 100644
--- a/tests/build/Android.mk
+++ b/tests/build/Android.mk
@@ -25,7 +25,7 @@
 
 LOCAL_MODULE := tf-contrib-tests
 LOCAL_MODULE_TAGS := optional
-LOCAL_JAVA_LIBRARIES := tradefed tradefed-contrib easymock
+LOCAL_JAVA_LIBRARIES := tradefed tradefed-contrib easymock mockito objenesis
 
 include $(BUILD_HOST_JAVA_LIBRARY)
 
diff --git a/tests/src/com/android/regression/tests/DetectRegressionTest.java b/tests/src/com/android/regression/tests/DetectRegressionTest.java
new file mode 100644
index 0000000..0434969
--- /dev/null
+++ b/tests/src/com/android/regression/tests/DetectRegressionTest.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.android.regression.tests.DetectRegression.TableRow;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.util.MultiMap;
+
+import com.google.common.primitives.Doubles;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/** Unit tests for {@link DetectRegression}. */
+@RunWith(JUnit4.class)
+public class DetectRegressionTest {
+
+    private static final double EPS = 0.0001;
+
+    @Test
+    public void testCalcMean() {
+        Map<Double, double[]> data = new HashMap<>();
+        data.put(2.5, new double[] {1, 2, 3, 4});
+        data.put(
+                4.5,
+                new double[] {
+                    -11, 22, 5, 4, 2.5,
+                });
+        data.forEach(
+                (k, v) -> {
+                    assertTrue(equal(k, DetectRegression.calcMean(Doubles.asList(v))));
+                });
+    }
+
+    @Test
+    public void testCalcStdDev() {
+        Map<Double, double[]> data = new HashMap<>();
+        data.put(36.331000536732, new double[] {12.3, 56.7, 45.6, 124, 56});
+        data.put(119.99906922093, new double[] {123.4, 22.5, 5.67, 4.56, 2.5, 333});
+        data.forEach(
+                (k, v) -> {
+                    assertTrue(equal(k, DetectRegression.calcStdDev(Doubles.asList(v))));
+                });
+    }
+
+    @Test
+    public void testDetectRegression() {
+        List<List<Double>> befores =
+                Arrays.stream(
+                                new double[][] {
+                                    {3, 3, 3, 3, 3},
+                                    {3, 5, 3, 5, 4},
+                                    {3, 4, 3, 4, 3},
+                                    {1, 2, 3, 2, 1},
+                                    {-1, -2, -3, 0, 1, 2, 3},
+                                    {5, 6, 5, 6, 6, 5, 7},
+                                })
+                        .map(Doubles::asList)
+                        .collect(Collectors.toList());
+        List<List<Double>> afters =
+                Arrays.stream(
+                                new double[][] {
+                                    {3, 3, 3, 3, 3},
+                                    {2, 3, 4, 5, 6},
+                                    {2, 5, 2, 5, 2},
+                                    {10, 11, 12, 13, 14},
+                                    {-10, -20, -30, 0, 10, 20, 30},
+                                    {5, 6, 5, 6, 6, 5, 700},
+                                })
+                        .map(Doubles::asList)
+                        .collect(Collectors.toList());
+        boolean[] result = {false, false, true, true, true, false};
+
+        for (int i = 0; i < result.length; i++) {
+            assertEquals(
+                    result[i], DetectRegression.computeRegression(befores.get(i), afters.get(i)));
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testRunRegressionDetection() {
+        DetectRegression detector = spy(DetectRegression.class);
+        doNothing().when(detector).logResult(any(), any(), any(), any());
+        TestDescription id1 = new TestDescription("class", "test1");
+        TestDescription id2 = new TestDescription("class", "test2");
+        Metrics before = new Metrics(false);
+        Arrays.asList("3.0", "3.0", "3.0", "3.0", "3.0")
+                .forEach(e -> before.addRunMetric("metric-1", e));
+        Arrays.asList("3.1", "3.3", "3.1", "3.2", "3.3")
+                .forEach(e -> before.addRunMetric("metric-2", e));
+        Arrays.asList("5.1", "5.2", "5.1", "5.2", "5.1")
+                .forEach(e -> before.addRunMetric("metric-3", e));
+        Arrays.asList("3.0", "3.0", "3.0", "3.0", "3.0")
+                .forEach(e -> before.addTestMetric(id1, "metric-4", e));
+        Arrays.asList("3.1", "3.3", "3.1", "3.2", "3.3")
+                .forEach(e -> before.addTestMetric(id2, "metric-5", e));
+        Arrays.asList("5.1", "5.2", "5.1", "5.2", "5.1")
+                .forEach(e -> before.addTestMetric(id2, "metric-6", e));
+
+        Metrics after = new Metrics(false);
+        Arrays.asList("3.0", "3.0", "3.0", "3.0", "3.0")
+                .forEach(e -> after.addRunMetric("metric-1", e));
+        Arrays.asList("3.2", "3.2", "3.2", "3.2", "3.2")
+                .forEach(e -> after.addRunMetric("metric-2", e));
+        Arrays.asList("8.1", "8.2", "8.1", "8.2", "8.1")
+                .forEach(e -> after.addRunMetric("metric-3", e));
+        Arrays.asList("3.0", "3.0", "3.0", "3.0", "3.0")
+                .forEach(e -> after.addTestMetric(id1, "metric-4", e));
+        Arrays.asList("3.2", "3.2", "3.2", "3.2", "3.2")
+                .forEach(e -> after.addTestMetric(id2, "metric-5", e));
+        Arrays.asList("8.1", "8.2", "8.1", "8.2", "8.1")
+                .forEach(e -> after.addTestMetric(id2, "metric-6", e));
+
+        ArgumentCaptor<List<TableRow>> runResultCaptor = ArgumentCaptor.forClass(List.class);
+        ArgumentCaptor<MultiMap<String, TableRow>> testResultCaptor =
+                ArgumentCaptor.forClass(MultiMap.class);
+        detector.runRegressionDetection(before, after);
+        verify(detector, times(1))
+                .logResult(
+                        eq(before),
+                        eq(after),
+                        runResultCaptor.capture(),
+                        testResultCaptor.capture());
+
+        List<TableRow> runResults = runResultCaptor.getValue();
+        assertEquals(1, runResults.size());
+        assertEquals("metric-3", runResults.get(0).name);
+
+        MultiMap<String, TableRow> testResults = testResultCaptor.getValue();
+        assertEquals(1, testResults.size());
+        assertEquals(1, testResults.get(id2.toString()).size());
+        assertEquals("metric-6", testResults.get(id2.toString()).get(0).name);
+    }
+
+    private boolean equal(double d1, double d2) {
+        return Math.abs(d1 - d2) < EPS;
+    }
+}
diff --git a/tests/src/com/android/regression/tests/MetricsTest.java b/tests/src/com/android/regression/tests/MetricsTest.java
new file mode 100644
index 0000000..6d672db
--- /dev/null
+++ b/tests/src/com/android/regression/tests/MetricsTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.util.Pair;
+
+import com.google.common.primitives.Doubles;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Unit tests for {@link Metrics}. */
+@RunWith(JUnit4.class)
+public class MetricsTest {
+
+    private Metrics mMetrics;
+
+    @Before
+    public void setUp() throws Exception {
+        mMetrics = spy(new Metrics(false));
+    }
+
+    @Test
+    public void testAddRunMetrics() {
+        Map<String, List<String>> data = new HashMap<>();
+        data.put("metric1", Arrays.asList("1.0", "1.1", "1.2"));
+        data.put("metric2", Arrays.asList("2.0", "2.1", "2.2"));
+        data.forEach((k, v) -> v.forEach(e -> mMetrics.addRunMetric(k, e)));
+        assertEquals(Doubles.asList(1.0, 1.1, 1.2), mMetrics.getRunMetrics().get("metric1"));
+        assertEquals(Doubles.asList(2.0, 2.1, 2.2), mMetrics.getRunMetrics().get("metric2"));
+    }
+
+    @Test
+    public void testAddTestMetrics() {
+        TestDescription id1 = new TestDescription("class", "test1");
+        Arrays.asList("1.0", "1.1", "1.2").forEach(e -> mMetrics.addTestMetric(id1, "metric1", e));
+        TestDescription id2 = new TestDescription("class", "test2");
+        Arrays.asList("2.0", "2.1", "2.2").forEach(e -> mMetrics.addTestMetric(id2, "metric1", e));
+        Arrays.asList("3.0", "3.1", "3.2").forEach(e -> mMetrics.addTestMetric(id2, "metric2", e));
+
+        assertEquals(
+                Doubles.asList(1.0, 1.1, 1.2),
+                mMetrics.getTestMetrics().get(new Pair<>(id1, "metric1")));
+        assertEquals(
+                Doubles.asList(2.0, 2.1, 2.2),
+                mMetrics.getTestMetrics().get(new Pair<>(id2, "metric1")));
+        assertEquals(
+                Doubles.asList(3.0, 3.1, 3.2),
+                mMetrics.getTestMetrics().get(new Pair<>(id2, "metric2")));
+    }
+
+    @Test
+    public void testValidate() {
+        Map<String, List<String>> data = new HashMap<>();
+        data.put("metric1", Arrays.asList("1.0", "1.1", "1.2"));
+        data.put("metric2", Arrays.asList("2.0", "2.1"));
+        data.forEach((k, v) -> v.forEach(e -> mMetrics.addRunMetric(k, e)));
+        TestDescription id1 = new TestDescription("class", "test1");
+        Arrays.asList("1.0", "1.1", "1.2").forEach(e -> mMetrics.addTestMetric(id1, "metric1", e));
+        TestDescription id2 = new TestDescription("class", "test2");
+        Arrays.asList("2.0", "2.1", "2.2").forEach(e -> mMetrics.addTestMetric(id2, "metric1", e));
+        Arrays.asList("3.0", "3.1").forEach(e -> mMetrics.addTestMetric(id2, "metric2", e));
+        mMetrics.validate(3);
+        verify(mMetrics, times(2)).error(anyString());
+    }
+
+    @Test
+    public void testCrossValidate() {
+        Metrics other = new Metrics(false);
+        Arrays.asList("1.0", "1.1", "1.2")
+                .forEach(
+                        e -> {
+                            mMetrics.addRunMetric("metric1", e);
+                            other.addRunMetric("metric1", e);
+                        });
+        Arrays.asList("2.0", "2.1", "2.2").forEach(e -> mMetrics.addRunMetric("metric2", e));
+        Arrays.asList("2.0", "2.1", "2.2").forEach(e -> other.addRunMetric("metric5", e));
+        TestDescription id1 = new TestDescription("class", "test1");
+        Arrays.asList("1.0", "1.1", "1.2")
+                .forEach(
+                        e -> {
+                            mMetrics.addTestMetric(id1, "metric1", e);
+                            other.addTestMetric(id1, "metric1", e);
+                        });
+        Arrays.asList("3.0", "3.1", "3.3").forEach(e -> mMetrics.addTestMetric(id1, "metric6", e));
+        TestDescription id2 = new TestDescription("class", "test2");
+        Arrays.asList("2.0", "2.1", "2.2")
+                .forEach(
+                        e -> {
+                            mMetrics.addTestMetric(id2, "metric1", e);
+                            other.addTestMetric(id2, "metric1", e);
+                        });
+        Arrays.asList("3.0", "3.1", "3.3").forEach(e -> other.addTestMetric(id2, "metric2", e));
+        mMetrics.crossValidate(other);
+        verify(mMetrics, times(1)).warn("Run metric \"metric2\" only in before-patch run.");
+        verify(mMetrics, times(1)).warn("Run metric \"metric5\" only in after-patch run.");
+        verify(mMetrics, times(1))
+                .warn(
+                        String.format(
+                                "Test %s metric \"metric6\" only in before-patch run.",
+                                id1.toString()));
+        verify(mMetrics, times(1))
+                .warn(
+                        String.format(
+                                "Test %s metric \"metric2\" only in after-patch run.",
+                                id2.toString()));
+    }
+}
diff --git a/tests/src/com/android/regression/tests/MetricsXmlParserTest.java b/tests/src/com/android/regression/tests/MetricsXmlParserTest.java
new file mode 100644
index 0000000..74596f6
--- /dev/null
+++ b/tests/src/com/android/regression/tests/MetricsXmlParserTest.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2018 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.regression.tests;
+
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.android.regression.tests.MetricsXmlParser.ParseException;
+import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.MetricsXMLResultReporter;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.util.proto.TfMetricProtoUtil;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** Simple unit tests for {@link MetricsXmlParser}. */
+@RunWith(JUnit4.class)
+public class MetricsXmlParserTest {
+
+    @Spy private MetricsXMLResultReporter mResultReporter;
+    @Mock private Metrics mMetrics;
+    private ByteArrayOutputStream mOutputStream;
+
+    @Before
+    public void setUp() throws Exception {
+        mOutputStream = new ByteArrayOutputStream();
+        MockitoAnnotations.initMocks(this);
+        OptionSetter optionSetter = new OptionSetter(mResultReporter);
+        optionSetter.setOptionValue("metrics-folder", "/tmp");
+        doReturn(mOutputStream).when(mResultReporter).createOutputStream();
+        doReturn("ignore").when(mResultReporter).getTimeStamp();
+    }
+
+    /** Test behavior when data to parse is empty */
+    @Test
+    public void testEmptyParse() {
+        try {
+            MetricsXmlParser.parse(
+                    mMetrics, Collections.emptySet(), new ByteArrayInputStream(new byte[0]));
+            fail("ParseException not thrown");
+        } catch (ParseException e) {
+            // expected
+        }
+        Mockito.verifyZeroInteractions(mMetrics);
+    }
+
+    /** Simple success test for xml parsing */
+    @Test
+    public void testSimpleParse() throws ParseException {
+        IInvocationContext context = new InvocationContext();
+        context.addDeviceBuildInfo("fakeDevice", new BuildInfo());
+        context.setTestTag("stub");
+        mResultReporter.invocationStarted(context);
+        mResultReporter.testRunStarted("run", 3);
+        final TestDescription testId0 = new TestDescription("Test", "pass1");
+        mResultReporter.testStarted(testId0);
+        mResultReporter.testEnded(testId0, new HashMap<String, Metric>());
+        final TestDescription testId1 = new TestDescription("Test", "pass2");
+        mResultReporter.testStarted(testId1);
+        mResultReporter.testEnded(testId1, new HashMap<String, Metric>());
+        final TestDescription testId2 = new TestDescription("Test", "pass3");
+        mResultReporter.testStarted(testId2);
+        mResultReporter.testEnded(testId2, new HashMap<String, Metric>());
+        mResultReporter.testRunEnded(3, new HashMap<String, Metric>());
+        mResultReporter.invocationEnded(5);
+
+        MetricsXmlParser.parse(
+                mMetrics, Collections.emptySet(), new ByteArrayInputStream(getOutput()));
+        verify(mMetrics).setNumTests(3);
+        verify(mMetrics).addRunMetric("time", "5");
+        verify(mMetrics, times(0)).addTestMetric(any(), anyString(), anyString());
+        Mockito.verifyNoMoreInteractions(mMetrics);
+    }
+
+    /** Test parsing a comprehensive document containing run metrics and test metrics */
+    @Test
+    public void testParse() throws ParseException {
+        IInvocationContext context = new InvocationContext();
+        context.addDeviceBuildInfo("fakeDevice", new BuildInfo());
+        context.setTestTag("stub");
+        mResultReporter.invocationStarted(context);
+        mResultReporter.testRunStarted("run", 2);
+
+        final TestDescription testId0 = new TestDescription("Test", "pass1");
+        mResultReporter.testStarted(testId0);
+        Map<String, String> testMetrics0 = new HashMap<>();
+        testMetrics0.put("metric1", "1.1");
+        mResultReporter.testEnded(testId0, TfMetricProtoUtil.upgradeConvert(testMetrics0));
+
+        final TestDescription testId1 = new TestDescription("Test", "pass2");
+        mResultReporter.testStarted(testId1);
+        Map<String, String> testMetrics1 = new HashMap<>();
+        testMetrics1.put("metric2", "5.5");
+        mResultReporter.testEnded(testId1, TfMetricProtoUtil.upgradeConvert(testMetrics1));
+
+        Map<String, String> runMetrics = new HashMap<>();
+        runMetrics.put("metric3", "8.8");
+        mResultReporter.testRunEnded(3, TfMetricProtoUtil.upgradeConvert(runMetrics));
+        mResultReporter.invocationEnded(5);
+
+        MetricsXmlParser.parse(
+                mMetrics, Collections.emptySet(), new ByteArrayInputStream(getOutput()));
+
+        verify(mMetrics).setNumTests(2);
+        verify(mMetrics).addRunMetric("metric3", "8.8");
+        verify(mMetrics).addTestMetric(testId0, "metric1", "1.1");
+        verify(mMetrics).addTestMetric(testId1, "metric2", "5.5");
+    }
+
+    /** Test parsing a document with blacklist metrics */
+    @Test
+    public void testParseBlacklist() throws ParseException {
+        IInvocationContext context = new InvocationContext();
+        context.addDeviceBuildInfo("fakeDevice", new BuildInfo());
+        context.setTestTag("stub");
+        mResultReporter.invocationStarted(context);
+        mResultReporter.testRunStarted("run", 3);
+
+        final TestDescription testId0 = new TestDescription("Test", "pass1");
+        mResultReporter.testStarted(testId0);
+        Map<String, String> testMetrics0 = new HashMap<>();
+        testMetrics0.put("metric1", "1.1");
+        mResultReporter.testEnded(testId0, TfMetricProtoUtil.upgradeConvert(testMetrics0));
+
+        final TestDescription testId1 = new TestDescription("Test", "pass2");
+        mResultReporter.testStarted(testId1);
+        Map<String, String> testMetrics1 = new HashMap<>();
+        testMetrics1.put("metric2", "5.5");
+        mResultReporter.testEnded(testId1, TfMetricProtoUtil.upgradeConvert(testMetrics1));
+
+        Map<String, String> runMetrics = new HashMap<>();
+        runMetrics.put("metric3", "8.8");
+        mResultReporter.testRunEnded(3, TfMetricProtoUtil.upgradeConvert(runMetrics));
+        mResultReporter.invocationEnded(5);
+
+        Set<String> blacklist = ImmutableSet.of("metric1", "metric3");
+
+        MetricsXmlParser.parse(mMetrics, blacklist, new ByteArrayInputStream(getOutput()));
+
+        verify(mMetrics, times(0)).addRunMetric("metric3", "8.8");
+        verify(mMetrics, times(0)).addTestMetric(testId0, "metric1", "1.1");
+        verify(mMetrics).addTestMetric(testId1, "metric2", "5.5");
+    }
+
+    /** Gets the output produced, stripping it of extraneous whitespace characters. */
+    private byte[] getOutput() {
+        return mOutputStream.toByteArray();
+    }
+}
diff --git a/tests/src/com/android/tradefed/prodtests/UnitTests.java b/tests/src/com/android/tradefed/prodtests/UnitTests.java
index d8ff12b..e74ca9c 100644
--- a/tests/src/com/android/tradefed/prodtests/UnitTests.java
+++ b/tests/src/com/android/tradefed/prodtests/UnitTests.java
@@ -18,6 +18,9 @@
 import com.android.build.tests.ImageStatsTest;
 import com.android.continuous.SmokeTestTest;
 import com.android.monkey.MonkeyBaseTest;
+import com.android.regression.tests.DetectRegressionTest;
+import com.android.regression.tests.MetricsTest;
+import com.android.regression.tests.MetricsXmlParserTest;
 
 import org.junit.runner.RunWith;
 import org.junit.runners.Suite;
@@ -38,7 +41,12 @@
     SmokeTestTest.class,
 
     // monkey
-    MonkeyBaseTest.class
+    MonkeyBaseTest.class,
+
+    // regression
+    DetectRegressionTest.class,
+    MetricsTest.class,
+    MetricsXmlParserTest.class,
 })
 public class UnitTests {
     // empty of purpose