-
Notifications
You must be signed in to change notification settings - Fork 937
Add test name filtering to the java test runner #1129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
There are already multiple ways to enable/disable tests: Here is my proposal, but I'm open to other suggestions:
This will be intuitive, simple to use, and backward compatible. |
|
If only a single test is required to run during development, We could reintroduce the |
|
Right, |
Makefile
Outdated
| test-java: build-test-java | ||
| echo "Running tests against $(LIB_PROFILER)" | ||
| $(JAVA) "-Djava.library.path=$(TEST_LIB_DIR)" $(TEST_FLAGS) -ea -cp "build/test.jar:build/jar/*:build/lib/*" one.profiler.test.Runner $(TESTS) | ||
| $(JAVA) "-Djava.library.path=$(TEST_LIB_DIR)" $(TEST_FLAGS) -ea -cp "build/test.jar:build/jar/*:build/lib/*" one.profiler.test.Runner $(TEST_DIRS) $(TEST_FILTER) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are still two different variables. My idea was to have one, so a user can specify TESTS=cpu or TESTS=ClassName or TESTS=ClassName.methodName. No filters - just a list of tests to run. If it helps, make test specifier case-sensitive, so it's easy to disambiguate between directory names and class names.
test/one/profiler/test/Runner.java
Outdated
| private static RunnerDeclaration parseRunnerDeclaration(String[] args) { | ||
| List<String> testNames = new ArrayList<>(); | ||
| List<String> testDirectories = new ArrayList<>(); | ||
| List<String> testFilters = new ArrayList<>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you overcomplicated it a bit. There is no division into directories, names and filters. Furthermore, there are no filters. Everything passed as an argument is just a test name. The rules to interpret a test name can be the following (you may suggest your version, but as usual - the simpler the better).
- If it only consists of lowercase letters - it's a directory name (alternatively, you may check for directory existence, but this is not necessary). A directory name is unambiguously translated to the class name:
alloc->test.alloc.AllocTests. - Otherwise, if it ends with
Tests, it's a class name. It may be a fully qualified class name or short class name. In the latter case, the package is prepended using the same basic rules:AllocTests->test.alloc.AllocTests. - Otherwise, if it contains a dot, it's a class name with a method name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That directory-name division was already in the code, I made it explicit by splitting args into testNames and testDirectories. testFilters is a new addition.
I am hoping to implement a contains matching. Current PR does not implement exact matching.
test/one/profiler/test/Runner.java
Outdated
| for (int i = 0; i < args.length; i++) { | ||
| String arg = args[i]; | ||
|
|
||
| if(arg.indexOf('.') >= 0 && !Character.isLowerCase(arg.charAt(0))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, format the code. There is always a space between a keyword and (.
|
|
||
| public boolean isFilterMatch(Method m) { | ||
| String fullNameLower = (m.getDeclaringClass().getName() + '.' + m.getName()).toLowerCase(); | ||
| return filters().isEmpty() || filters().stream().anyMatch(f -> fullNameLower.contains(f.toLowerCase())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To avoid ambiguity, I would not convert names to lowercase and would check for exact match rather than contains. Otherwise, AllocTests.alloc will match both alloc and allocTotal; liveness will also select livenessJfrHasStacks and so on. This is probably not what a user expects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is what I expected :) We can have both contains and exact match modes. Exact match to be implemented.
|
updated PR description with the latest changes. |
test/one/profiler/test/Filter.java
Outdated
| } | ||
| } | ||
|
|
||
| class StartsWithFilter extends Filter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having multiple top-level classes in one source file is discouraged (in favor of nested classes or one class per file).
That said, in this particular case, I think we don't all these classes at all. The code could be shorter and simpler with lambdas. Alternatively, what I would personally prefer, is using a standard j.u.regex.Pattern, which can be easily constructed from a glob expression.
test/one/profiler/test/Runner.java
Outdated
| for (int i = 0; i < args.length; i++) { | ||
| String arg = args[i]; | ||
|
|
||
| if (arg.indexOf('.') >= 0 && !Character.isLowerCase(arg.charAt(0))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This condition is never met. Class names that have a period (that is, a package) always start with a lower case character.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This condition is met e.g. for CstackTests.vmStructs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but this does not work: the subsequent Class.forName will fail anyway, since the actual class name is test.cstack.CstackTests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, simplified and refactored.
test/one/profiler/test/Runner.java
Outdated
|
|
||
| // If 'arg' contains a period and not starts with a lowercase character, treat it as a testName. | ||
| // Otherwise, if it is an existing directory, consider it a directory. | ||
| // Else, treat it as a filter. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I mentioned in a previous review, division by directories, classes and filters is confusing, especially when they are mixed together.
E.g., in the current implementation, TESTS='allo CstackTests.vmStructs' selects one test, whereas TESTS='alloc CstackTests.vmStructs' selects nothing, which is counterintuitive.
Users should not rack their brains keeping this division in mind; they just provide a list of test specifiers, and the runner's job is to make a best guess how to interpret this specifier. Specifiers are independent. So, alloc CstackTests.vmStructs should mean "run all tests in the alloc directory and CstackTests.vmStructs".
test/one/profiler/test/Runner.java
Outdated
| } | ||
| } | ||
|
|
||
| if (testDirectories.isEmpty()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This conditional branch makes it even more confusing.
Maybe we can always get a list of all available tests and then let specifiers (or filters, whatever you call it) select matched ones?
|
One more proposal: convert |
address comments
|
Simplified and refactored. Added |
| test-java: build-test-java | ||
| echo "Running tests against $(LIB_PROFILER)" | ||
| $(JAVA) "-Djava.library.path=$(TEST_LIB_DIR)" $(TEST_FLAGS) -ea -cp "build/test.jar:build/jar/*:build/lib/*" one.profiler.test.Runner $(TESTS) | ||
| $(JAVA) "-Djava.library.path=$(TEST_LIB_DIR)" $(TEST_FLAGS) -ea -cp "build/test.jar:build/jar/*:build/lib/*" one.profiler.test.Runner $(subst $(COMMA), ,$(TESTS)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since Runner already scans test directories, Makefile no longer needs to do so. Perhaps TESTS can be initialized with *
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is initialized as empty now.
test/one/profiler/test/Filter.java
Outdated
|
|
||
| import java.util.regex.Pattern; | ||
|
|
||
| public abstract class Filter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's now a single-method utility class; I'd just move the method to RunnerDeclaration.
test/one/profiler/test/Runner.java
Outdated
| for (int i = 0; i < args.length; i++) { | ||
| String arg = args[i]; | ||
| File f = new File("test/test/" + arg); | ||
| if (!f.exists() || !f.isDirectory()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RunnerDeclaration already gets a list of all directories elsewhere. Let's use list (or better, set) here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to check directory existence to distinguish between testFilters and includeDirs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You get complete list of test directories when Runner starts, no need to do filesystem checks again. includeDirs will go away as per my other comment.
So, the algorithm I imagine works as follows:
- Obtain a set of test directories by listing
test/test/. - Parse test specifiers passed in command line arguments, converting each one to
Pattern. - Similarly, parse
skipdefinition to get a list of Patterns to be skipped. - Use reflection to find all runnable tests from directories obtained at (1).
- Go through a sorted list of runnable tests and execute them if the name matches any pattern from (2) and does not match any pattern from (3).
Does this make sense?
| this.includeDirs = new HashSet<>(includeDirs); | ||
| this.filters = globs.stream().map(Filter::from).collect(Collectors.toList()); | ||
| this.looksLikeClassName = globs.stream().filter(f -> !f.contains(".") && !f.contains("*") && Character.isUpperCase(f.charAt(0))).collect(Collectors.toSet()); | ||
| this.looksLikeMethodName = globs.stream().filter(f -> !f.contains(".") && !f.contains("*") && Character.isLowerCase(f.charAt(0))).collect(Collectors.toSet()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think all these 4 structures can be replaced with a single list of patterns as follows:
- glob is a test dir =>
GlobTests.* - glob looks like a class name =>
Glob.* - glob looks like a method name =>
*.glob - otherwise =>
glob
| } | ||
|
|
||
| private List<RunnableTest> getRunnableTests(List<String> globs) { | ||
| return globs.stream().filter(g -> g.equals(g.toLowerCase())).flatMap(g -> getRunnableTests(g).stream()).sorted(Comparator.comparing(RunnableTest::testName)).collect(Collectors.toList()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not readable. Streams are usually formatted as follows:
return globs.stream()
.filter(g -> g.equals(g.toLowerCase()))
.flatMap(g -> getRunnableTests(g).stream())
.sorted(Comparator.comparing(RunnableTest::testName))
.collect(Collectors.toList());
Given that List<String> globs is always a list of directories here, filter can be removed.
Also, getRunnableTests method has too many overloads, I'd inline this one in getRunnableTests() below.
| if (!g.contains(".") && !g.contains("*")) { | ||
| // all lowercase, folder name. | ||
| if (g.equals(g.toLowerCase())) { | ||
| result.add(g.substring(0, 1).toUpperCase() + g.substring(1).toLowerCase() + "Tests.*"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
continue; ?
But I'd rewrite all this loop without continue using a plain one-level if-else chain - this will be easier to follow, IMO:
if (g.contains(".") || g.contains("*")) {
...
} else if (...) {
...
} else if ...
| // Looks like method name. | ||
| result.add("*." + g.toLowerCase()); | ||
| continue; | ||
| } else if (g.equals(g.toLowerCase())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never reached because of the above condition?
In order to avoid confusion with a method name consisting of all lowercase letters, I'd check against allDirs instead.
| rts.add(new RunnableTest(m, t)); | ||
| } | ||
| } | ||
| return rts.stream(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: returning List here and calling stream() in the context of the stream below will look a bit nicer.
| import java.util.logging.Logger; | ||
| import java.util.regex.Pattern; | ||
| import java.util.stream.Collectors; | ||
| import java.util.stream.Stream; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import
| // All available directories. | ||
| List<String> testDirectories = new ArrayList<>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dead code
| String name = m.getDeclaringClass().getSimpleName() + '.' + m.getName(); | ||
|
|
||
| if (includeGlobs.isEmpty() || includeGlobs.stream().anyMatch(f -> f.matcher(name).matches())) { | ||
| return skipGlobs.isEmpty() || !skipGlobs.stream().anyMatch(f -> f.matcher(name).matches()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
!anyMatch -> noneMatch
| result.add(g); | ||
| } else if (Character.isUpperCase(g.charAt(0))) { | ||
| // Looks like class name. | ||
| result.add(g.substring(0, 1).toUpperCase() + g.substring(1).toLowerCase() + ".*"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the purpose of these case manipulations?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was a misunderstanding, correcting to result.add(g + ".*");.
| result.add(g.substring(0, 1).toUpperCase() + g.substring(1).toLowerCase() + ".*"); | ||
| } else if (Character.isLowerCase(g.charAt(0))) { | ||
| // Looks like method name. | ||
| result.add("*." + g.toLowerCase()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we convert it to lowercase? AFAICS, we use case sensitive patterns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was a misunderstanding, correcting to result.add("*." + g);.
|
Unrelated failure:
|
Signed-off-by: Andrei Pangin <1749416+apangin@users.noreply.github.com>
Description
Test filter format is controlled by makefile variables
TESTSandSKIP. Both are case-sensitive and accept the format and rules as:TESTS
test/test, include all tests in the specified directory.make test-java TESTS=allocfilter. Supported filter types are:CstackTests.vmStructsCstac**Structs*vm*<ClassName>.<methodName>.make test-java TESTS='*alloc* CstackTests.vmStructs'make test-java TESTS='*alloc*,CstackTests.vmStructs'make test-java TESTS=alloc,CstackTests.vmStructsmatches all tests inallocfolder and any other test matching exactlyCstackTests.vmStructs.make test-java TESTS='*Calloc*'matches all tests withCallocin their<ClassName>.<methodName>.SKIP
Skip does not accept directory names. Exclusion is via glob matching the
<ClassName>.<methodName>as inTESTS:make test-java TESTS='*Calloc*' SKIP="*Tra*":NativememTests.canAsprofTraceMallocCallocNativememTests.canAgentFilterMallocCallocmake test-java TESTS='*Calloc*' SKIP="*Tra* nativemem":nativememis considered an exact match and not a directory name.make test-java SKIP='*lloc*'llocin their<ClassName>.<methodName>.How has this been tested?
Use
LOG_LEVEL=FINEto log parsed test declaration and filters:By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.