Skip to content
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

Support Cartesian products of environments in MultiEnvTestEngine #7816

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7620b94
Support cartesian products of environments in MultiEnvTestEngine
colan-dremio Dec 5, 2023
554dfab
Update tests to assert on entire UniqueIds
colan-dremio Dec 6, 2023
e0a8dd3
Merge remote-tracking branch 'upstream/main' into cartesian-multienv
colan-dremio Dec 6, 2023
db22c92
Merge remote-tracking branch 'upstream/main' into cartesian-multienv
colan-dremio Dec 8, 2023
428728a
Remove second registry
colan-dremio Dec 8, 2023
e004be1
Support ordering of extensions
colan-dremio Dec 8, 2023
5faf681
Clear registry between tests
colan-dremio Dec 8, 2023
870d25a
Use separate registry in MultiEnvTestFilter
colan-dremio Dec 8, 2023
5cb8b1b
Re-add tooManyExtensions()
colan-dremio Dec 8, 2023
e8131d4
Clear filter registry between tests too
colan-dremio Dec 8, 2023
67bc7f2
Merge remote-tracking branch 'upstream/main' into cartesian-multienv
colan-dremio May 14, 2024
ab0fd2b
Clear registries between Nessie compat tests
colan-dremio Dec 8, 2023
3df881b
Add missing assert
colan-dremio Dec 8, 2023
db1a3a6
spotless apply
colan-dremio May 14, 2024
5104a20
Call clear from registry constructor
colan-dremio May 14, 2024
732fc44
Rename getOrder to segmentPriority
colan-dremio May 14, 2024
379935f
clarify filter logic
colan-dremio May 14, 2024
577685a
Add environment names to test display names
colan-dremio May 15, 2024
b9c229d
update segmentPriority docs
colan-dremio May 15, 2024
f4e11e4
Reorder extensions to better exercise ordering test
colan-dremio May 16, 2024
a1abab1
Expand cartesian product test
colan-dremio May 17, 2024
a8ce700
Do calculation of partial cartesian product up front in the engine
colan-dremio May 21, 2024
d6fef14
environmentNames -> environmentIds
colan-dremio May 21, 2024
95e8590
Remove filter's dependence on registry
colan-dremio May 21, 2024
915fe05
Remove unnecessary field
colan-dremio May 22, 2024
3439176
spotlessApply
colan-dremio May 22, 2024
1d6166e
Merge remote-tracking branch 'upstream/main' into cartesian-multienv
colan-dremio May 22, 2024
fafab6d
environmentIds -> environment
colan-dremio May 22, 2024
67106da
Add support for TestTemplateTestDescriptor
colan-dremio May 22, 2024
c32b55b
paragraph
colan-dremio May 23, 2024
4a8af55
immutables
colan-dremio May 23, 2024
c7799d0
unused method
colan-dremio May 23, 2024
50b3141
UniqueId selection tests
colan-dremio May 24, 2024
eea4d5d
Switch back to DiscoverySelectorResolver, add post filtering so it do…
colan-dremio May 24, 2024
3ff67f4
Add complex nesting tests, some failing
colan-dremio May 23, 2024
40169b4
Re-use nested find in registry
colan-dremio May 24, 2024
b695dee
fixup
colan-dremio May 24, 2024
4a58d99
Merge remote-tracking branch 'upstream/main' into cartesian-multienv
colan-dremio Jun 3, 2024
3febfbf
remove duplicate annotations
colan-dremio Jun 3, 2024
f0219e1
update new extension
colan-dremio Jun 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.assertj.core.api.SoftAssertions;
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
Expand Down Expand Up @@ -66,6 +67,12 @@ private void assertNoFailedTestEvents(EngineExecutionResults result) {
.isEqualTo(0);
}

@BeforeEach
public void beforeEach() {
MultiEnvTestEngine.clearRegistry();
MultiEnvTestFilter.clear();
}

@Test
void noVersions() {
soft.assertThatThrownBy(
Expand All @@ -76,8 +83,7 @@ void noVersions() {
.hasMessageContaining("TestEngine with ID 'nessie-multi-env' failed to discover tests")
.cause()
.hasMessageContaining(
"MultiEnvTestEngine was enabled, but test extensions did not discover any environment IDs")
.hasMessageContaining("OlderNessieClientsExtension");
"MultiEnvTestEngine was enabled, but test extensions [OlderNessieClientsExtension] did not discover any environment IDs.");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,28 @@
public class MultiEnvDisplayNameGenerator implements DisplayNameGenerator {

private final DisplayNameGenerator delegate;
private final String environment;
private final String environmentNames;

public MultiEnvDisplayNameGenerator(DisplayNameGenerator delegate, String environment) {
public MultiEnvDisplayNameGenerator(DisplayNameGenerator delegate, String environmentNames) {
this.delegate = delegate;
this.environment = environment;
this.environmentNames = environmentNames;
Copy link
Member

Choose a reason for hiding this comment

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

why rename? The String is opaque in this context, does not convey anything about names, IMHO.

Copy link
Author

Choose a reason for hiding this comment

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

On review, I meant environmentIds not names, updated accordingly. The goal was to give a bit of clarity into what the value holds. If we want to treat it as opaque, I can rename to suffix instead.


More generally, I've found that there is some ambiguity in naming. This is a multiple environment test engine, what is an environment? Take the example OlderNessieServersExtension (S1, S2) and OlderNessieClientsExtension (C1) which will run:

  • S1, C1
  • S2, C1

There are a number of concepts that need names:

  1. Each multienv extension (e.g. OlderNessieServersExtension)
  2. Each segement type (e.g. nessie-server-version)
  3. Each "environment id" within an extension (e.g. S1 and S2 individually)
  4. Each unique combination of environment ids on a test execution (e.g. [S1, C1])

If we go from the name here, it seems #4 is meant to be "environment"

Possible names to make this work:

  1. Dimension
  2. Dimension type
  3. Dimension element
  4. Environment

e.g.

public interface MultiEnvTestExtension extends Extension {
  String dimensionType();
  List<String> allDimensionElements(ConfigurationParameters configuration);
  default int dimensionPriority() {
    return 0;
  }
}

Thoughts?

Copy link
Member

@dimas-b dimas-b May 22, 2024

Choose a reason for hiding this comment

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

This particular class is meant to make multi-env testa distinguishable in tools that do not understand Junit5 UniqueId (i.e. those that deal with test class/method names only). environmentIds works in this case, but I still think the rename is unnecessary in this case as the old environment works just as well :)

Copy link
Member

Choose a reason for hiding this comment

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

1. Dimention - SGTM (but let's wait a bit with renaming :) )
2. Dimention type - I prefer segment (current) as it related to JUnit5 ID segments.
3. Dimention elements - Maybe Category? (in the statistical sense)

Copy link
Author

Choose a reason for hiding this comment

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

Keeping environment as # 4 is fine with me. I don't think "category" should be used for elements, "category" is synonymous with dimension type / segment type. Also, I don't think we should use "segment" alone because a UniqueId segment is the pair of segment type + segment value.

It looks like we should choose

  • One of (dimension | segment | category) for # 2
  • Whether or not to use "type" as a suffix for # 2. I'd avoid "segment" alone, but could see "category" or "category type" working fine.
  • One of (value | element | ID) for # 3

My vote at the moment is "dimension", "type", and "value":

public @interface MultiEnvDimensionType {}
public interface MultiEnvTestExtension extends Extension {
  List<String> allDimensionValues(ConfigurationParameters configuration);
  int segmentPriority()
}

For now, I've reverted these back to environment and will see what the rename would look like in practice.

Copy link
Author

Choose a reason for hiding this comment

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

Proposed naming: colan-dremio@e4625b7

There are some subtle advantages here. For example in this block, it can be ambiguous about whether currentSegmentTypes/Values is referring to MultiEnv segments or all JUnit segments (engine, class, nested-class, method too). I had considered currentMultiEnvSegmentTypes/Values to clarify this. Now that it is currentDimensionTypes/Values, the ambiguity is also resolved.

}

@Override
public String generateDisplayNameForClass(Class<?> testClass) {
return delegate.generateDisplayNameForClass(testClass) + " [" + environment + "]";
return delegate.generateDisplayNameForClass(testClass) + " [" + environmentNames + "]";
}

@Override
public String generateDisplayNameForNestedClass(Class<?> nestedClass) {
return delegate.generateDisplayNameForNestedClass(nestedClass) + " [" + environment + "]";
return delegate.generateDisplayNameForNestedClass(nestedClass) + " [" + environmentNames + "]";
}

@Override
public String generateDisplayNameForMethod(Class<?> testClass, Method testMethod) {
return delegate.generateDisplayNameForMethod(testClass, testMethod) + " [" + environment + "]";
return delegate.generateDisplayNameForMethod(testClass, testMethod)
+ " ["
+ environmentNames
+ "]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@
* execution.
*/
public class MultiEnvExtensionRegistry {
private final MutableExtensionRegistry registry;
private MutableExtensionRegistry registry;

public MultiEnvExtensionRegistry() {
this.registry =
MutableExtensionRegistry.createRegistryWithDefaultExtensions(
new DefaultJupiterConfiguration(new EmptyConfigurationParameters()));
clear();
}

public void registerExtensions(Class<?> testClass) {
Expand Down Expand Up @@ -66,4 +64,10 @@ public Stream<? extends MultiEnvTestExtension> stream(Class<?> testClass) {
.flatMap(registry::stream);
return r;
}

public void clear() {
registry =
MutableExtensionRegistry.createRegistryWithDefaultExtensions(
new DefaultJupiterConfiguration(new EmptyConfigurationParameters()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@

public class MultiEnvJupiterConfiguration extends DefaultJupiterConfiguration {

private final String environment;
private final String environmentNames;

public MultiEnvJupiterConfiguration(
ConfigurationParameters configurationParameters, String environment) {
ConfigurationParameters configurationParameters, String environmentNames) {
super(configurationParameters);
this.environment = environment;
this.environmentNames = environmentNames;
colan-dremio marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public DisplayNameGenerator getDefaultDisplayNameGenerator() {
DisplayNameGenerator delegate = super.getDefaultDisplayNameGenerator();
return new MultiEnvDisplayNameGenerator(delegate, environment);
return new MultiEnvDisplayNameGenerator(delegate, environmentNames);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2023 Dremio
*
* 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 org.projectnessie.junit.engine;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.junit.jupiter.engine.config.CachingJupiterConfiguration;
import org.junit.jupiter.engine.config.JupiterConfiguration;
import org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor;
import org.junit.jupiter.engine.discovery.DiscoverySelectorResolver;
import org.junit.platform.engine.ConfigurationParameters;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.UniqueId.Segment;

public class MultiEnvTestDescriptorTree {

private static final boolean FAIL_ON_MISSING_ENVIRONMENTS =
!Boolean.getBoolean("org.projectnessie.junit.engine.ignore-empty-environments");

private final TestDescriptor rootNode;
private final ConfigurationParameters configurationParameters;
private final AtomicBoolean foundAtLeastOneEnvironmentId = new AtomicBoolean();
private final List<String> processedExtensionNames = new ArrayList<>();
private List<TestDescriptor> latestLeafNodes;

public MultiEnvTestDescriptorTree(
TestDescriptor rootNode, ConfigurationParameters configurationParameters) {
this.rootNode = rootNode;
this.latestLeafNodes = Collections.singletonList(rootNode);
this.configurationParameters = configurationParameters;
}

public void appendDescriptorsForExtension(MultiEnvTestExtension multiEnvTestExtension) {
processedExtensionNames.add(multiEnvTestExtension.getClass().getSimpleName());

List<TestDescriptor> newLeafNodes = new ArrayList<>();
for (TestDescriptor parentNode : latestLeafNodes) {
for (String environmentId :
multiEnvTestExtension.allEnvironmentIds(configurationParameters)) {
Copy link
Member

Choose a reason for hiding this comment

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

TBH, I find this method of constructing a Cartesian product of environments a bit obscure. Why not simply have nested loops in one method (without calling out to this helper class)?

Copy link
Author

Choose a reason for hiding this comment

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

I've moved the tree construction logic directly into the engine implementation as part of fixing the updated cartesian test. The main annoyance is that the tree of nodes (represented by recursive child references) sits independently of the UniqueIds of those nodes. My new implementation still maintains a cache of known nodes to keep lookup simple, but should be simpler overall.

foundAtLeastOneEnvironmentId.set(true);
UniqueId newNodeId =
parentNode.getUniqueId().append(multiEnvTestExtension.segmentType(), environmentId);
MultiEnvTestDescriptor newNode = new MultiEnvTestDescriptor(newNodeId, environmentId);
parentNode.addChild(newNode);
newLeafNodes.add(newNode);
}
}

latestLeafNodes = newLeafNodes;
}

public void addOriginalChildrenToFinishedTree(EngineDiscoveryRequest discoveryRequest) {
final String rootNodeSegmentValue = rootNode.getUniqueId().getSegments().get(0).getValue();

for (TestDescriptor leafNode : latestLeafNodes) {
String environmentNames =
leafNode.getUniqueId().getSegments().stream()
.map(Segment::getValue)
.filter(Predicate.not(rootNodeSegmentValue::equals))
.collect(Collectors.joining(","));
JupiterConfiguration nodeConfiguration =
new CachingJupiterConfiguration(
new MultiEnvJupiterConfiguration(configurationParameters, environmentNames));
JupiterEngineDescriptor discoverResult =
new JupiterEngineDescriptor(leafNode.getUniqueId(), nodeConfiguration);
new DiscoverySelectorResolver().resolveSelectors(discoveryRequest, discoverResult);
for (TestDescriptor childWithProperId : discoverResult.getChildren()) {
leafNode.addChild(childWithProperId);
}
}
}

public TestDescriptor getRootNode() {
if (FAIL_ON_MISSING_ENVIRONMENTS
&& !processedExtensionNames.isEmpty()
&& !foundAtLeastOneEnvironmentId.get()) {
throw new IllegalStateException(
String.format(
"%s was enabled, but test extensions %s did not discover any environment IDs.",
MultiEnvTestEngine.class.getSimpleName(),
Arrays.toString(processedExtensionNames.toArray())));
}

return rootNode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,11 @@
package org.projectnessie.junit.engine;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.engine.JupiterTestEngine;
import org.junit.jupiter.engine.config.CachingJupiterConfiguration;
import org.junit.jupiter.engine.config.DefaultJupiterConfiguration;
import org.junit.jupiter.engine.config.JupiterConfiguration;
import org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor;
import org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor;
import org.junit.jupiter.engine.discovery.DiscoverySelectorResolver;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.ExecutionRequest;
import org.junit.platform.engine.TestDescriptor;
Expand All @@ -38,25 +33,41 @@
* This is a JUnit5 Test Engine that delegates test discovery to {@link JupiterTestEngine} and
* replicates the discovered tests for execution in multiple test environments.
*
* <p>When multiple {@link MultiEnvTestExtension}s are applied to the same test, performs a
* cartesian product of available environments and their IDs.
*
* <p>For example:
*
* <ul>
* <li>MultiEnvTestExtension segmentA with IDs 1, 2, 3
* <li>MultiEnvTestExtension segmentB with IDs 1, 2
* <li>MultiEnvTestExtension segmentC with ID 1
* </ul>
*
* will result in the following IDs:
*
* <ul>
* <li>[engine:nessie-multi-env][segmentA:1][segmentB:1][segmentC:1]
* <li>[engine:nessie-multi-env][segmentA:1][segmentB:2][segmentC:1]
* <li>[engine:nessie-multi-env][segmentA:2][segmentB:1][segmentC:1]
* <li>[engine:nessie-multi-env][segmentA:2][segmentB:2][segmentC:1]
* <li>[engine:nessie-multi-env][segmentA:3][segmentB:1][segmentC:1]
* <li>[engine:nessie-multi-env][segmentA:3][segmentB:2][segmentC:1]
* </ul>
*
* <p>Actual test environments are expected to be managed by JUnit 5 extensions, implementing the
* {@link MultiEnvTestExtension} interface.
*/
public class MultiEnvTestEngine implements TestEngine {

private static final Logger LOGGER = LoggerFactory.getLogger(MultiEnvTestEngine.class);
private static final boolean FAIL_ON_MISSING_ENVIRONMENTS =
!Boolean.getBoolean("org.projectnessie.junit.engine.ignore-empty-environments");

public static final String ENGINE_ID = "nessie-multi-env";

private static final MultiEnvExtensionRegistry registry = new MultiEnvExtensionRegistry();

private final JupiterTestEngine delegate = new JupiterTestEngine();

static MultiEnvExtensionRegistry registry() {
return registry;
}

@Override
public String getId() {
return ENGINE_ID;
Expand All @@ -70,64 +81,36 @@ public void execute(ExecutionRequest request) {
@Override
public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId uniqueId) {
try {
TestDescriptor originalRoot = delegate.discover(discoveryRequest, uniqueId);
List<TestDescriptor> originalChildren = new ArrayList<>(originalRoot.getChildren());

// Scan for multi-env test extensions
TestDescriptor preliminaryResult = delegate.discover(discoveryRequest, uniqueId);
preliminaryResult.accept(
originalRoot.accept(
descriptor -> {
if (descriptor instanceof ClassBasedTestDescriptor) {
Class<?> testClass = ((ClassBasedTestDescriptor) descriptor).getTestClass();
registry.registerExtensions(testClass);
}
});

// JupiterEngineDescriptor must be the root, that's what the JUnit Jupiter engine
// implementation expects.
JupiterEngineDescriptor multiEnvDescriptor =
new JupiterEngineDescriptor(
uniqueId,
new DefaultJupiterConfiguration(discoveryRequest.getConfigurationParameters()));
// Note: this also removes the reference to parent from the child
originalChildren.forEach(originalRoot::removeChild);

List<String> extensions = new ArrayList<>();
AtomicBoolean envDiscovered = new AtomicBoolean();
// Append each extension's IDs in a new, nested, layer
MultiEnvTestDescriptorTree tree =
new MultiEnvTestDescriptorTree(
originalRoot, discoveryRequest.getConfigurationParameters());
registry.stream()
.forEach(
ext -> {
extensions.add(ext.getClass().getSimpleName());
for (String envId :
ext.allEnvironmentIds(discoveryRequest.getConfigurationParameters())) {
envDiscovered.set(true);
UniqueId segment = uniqueId.append(ext.segmentType(), envId);

MultiEnvTestDescriptor envRoot = new MultiEnvTestDescriptor(segment, envId);
multiEnvDescriptor.addChild(envRoot);

JupiterConfiguration envRootConfiguration =
new CachingJupiterConfiguration(
new MultiEnvJupiterConfiguration(
discoveryRequest.getConfigurationParameters(), envId));
JupiterEngineDescriptor discoverResult =
new JupiterEngineDescriptor(segment, envRootConfiguration);
new DiscoverySelectorResolver()
.resolveSelectors(discoveryRequest, discoverResult);

List<? extends TestDescriptor> children =
new ArrayList<>(discoverResult.getChildren());
for (TestDescriptor child : children) {
// Note: this also removes the reference to parent from the child
discoverResult.removeChild(child);
envRoot.addChild(child);
}
}
});

if (!extensions.isEmpty() && !envDiscovered.get() && FAIL_ON_MISSING_ENVIRONMENTS) {
throw new IllegalStateException(
String.format(
"%s was enabled, but test extensions did not discover any environment IDs: %s",
getClass().getSimpleName(), extensions));
}
return multiEnvDescriptor;
.sorted(
Comparator.comparing(MultiEnvTestExtension::segmentPriority)
.reversed()
.thenComparing(MultiEnvTestExtension::segmentType))
.forEach(tree::appendDescriptorsForExtension);

// Migrate the actual tests from the root to each of the leaves of the new tree
tree.addOriginalChildrenToFinishedTree(discoveryRequest);

return tree.getRootNode();
} catch (Exception e) {
LOGGER.error("Failed to discover tests", e);
throw new RuntimeException(e);
Expand All @@ -141,6 +124,10 @@ public Optional<String> getGroupId() {

@Override
public Optional<String> getArtifactId() {
return Optional.of("nessie-compatibility-tools");
return Optional.of("nessie-multi-env-test-engine");
}

public static void clearRegistry() {
registry.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,13 @@ public interface MultiEnvTestExtension extends Extension {
* invocations of this method to ensure a stable test case creation order.
*/
List<String> allEnvironmentIds(ConfigurationParameters configuration);

/**
* Allows {@link MultiEnvTestExtension}s to define their relative ordering within a JUnit
* UniqueID's segments. Higher numbers will appear earlier in the JUnit UniqueId. Extensions with
* the same segment priority will be sorted alphabetically by segmentType.
*/
default int segmentPriority() {
return 0;
}
}
Loading
Loading