blob: b4f61796f97c2e752dd7e292872bd8fcd47abad2 [file] [log] [blame]
/*
* Copyright (C) 2016 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.testtype;
import com.android.ddmlib.MultiLineReceiver;
import com.android.tradefed.error.HarnessRuntimeException;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.result.error.TestErrorIdentifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Interprets the output of tests run with Python's unittest framework and translates it into calls
* on a series of {@link ITestInvocationListener}s. Output from these tests follows this EBNF
* grammar:
*
* <p>TestReport ::= TestResult* Line TimeMetric [FailMessage*] Status. TestResult ::= string
* “(“string”)” “…” SingleStatus. FailMessage ::= EqLine “ERROR:” string “(“string”)” Line Traceback
* Line. SingleStatus ::= “ok” | “ERROR”. TimeMetric ::= “Ran” integer “tests in” float ”s”. Status
* ::= “OK” | “FAILED (errors=” int “)”. Traceback ::= string+.
*
* <p>Example output (passing): test_size (test_rangelib.RangeSetTest) ... ok test_str
* (test_rangelib.RangeSetTest) ... ok test_subtract (test_rangelib.RangeSetTest) ... ok
* test_to_string_raw (test_rangelib.RangeSetTest) ... ok test_union (test_rangelib.RangeSetTest)
* ... ok
*
* <p>---------------------------------------------------------------------- Ran 5 tests in 0.002s
*
* <p>OK
*
* <p>Example output (failed) test_size (test_rangelib.RangeSetTest) ... ERROR
*
* <p>====================================================================== ERROR: test_size
* (test_rangelib.RangeSetTest)
* ---------------------------------------------------------------------- Traceback (most recent
* call last): File "test_rangelib.py", line 129, in test_rangelib raise ValueError() ValueError
* ---------------------------------------------------------------------- Ran 1 test in 0.001s
* FAILED (errors=1)
*
* <p>Example output with several edge cases (failed): testError (foo.testFoo) ... ERROR
* testExpectedFailure (foo.testFoo) ... expected failure testFail (foo.testFoo) ... FAIL
* testFailWithDocString (foo.testFoo) foo bar ... FAIL testOk (foo.testFoo) ... ok
* testOkWithDocString (foo.testFoo) foo bar ... ok testSkipped (foo.testFoo) ... skipped 'reason
* foo' testUnexpectedSuccess (foo.testFoo) ... unexpected success
*
* <p>====================================================================== ERROR: testError
* (foo.testFoo) ---------------------------------------------------------------------- Traceback
* (most recent call last): File "foo.py", line 11, in testError self.assertEqual(2+2, 5/0)
* ZeroDivisionError: integer division or modulo by zero
*
* <p>====================================================================== FAIL: testFail
* (foo.testFoo) ---------------------------------------------------------------------- Traceback
* (most recent call last): File "foo.py", line 8, in testFail self.assertEqual(2+2, 5)
* AssertionError: 4 != 5
*
* <p>====================================================================== FAIL:
* testFailWithDocString (foo.testFoo) foo bar
* ---------------------------------------------------------------------- Traceback (most recent
* call last): File "foo.py", line 31, in testFailWithDocString self.assertEqual(2+2, 5)
* AssertionError: 4 != 5
*
* <p>---------------------------------------------------------------------- Ran 8 tests in 0.001s
*
* <p>FAILED (failures=2, errors=1, skipped=1, expected failures=1, unexpected successes=1)
*
* <p>TODO: Consider refactoring the full class, handling is quite messy right now.
*/
public class PythonUnitTestResultParser extends MultiLineReceiver {
private boolean mFinalizeWhenParsing = true;
// Current test state
private ParserState mCurrentParseState;
private String mCurrentTestName;
private String mCurrentTestClass;
private String mCurrentTestStatus;
private Matcher mCurrentMatcher;
private StringBuilder mCurrentTraceback;
private long mTotalElapsedTime;
private int mTotalTestCount;
private String mCurrentTestCaseString = null;
// Filters used for parsing test methods.
private Set<String> mIncludeFilters = new LinkedHashSet<>();
private Set<String> mExcludeFilters = new LinkedHashSet<>();
// General state
private Collection<ITestInvocationListener> mListeners = new ArrayList<>();
private final String mRunName;
private Map<TestDescription, String> mTestResultCache;
// Use a special entry to mark skipped test in mTestResultCache
static final String SKIPPED_ENTRY = "Skipped";
// Constant tokens that appear in the result grammar.
static final String EQUAL_LINE =
"======================================================================";
static final String DASH_LINE =
"----------------------------------------------------------------------";
static final String TRACEBACK_LINE = "Traceback (most recent call last):";
static final Pattern PATTERN_TEST_SUCCESS = Pattern.compile("ok|expected failure");
static final Pattern PATTERN_TEST_FAILURE = Pattern.compile("FAIL|ERROR");
static final Pattern PATTERN_TEST_SKIPPED = Pattern.compile("skipped '.*");
static final Pattern PATTERN_TEST_UNEXPECTED_SUCCESS = Pattern.compile("unexpected success");
static final Pattern PATTERN_ONE_LINE_RESULT =
Pattern.compile(
"(\\S*) \\((\\S*)\\) \\.\\.\\. "
+ "(ok|expected failure|FAIL|ERROR|skipped '.*'|unexpected success)?");
static final Pattern PATTERN_TWO_LINE_RESULT_FIRST = Pattern.compile("(\\S*) \\((\\S*)\\)");
static final Pattern PATTERN_TWO_LINE_RESULT_SECOND =
Pattern.compile(
"(.*) \\.\\.\\. "
+ "(ok|expected failure|FAIL|ERROR|skipped '.*'|unexpected success)");
static final Pattern PATTERN_TWO_LINE_RESULT_SECOND_ERROR =
Pattern.compile(
"(.*) \\.\\.\\. error: (.*)"
+ "(ok|expected failure|FAIL|ERROR|skipped '.*'|unexpected success)",
Pattern.DOTALL);
static final Pattern PATTERN_FAIL_MESSAGE =
Pattern.compile("(FAIL|ERROR): (\\S*) \\((\\S*)\\)( \\(.*\\))?");
static final Pattern PATTERN_RUN_SUMMARY =
Pattern.compile("Ran (\\d+) tests? in (\\d+(.\\d*)?)s(.*)");
/** In case of error spanning over multiple lines. */
static final Pattern MULTILINE_RESULT_WITH_WARNING =
Pattern.compile("(.*) \\.\\.\\. (.*)", Pattern.DOTALL);
static final Pattern MULTILINE_FINAL_RESULT_WITH_WARNING =
Pattern.compile("(.*) \\.\\.\\. (.*)ok(.*)", Pattern.DOTALL);
/** Unexpected (multiline) text between ... and test status - likely corrupted */
static final Pattern PATTERN_MULTILINE_RESULT_FIRST =
Pattern.compile("(\\S*) \\((\\S*)\\) \\.\\.\\. .+");
static final Pattern PATTERN_MULTILINE_RESULT_FIRST_NEGATIVE =
Pattern.compile(
"(\\S*) \\((\\S*)\\) \\.\\.\\. (ok|expected failure|FAIL|ERROR|error|skipped"
+ " '.*'|unexpected success).*");
static final Pattern PATTERN_MULTILINE_RESULT_LAST =
Pattern.compile("(ok|expected failure|FAIL|ERROR|skipped '.*'|unexpected success)");
static final Pattern PATTERN_RUN_RESULT = Pattern.compile("(OK|FAILED).*");
enum ParserState {
TEST_CASE,
FAIL_MESSAGE,
TRACEBACK,
SUMMARY,
COMPLETE,
}
private class PythonUnitTestParseException extends Exception {
static final long serialVersionUID = -3387516993124229948L;
public PythonUnitTestParseException(String reason) {
super(reason);
}
}
/**
* Create a new {@link PythonUnitTestResultParser} that reports to the given {@link
* ITestInvocationListener}.
*/
public PythonUnitTestResultParser(ITestInvocationListener listener, String runName) {
this(Arrays.asList(listener), runName);
}
/**
* Create a new {@link PythonUnitTestResultParser} that reports to the given {@link
* ITestInvocationListener}s.
*/
public PythonUnitTestResultParser(
Collection<ITestInvocationListener> listeners, String runName) {
this(listeners, runName, new LinkedHashSet<>(), new LinkedHashSet<>());
}
/**
* Create a new {@link PythonUnitTestResultParser} that reports to the given {@link
* ITestInvocationListener}s, with specified include and exclude filters.
*/
public PythonUnitTestResultParser(
Collection<ITestInvocationListener> listeners,
String runName,
Set<String> includeFilters,
Set<String> excludeFilters) {
mListeners.addAll(listeners);
mRunName = runName;
mTestResultCache = new LinkedHashMap<>();
mIncludeFilters = includeFilters;
mExcludeFilters = excludeFilters;
mCurrentParseState = ParserState.TEST_CASE;
}
/**
* Process Python unittest output and report parsed results.
*
* <p>This method should be called only once with the full output, unlike the base method in
* {@link MultiLineReceiver}.
*/
@Override
public void processNewLines(String[] lines) {
try {
if (lines.length < 1 || isTracebackLine(lines[0])) {
throw new PythonUnitTestParseException("Test execution failed");
}
for (String line : lines) {
parse(line);
}
if (mFinalizeWhenParsing) {
finalizeParser();
}
} catch (PythonUnitTestParseException e) {
throw new HarnessRuntimeException(
e.getMessage(), TestErrorIdentifier.OUTPUT_PARSER_ERROR);
}
}
public void setFinalizeWhenParsing(boolean shouldFinalize) {
mFinalizeWhenParsing = shouldFinalize;
}
public void finalizeParser() {
if (mCurrentParseState != ParserState.COMPLETE) {
throw new HarnessRuntimeException(
"Parser finished in unexpected state " + mCurrentParseState.toString(),
TestErrorIdentifier.OUTPUT_PARSER_ERROR);
}
}
/** Parse the next result line according to current parser state. */
void parse(String line) throws PythonUnitTestParseException {
CLog.v(line);
switch (mCurrentParseState) {
case TEST_CASE:
processTestCase(line);
break;
case TRACEBACK:
processTraceback(line);
break;
case SUMMARY:
processRunSummary(line);
break;
case FAIL_MESSAGE:
processFailMessage(line);
break;
case COMPLETE:
break;
}
}
/** Process a test case line and collect the test name, class, and status. */
void processTestCase(String line) throws PythonUnitTestParseException {
if (mCurrentTestCaseString != null) {
mCurrentTestCaseString = mCurrentTestCaseString + "\n" + line;
line = mCurrentTestCaseString;
}
if (isEqualLine(line)) {
// equal line before fail message
mCurrentParseState = ParserState.FAIL_MESSAGE;
mCurrentTestCaseString = null;
} else if (isDashLine(line)) {
// dash line before run summary
mCurrentParseState = ParserState.SUMMARY;
mCurrentTestCaseString = null;
} else if (lineStartswithPattern(line, PATTERN_ONE_LINE_RESULT)) {
// The below parsing is involved due to output from tests that use Python's subTest
// feature. In particular, a line could contain multiple test case summary lines such
// as; a (T) ... b (T) ... ok.
// TODO(hzalek): Consider adding a Python support library for writing the output of the
// test in a structured format to avoid parsing string output.
List<MatchResult> matchResults = new ArrayList<>();
// Collect the results to avoid modifying state in case the entire line doesn't match.
do {
matchResults.add(mCurrentMatcher.toMatchResult());
} while (mCurrentMatcher.find());
int lastMatchEnd = matchResults.get(matchResults.size() - 1).end();
if (lastMatchEnd != line.length()) {
boolean canBeMultiline =
!lineMatchesPattern(line, PATTERN_MULTILINE_RESULT_FIRST_NEGATIVE);
if (canBeMultiline && lineMatchesPattern(line, PATTERN_MULTILINE_RESULT_FIRST)) {
mCurrentTestName = mCurrentMatcher.group(1);
mCurrentTestClass =
removeTestNameFromClassNameGroup(
mCurrentMatcher.group(2), mCurrentTestName);
mCurrentTestCaseString = null;
}
return; // The entire line doesn't match so just ignore it.
}
for (MatchResult r : matchResults) {
mCurrentTestName = r.group(1);
mCurrentTestClass = removeTestNameFromClassNameGroup(r.group(2), mCurrentTestName);
mCurrentTestStatus = r.group(3);
// Tests with failed subtests have no status printed so we add an entry with 'FAIL'
// status. In any case, subsequent failed subtest assertions have the same test name
// and will clobber whatever status we set here. Passed subtest assertions don't
// appear in the output and do not risk overwriting the status.
if (mCurrentTestStatus == null) {
mCurrentTestStatus = "FAIL";
}
reportNonFailureTestResult();
}
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, PATTERN_MULTILINE_RESULT_LAST)) {
mCurrentTestStatus = mCurrentMatcher.group(1);
reportNonFailureTestResult();
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, PATTERN_TWO_LINE_RESULT_FIRST)) {
mCurrentTestName = mCurrentMatcher.group(1);
mCurrentTestClass =
removeTestNameFromClassNameGroup(mCurrentMatcher.group(2), mCurrentTestName);
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, PATTERN_TWO_LINE_RESULT_SECOND)) {
mCurrentTestStatus = mCurrentMatcher.group(2);
reportNonFailureTestResult();
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, PATTERN_TWO_LINE_RESULT_SECOND_ERROR)) {
// Skip that odd error message
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, MULTILINE_FINAL_RESULT_WITH_WARNING)) {
StringBuilder message = new StringBuilder("Test seems to pass but with Warnings:\n");
message.append(mCurrentMatcher.group(2));
mCurrentTraceback = message;
reportFailureTestResult();
mCurrentTestCaseString = null;
} else if (lineMatchesPattern(line, MULTILINE_RESULT_WITH_WARNING)) {
if (mCurrentTestCaseString == null) {
mCurrentTestCaseString = line;
}
}
}
String removeTestNameFromClassNameGroup(String classNameGroup, String testName) {
if (!classNameGroup.endsWith(testName)) {
return classNameGroup;
}
Pattern p = Pattern.compile("(.*)\\." + testName);
Matcher matcher = p.matcher(classNameGroup);
if (!matcher.matches()) {
return classNameGroup;
}
return matcher.group(1);
}
/** Process a fail message line and collect the test name, class, and status. */
void processFailMessage(String line) {
if (isDashLine(line)) {
// dash line before traceback
mCurrentParseState = ParserState.TRACEBACK;
mCurrentTraceback = new StringBuilder();
} else if (lineMatchesPattern(line, PATTERN_FAIL_MESSAGE)) {
mCurrentTestName = mCurrentMatcher.group(2);
mCurrentTestClass =
removeTestNameFromClassNameGroup(mCurrentMatcher.group(3), mCurrentTestName);
mCurrentTestStatus = mCurrentMatcher.group(1);
}
// optional docstring - do nothing
}
/** Process a traceback line and append it to the full traceback message. */
void processTraceback(String line) {
if (isDashLine(line)) {
// dash line before run summary
mCurrentParseState = ParserState.SUMMARY;
reportFailureTestResult();
} else if (isEqualLine(line)) {
// equal line before another fail message followed by traceback
mCurrentParseState = ParserState.FAIL_MESSAGE;
reportFailureTestResult();
} else {
if (mCurrentTraceback.length() > 0) {
mCurrentTraceback.append(System.lineSeparator());
}
mCurrentTraceback.append(line);
}
}
/** Process the run summary line and collect the test count and run time. */
void processRunSummary(String line) {
if (lineMatchesPattern(line, PATTERN_RUN_SUMMARY)) {
mTotalTestCount = Integer.parseInt(mCurrentMatcher.group(1));
double timeInSeconds = Double.parseDouble(mCurrentMatcher.group(2));
mTotalElapsedTime = (long) (timeInSeconds * 1000D);
reportToListeners();
mCurrentParseState = ParserState.COMPLETE;
}
// ignore status message on the last line because Python consider "unexpected success"
// passed while we consider it failed
}
boolean isEqualLine(String line) {
return line.startsWith(EQUAL_LINE);
}
boolean isDashLine(String line) {
return line.startsWith(DASH_LINE);
}
boolean isTracebackLine(String line) {
return line.startsWith(TRACEBACK_LINE);
}
/** Check if the given line matches the given pattern and caches the matcher object */
private boolean lineMatchesPattern(String line, Pattern p) {
mCurrentMatcher = p.matcher(line);
return mCurrentMatcher.matches();
}
private boolean lineStartswithPattern(String line, Pattern p) {
mCurrentMatcher = p.matcher(line);
return mCurrentMatcher.find();
}
/** Send recorded test results to all listeners. */
private void reportToListeners() {
for (ITestInvocationListener listener : mListeners) {
listener.testRunStarted(mRunName, mTotalTestCount);
for (Entry<TestDescription, String> test : mTestResultCache.entrySet()) {
listener.testStarted(test.getKey());
if (SKIPPED_ENTRY.equals(test.getValue())) {
listener.testIgnored(test.getKey());
} else if (test.getValue() != null) {
listener.testFailed(test.getKey(), test.getValue());
}
listener.testEnded(test.getKey(), new HashMap<String, Metric>());
}
listener.testRunEnded(mTotalElapsedTime, new HashMap<String, Metric>());
}
}
/** Record a non-failure test case. */
private void reportNonFailureTestResult() throws PythonUnitTestParseException {
TestDescription testId = new TestDescription(mCurrentTestClass, mCurrentTestName);
if (shouldSkipCurrentTest()) {
// Force to skip any test not listed in include filters, or listed in exclude filters.
mTestResultCache.put(testId, SKIPPED_ENTRY);
} else if (PATTERN_TEST_SUCCESS.matcher(mCurrentTestStatus).matches()) {
mTestResultCache.put(testId, null);
} else if (PATTERN_TEST_SKIPPED.matcher(mCurrentTestStatus).matches()) {
mTestResultCache.put(testId, SKIPPED_ENTRY);
} else if (PATTERN_TEST_UNEXPECTED_SUCCESS.matcher(mCurrentTestStatus).matches()) {
mTestResultCache.put(testId, "Test unexpected succeeded");
} else if (PATTERN_TEST_FAILURE.matcher(mCurrentTestStatus).matches()) {
// do nothing for now, report only after traceback is collected
} else {
throw new PythonUnitTestParseException("Unrecognized test status");
}
}
/** Record a failed test case and its traceback message. */
private void reportFailureTestResult() {
TestDescription testId = new TestDescription(mCurrentTestClass, mCurrentTestName);
if (shouldSkipCurrentTest()) {
mTestResultCache.put(testId, SKIPPED_ENTRY);
} else {
mTestResultCache.put(testId, mCurrentTraceback.toString());
}
}
/**
* Check if current test should be skipped.
*
* @return true if the test should be skipped.
*/
private boolean shouldSkipCurrentTest() {
// Force to skip any test not listed in include filters, or listed in exclude filters.
// exclude filters have highest priority.
if (mExcludeFilters.contains(mCurrentTestClass + "#" + mCurrentTestName)
|| mExcludeFilters.contains(mCurrentTestClass)) {
return true;
}
if (!mIncludeFilters.isEmpty()) {
if (mIncludeFilters.contains(mCurrentTestClass + "#" + mCurrentTestName)
|| mIncludeFilters.contains(mCurrentTestClass)) {
return false;
}
for (String filter : mIncludeFilters) {
if ((mCurrentTestClass + "#" + mCurrentTestName).matches(filter)) {
return false;
}
}
return true;
}
return false;
}
@Override
public boolean isCancelled() {
return false;
}
}