KEMBAR78
Add test name filtering to the java test runner by krk · Pull Request #1129 · async-profiler/async-profiler · GitHub
Skip to content

Conversation

krk
Copy link
Contributor

@krk krk commented Feb 7, 2025

Description

Test filter format is controlled by makefile variables TESTS and SKIP. Both are case-sensitive and accept the format and rules as:

TESTS

  1. If the argument exactly specifies an existing directory under test/test, include all tests in the specified directory.
    1. make test-java TESTS=alloc
  2. If the argument is not an existing directory name, it is a filter. Supported filter types are:
    1. Exact match: CstackTests.vmStructs
    2. Starts with: Cstac*
    3. Ends with: *Structs
    4. Contains: *vm*
  3. Filters are matched on <ClassName>.<methodName>.
  4. Multiple arguments can be specified with space or comma.
    1. make test-java TESTS='*alloc* CstackTests.vmStructs'
    2. make test-java TESTS='*alloc*,CstackTests.vmStructs'
  5. If a directory and a filter is specifies at the same time, tests in directories are included regardless of the filter. Filter applies to tests in other directories.
    1. make test-java TESTS=alloc,CstackTests.vmStructs matches all tests in alloc folder and any other test matching exactly CstackTests.vmStructs.
  6. If only filter is specified and not directory, it applies to all tests.
    1. make test-java TESTS='*Calloc*' matches all tests with Calloc in their <ClassName>.<methodName>.

SKIP

Skip does not accept directory names. Exclusion is via glob matching the <ClassName>.<methodName> as in TESTS:

  1. make test-java TESTS='*Calloc*' SKIP="*Tra*":
    1. Skips NativememTests.canAsprofTraceMallocCalloc
    2. Executes NativememTests.canAgentFilterMallocCalloc
  2. make test-java TESTS='*Calloc*' SKIP="*Tra* nativemem":
    1. nativemem is considered an exact match and not a directory name.
    2. Same set of tests run as above.
  3. make test-java SKIP='*lloc*'
    1. Skip all tests containing lloc in their <ClassName>.<methodName>.

How has this been tested?

make test-java TESTS=calloc
make test-java TESTS=nativemem
make test-java TESTS=CstackTests.vmStructs
make test-java TESTS='malloc CstackTests.vmStructs'
make test-java TESTS='*alloc* CstackTests.vmStructs'
make test-java TESTS=JfrTests
make test-java TESTS=jfrNoLeaks

# Run all nativemem and jfr tests that ends with the string 'Calloc' in their name:
make test-java TESTS='nativemem jfr *Calloc'

make test-java SKIP='*lloc*'

Use LOG_LEVEL=FINE to log parsed test declaration and filters:

make test-java TESTS='alloc *Malloc CstackTests.vmStructs' SKIP='*vm*' LOG_LEVEL=FINE
...
FINE: Selected directories: [alloc]
FINE: Test Filters: [*Malloc, CstackTests.vmStructs]
FINE: Skip Filters: [*vm*]
...

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@apangin
Copy link
Member

apangin commented Feb 7, 2025

There are already multiple ways to enable/disable tests: TESTS and SKIP variables and enabled annotation parameter. Introducing one more would only increase confusion. It's better to have a uniform way to enable/disable tests basing on a test name (which includes directory, class name and method name).

Here is my proposal, but I'm open to other suggestions:

  1. TESTS and SKIP should follow the same syntax. Runner will find all tests that match TESTS and do not match SKIP selectors.
  2. Either varible allows to specify multiple test selectors separated by a comma or whitespace.
  3. Selector may consist of a directory name (cpu, nativemem), simple class name (CpuTests, NativememTests), method name (jfrNoFree) or a combination of simple class name and method (NativememTests.jfrNoFree).
  4. Optionally, method names may include wildcards (jfr* or *MallocCalloc), but this is not necessary.

This will be intuitive, simple to use, and backward compatible.

@krk
Copy link
Contributor Author

krk commented Feb 7, 2025

If only a single test is required to run during development, enabled will not cut it, as we may have to add enabled = false to all "other" test cases.

We could reintroduce the only = true from a previous cancelled PR. When only = true is present, only runs the tests with that attribute.

@apangin
Copy link
Member

apangin commented Feb 7, 2025

Right, enabled is for different purpose, let's leave it aside.
I think the best way to run a single test would be make test TESTS=SomeClass.singleMethod

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)
Copy link
Member

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.

private static RunnerDeclaration parseRunnerDeclaration(String[] args) {
List<String> testNames = new ArrayList<>();
List<String> testDirectories = new ArrayList<>();
List<String> testFilters = new ArrayList<>();
Copy link
Member

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).

  1. 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.
  2. 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.
  3. Otherwise, if it contains a dot, it's a class name with a method name.

Copy link
Contributor Author

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.

for (int i = 0; i < args.length; i++) {
String arg = args[i];

if(arg.indexOf('.') >= 0 && !Character.isLowerCase(arg.charAt(0))) {
Copy link
Member

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()));
Copy link
Member

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.

Copy link
Contributor Author

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.

@krk
Copy link
Contributor Author

krk commented Feb 13, 2025

updated PR description with the latest changes.

}
}

class StartsWithFilter extends Filter {
Copy link
Member

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.

for (int i = 0; i < args.length; i++) {
String arg = args[i];

if (arg.indexOf('.') >= 0 && !Character.isLowerCase(arg.charAt(0))) {
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, simplified and refactored.


// 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.
Copy link
Member

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".

}
}

if (testDirectories.isEmpty()) {
Copy link
Member

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?

@apangin
Copy link
Member

apangin commented Feb 20, 2025

One more proposal: convert , to when passing TESTS list to the command line.
It is easier to specify make test TESTS=cpu,alloc instead of make test TESTS='cpu alloc'

@krk
Copy link
Contributor Author

krk commented Mar 24, 2025

Simplified and refactored. Added SKIP support with the glob format, resolved merge conflicts, updated PR desc.

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))
Copy link
Member

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 *

Copy link
Contributor Author

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.


import java.util.regex.Pattern;

public abstract class Filter {
Copy link
Member

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.

for (int i = 0; i < args.length; i++) {
String arg = args[i];
File f = new File("test/test/" + arg);
if (!f.exists() || !f.isDirectory()) {
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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:

  1. Obtain a set of test directories by listing test/test/.
  2. Parse test specifiers passed in command line arguments, converting each one to Pattern.
  3. Similarly, parse skip definition to get a list of Patterns to be skipped.
  4. Use reflection to find all runnable tests from directories obtained at (1).
  5. 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?

Comment on lines 30 to 33
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());
Copy link
Member

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:

  1. glob is a test dir => GlobTests.*
  2. glob looks like a class name => Glob.*
  3. glob looks like a method name => *.glob
  4. 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());
Copy link
Member

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.*");
Copy link
Member

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())) {
Copy link
Member

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();
Copy link
Member

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import

Comment on lines 40 to 41
// All available directories.
List<String> testDirectories = new ArrayList<>();
Copy link
Member

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());
Copy link
Member

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() + ".*");
Copy link
Member

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?

Copy link
Contributor Author

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());
Copy link
Member

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.

Copy link
Contributor Author

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);.

@krk
Copy link
Contributor Author

krk commented Apr 28, 2025

Unrelated failure:

INFO: Running WallTests.cpuWall...
FAIL [71/71] WallTests.cpuWall took 8.953 s
INFO: Test output and profiles are available in build/test/logs directory
java.lang.AssertionError
Error: Exception in thread "main" java.lang.RuntimeException: One or more tests failed
at test.wall.WallTests.cpuWall(WallTests.java:27)
at one.profiler.test.Runner.main(Runner.java:211)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
make: *** [test-java] Error 1
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at one.profiler.test.Runner.run(Runner.java:128)
at one.profiler.test.Runner.main(Runner.java:188)

krk and others added 2 commits April 28, 2025 10:37
Signed-off-by: Andrei Pangin <1749416+apangin@users.noreply.github.com>
@apangin apangin merged commit dbcd94f into async-profiler:master Apr 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants