Repository: junit-team/junit5 (language: java)

Issue:
Support programmatic registration of resource locks
I have several test classes, each with multiple test methods inside. Some of those classes are injected with a parameter, and when running classes in parallel those classes should not run parallel. If I understand correctly, the `@ResourceLock` annotation can help here, but it would be very inconvenient to manually add that annotation to hundereds of tests. Ideally a extension could take a look at the class, see that it has this parameter and somehow add the ExclusiveResource to it.

My JUnit execution parameters:

```
-Djunit.jupiter.execution.parallel.enabled=true
-Djunit.jupiter.execution.parallel.mode.default=same_thread
-Djunit.jupiter.execution.parallel.mode.classes.default=concurrent
-Djunit.jupiter.execution.parallel.config.strategy=fixed
-Djunit.jupiter.execution.parallel.config.fixed.parallelism=8
```


Discussion / hints:
We could add a new extension point for this:

```java
interface ExclusiveResourceProvider extends Extension {
	default Set<ExclusiveResource> provideExclusiveResourcesForClass(Class<?> testClass, Set<ExclusiveResource> declaredResources) {
		return declaredResources;
	};
	default Set<ExclusiveResource> provideExclusiveResourcesForNestedClass(Class<?> nestedClass, Set<ExclusiveResource> declaredResources) {
		return declaredResources;
	};
	default Set<ExclusiveResource> provideExclusiveResourcesForMethod(Class<?> testClass, Method testMethod, Set<ExclusiveResource> declaredResources) {
		return declaredResources;
	};
}
interface ExclusiveResource {
	String getKey();
	ResourceAccessMode getAccessMode();
	static ExclusiveResource of(String key);
	static ExclusiveResource of(String key, ResourceAccessMode accessMode);
}

```
This feature might be quite useful for my [PR](https://github.com/junit-pioneer/junit-pioneer/pull/491) on junit-pioneer to allow "resources" - arbitrary objects - to be instantiated and shared amongst tests declaratively. 

Specifically, if each "resource" were to have its own `ResourceLock` that could be created programmatically, then it would simplify making my extension thread-safe.

The proposed API leaves me with one question, though: if my extension were to implement `ResourceLockProvider`, when would the `provideX` methods be called?
It would have to be called immediately before starting execution on the engine level.
Okay, thank you!

I wasn't sure at first if this would work for me, since my extension's resources are created at test runtime. But I think it could work now, as all shared resources in my extension are declared with `@Shared` annotations, which I imagine my potential `ResourceLockProvider` could search for reflectively.

So I'm happy with the proposed API. 👍 
**Team decision:** Let's go with the above proposal.

@U1F984 @jbduncan Is one of you interested in working on this?
@marcphilipp I'd love to! However I still have https://github.com/junit-pioneer/junit-pioneer/issues/348 to finish, so I'm happy for anyone else to pick this up before me.
For clarity, my JUnit Pioneer work is not actually blocked on this feature, as I've found an alternative solution to programmatic `ResourceLock`s for my needs.
@marcphilipp A follow-up query from me: do you know if JUnit Jupiter already ensures that `ResourceLock`s are obtained in such an order as to prevent the [dining philosophers problem](https://en.wikipedia.org/wiki/Dining_philosophers_problem), by any chance?

If not, then I, or whoever picks this up, will need to ensure the locks are sorted when being moved from `ResourceLockProvider`s to the rest of the test framework, by some definition of "sorted".
They are sorted here: https://github.com/junit-team/junit5/blob/6a47ae152003a6ac3e77f2e604159b71e4f82953/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/LockManager.java#L61
@marcphilipp Great, thank you for answering so quickly. 👍
@marcphilipp I'd love it if you assigned this to me, as I've managed to park my junit-pioneer work and find time to start looking at this.
@jbduncan Sorry it took 6 days, but you're now assigned.
Sorry for the silence on this! I was a bit lost looking through the JUnit 5 codebase when I looked at this issue last year. But now that my [JUnit Pioneer PR](https://github.com/junit-pioneer/junit-pioneer/pull/491) that would have used this feature is finally done, I'll see if I can find the time to revisit this issue later this week, and write down anything I need help with. :)
@marcphilipp I have a few follow-up questions which I was hoping you could help me with.

For the proposed `ExclusiveResourceProvider`,

1. Does a `provide*` method's `declaredResources` contain just the resources found so far for the given class/method/nested class, or does it contain _all_ resources found so far by JUnit?
2. Do the `declaredResources` contain resources found through `@ResourceLock`s?
    ```java
    @Test
    @ResourceLock(value = "a")
    @ExtendWith(MyExclusiveResourceProvider.class)
    void test() {
    }
    ```

3.  If so, does the ordering matter? As in, does `MyExclusiveResourceProvider`'s declared resources contain resource "a" when running with `test2`, but not with `test1`?
    ```java
    @Test
    @ExtendWith(MyExclusiveResourceProvider.class)
    @ResourceLock(value = "a")
    void test1() {
    }

    @Test
    @ResourceLock(value = "a")
    @ExtendWith(MyExclusiveResourceProvider.class)
    void test2() {
    }

    class MyExclusiveResourceProvider implements ExclusiveResourceProvider {
        @Override public Set<ExclusiveResource> provideExclusiveResourcesForMethod(Class<?> testClass, Method testMethod, Set<ExclusiveResource> declaredResources) {
            return declaredResources;
        }
    }
    ```
I've pushed my ongoing spike to https://github.com/junit-team/junit5/pull/3096 for the sake of transparency. :)
@marcphilipp I'm feeling a bit stuck now, and I was wondering if you could help me.

It looks like `NodeTreeWalker` is responsible for discovering tests and, most importantly, their `@ResourceLock`s. However, to get resource locks from `ExclusiveResourceProvider`s, JUnit would need to discover them as extensions, which are currently discovered by `NodeTestTask`s, which in turn depend on the `NodeTreeWalker`! So there's a cycle of dependencies (maybe even a web), and I can't see how to break it, so I was was wondering if you had any thoughts?

Alternatively, would you be up to discussing this remotely some time? If so, feel free to contact me on my GitHub email address.
@marcphilipp I don't suppose you've had the time to look at my last comment recently, have you? 🙂
@jbduncan Sorry, I had fallen quite behind in reading my GitHub notifications. 😬 

> It looks like `NodeTreeWalker` is responsible for discovering tests and, most importantly, their `@ResourceLock`s. However, to get resource locks from `ExclusiveResourceProvider`s, JUnit would need to discover them as extensions, which are currently discovered by `NodeTestTask`s, which in turn depend on the `NodeTreeWalker`!

I'm not sure I fully understand the question. `ExclusiveResourceProvider` can't be a regular extension since it needs to be applied at discovery time, i.e. it should not extend `Extension`. We already have a few of this kind of extension, e.g. `DisplayNameGenerator` and `MethodOrderer`. So I think we'll need a new annotation that goes along with `ExclusiveResourceProvider`, maybe `@ExclusiveResources(MyExclusiveResourceProvider.class)`?
> @jbduncan Sorry, I had fallen quite behind in reading my GitHub notifications. 😬

No worries! Likewise, sorry for not responding immediately. :)

> I'm not sure I fully understand the question. `ExclusiveResourceProvider` can't be a regular extension since it needs to be applied at discovery time, i.e. it should not extend `Extension`. We already have a few of this kind of extension, e.g. `DisplayNameGenerator` and `MethodOrderer`. So I think we'll need a new annotation that goes along with `ExclusiveResourceProvider`, maybe `@ExclusiveResources(MyExclusiveResourceProvider.class)`?

@marcphilipp Oh, I hadn't considered that. That sounds like it could break the dependency cycle.

But admittedly I've lost interest in making programmatic exclusive resources a reality, so I'll leave this issue and my [draft PR](https://github.com/junit-team/junit5/pull/3096) for someone else who in interested in picking things up.
No worries. Thanks for letting us know!
Hi @marcphilipp 

Me and my team interested in this feature. 
We have a lot of tests and would like to configure locking strategy without adding / removing `@ResourceLock ` annotation.

I've prepared a draft implementation of this functionality and would be very grateful if you take a look at it.
Please, tell if you're happy with a such approach (in such case I'll finalize the PR), or you have another approach in mind.
https://github.com/junit-team/junit5/pull/3889

Regards,
Vladimir
@VladimirDmitrienko Thanks for the draft PR! Could you please describe your use case in more detail? Is a class-level annotation sufficient or would you rather register such a provider "globally" for all tests?
@marcphilipp Thanks for a quick response!

We have lots of tests which interact with our application through different levels: UI, API, DB.
When we run our tests in parallel, sometimes they may "intersect" because a test or a group of tests may modify a common state used by other tests (a typical problem actually).

My initial thought was to use `@ExclusiveResources(MyExclusiveResourceProvider.class)` on the base test class and assign different locks right from `MyExclusiveResourceProvider`. 
In such case we would be able to add a necessary level of isolation between tests based on class / package names patterns. 
Making sure that **particular kinds / groups of tests are never executed at the same time, but still be executed in parallel with tests which not require locks.**

---

So, responding to your question, the current approach would be sufficient for our case, but I guess that a "global" `ExclusiveResourceProvider` may be an even better solution since it will not require users to have a "base" class for all the tests.
For such purpose we could introduce a new property, maybe something like:
```properties
junit.jupiter.execution.parallel.locks.provider = org.com.example.MyExclusiveResourceProvider
```

---

To summarize, currently I see three options to set `ExclusiveResourceProvider`:

**1.** A class-level `@ExclusiveResources` annotation.

**2.** `junit.jupiter.execution.parallel.locks.provider` property.

**3.** Combine both approaches to be able to override the property with an annotation.
Looks like the most flexible since fits both: 
- small-medium size projects which can benefit from having all the logic "in one place";
- big projects with huge test classes hierarchy: each `Base...Tests` class can have it's own `@ExclusiveResourceProvider`.

So, I'm looking forward to your feedback to continue to work on whichever approach we choose.
@marcphilipp maybe you had a chance to take a look at my last comment?

Also, I'd appreciate if you assign this issue on me 🙂 
I think we should go with a class-level annotation for now. We can always add a config parameter or `ServiceLoader`-based registration later. Let's call it `@ResourceLocks` and `ResourceLockProvider`, though, to stick to the terminology used in the Jupiter API.
@marcphilipp got your point.
I'm afraid `@ResourceLocks` could be a bit confusing since we already have such annotation. Don't you think?
You're right, I forgot about the container annotation. I think `@ResourceLockProvider` would be good but then I don't know what we'd call the interface. Any ideas?
What do you think about:
```java
@ResourceLocksSource(MyResourceLocksProvider.class)
```
The same approach is used in `@ArgumentsSource`.
I thought about that as well but concluded that it might be confusing since it's unrelated to the other source annotations. 
Fair enough.

There are two options coming in my mind.
1. What if we reuse existing `@ResourceLocks` annotation:
```java
public @interface ResourceLocks {

	Class<? extends ResourceLocksProvider> provider() default EmptyResourceLocksProvider.class;
        // or
	Class<? extends ResourceLocksProvider>[] providers() default {};
}
```
2. `@ResourceLocksFrom(MyResourceLocksProvider.class)`
> What if we reuse existing `@ResourceLocks` annotation

That would break backward compatibility.

> @ResourceLocksFrom(MyResourceLocksProvider.class)

Let's go with that. Renaming it as part of the PR should be easy enough if we come up with a better name.
Agreed 👍 
As always, naming is hard 😅

Just curious, what kind of compatibility will it break?
I supposed adding a new method will be both a source and binary compatible change.

<img width="1499" alt="image" src="https://github.com/user-attachments/assets/81d270a9-fb86-4efd-9fe0-6d61b7461d8d">

[link](https://wiki.eclipse.org/Evolving_Java-based_APIs_2 )
Did you mean adding another attribute to the container annotation? If so, you may be right but I still think it would be confusing to appropriate it in this way.
Got it, thank you 🙂 
Will keep you updated on my work.
Hi @marcphilipp 

I've updated the [PR](https://github.com/junit-team/junit5/pull/3889) and now it's ready for review.
Looking forward to your feedback.

Btw, I've set `API since` to `5.12` but actually I'm not really sure in which release it will go, please, let me know and I'll update `since` values and add release notes.
Hi @marcphilipp 
I would be happy to improve my [PR](https://github.com/junit-team/junit5/pull/3889) and waiting for a feedback from your side.
Maybe you have any at least approximate estimates of when could I expect that?
Would be glad to receive any news on this one.
Sorry for the delay! I was on vacation. I should be able to get to it this week. :crossed_fingers: 
@marcphilipp Thanks for the update 👍 
Reopening to track polishing work

Fix patch:
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
index ddfbe7ef3a38..6b3b524e6f55 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
@@ -74,7 +74,6 @@
 import org.junit.platform.engine.TestTag;
 import org.junit.platform.engine.UniqueId;
 import org.junit.platform.engine.support.descriptor.ClassSource;
-import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
 import org.junit.platform.engine.support.hierarchical.ThrowableCollector;
 
 /**
@@ -83,13 +82,14 @@
  * @since 5.5
  */
 @API(status = INTERNAL, since = "5.5")
-public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor {
+public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor implements ResourceLockAware {
 
 	private static final InterceptingExecutableInvoker executableInvoker = new InterceptingExecutableInvoker();
 
 	private final Class<?> testClass;
 	protected final Set<TestTag> tags;
 	protected final Lifecycle lifecycle;
+	private final ExclusiveResourceCollector exclusiveResourceCollector;
 
 	private ExecutionMode defaultChildExecutionMode;
 	private TestInstanceFactory testInstanceFactory;
@@ -104,6 +104,7 @@ public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor {
 		this.tags = getTags(testClass);
 		this.lifecycle = getTestInstanceLifecycle(testClass, configuration);
 		this.defaultChildExecutionMode = (this.lifecycle == Lifecycle.PER_CLASS ? ExecutionMode.SAME_THREAD : null);
+		this.exclusiveResourceCollector = ExclusiveResourceCollector.from(testClass);
 	}
 
 	// --- TestDescriptor ------------------------------------------------------
@@ -112,6 +113,8 @@ public final Class<?> getTestClass() {
 		return this.testClass;
 	}
 
+	public abstract List<Class<?>> getEnclosingTestClasses();
+
 	@Override
 	public Type getType() {
 		return Type.CONTAINER;
@@ -139,13 +142,8 @@ public void setDefaultChildExecutionMode(ExecutionMode defaultChildExecutionMode
 	}
 
 	@Override
-	public Set<ExclusiveResource> getExclusiveResources() {
-		// @formatter:off
-		return getExclusiveResourcesFromAnnotations(
-				getTestClass(),
-				provider -> provider.provideForClass(getTestClass())
-		);
-		// @formatter:on
+	public final ExclusiveResourceCollector getExclusiveResourceCollector() {
+		return exclusiveResourceCollector;
 	}
 
 	@Override
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java
index a7c3034a8f60..e644518fb57b 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java
@@ -10,16 +10,19 @@
 
 package org.junit.jupiter.engine.descriptor;
 
+import static java.util.Collections.emptyList;
 import static org.apiguardian.api.API.Status.INTERNAL;
 import static org.junit.jupiter.engine.descriptor.DisplayNameUtils.createDisplayNameSupplierForClass;
 
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
 import org.apiguardian.api.API;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.TestInstances;
+import org.junit.jupiter.api.parallel.ResourceLocksProvider;
 import org.junit.jupiter.engine.config.JupiterConfiguration;
 import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
 import org.junit.jupiter.engine.extension.ExtensionRegistrar;
@@ -57,6 +60,11 @@ public Set<TestTag> getTags() {
 		return new LinkedHashSet<>(this.tags);
 	}
 
+	@Override
+	public List<Class<?>> getEnclosingTestClasses() {
+		return emptyList();
+	}
+
 	// --- Node ----------------------------------------------------------------
 
 	@Override
@@ -72,4 +80,9 @@ protected TestInstances instantiateTestClass(JupiterEngineExecutionContext paren
 		return instantiateTestClass(Optional.empty(), registry, extensionContext);
 	}
 
+	@Override
+	public Set<ResourceLocksProvider.Lock> evaluateResourceLocksProvider(ResourceLocksProvider provider) {
+		return provider.provideForClass(getTestClass());
+	}
+
 }
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ExclusiveResourceCollector.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ExclusiveResourceCollector.java
new file mode 100644
index 000000000000..42b1295b2221
--- /dev/null
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ExclusiveResourceCollector.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2015-2024 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.jupiter.engine.descriptor;
+
+import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations;
+import static org.junit.platform.commons.util.CollectionUtils.toUnmodifiableList;
+
+import java.lang.reflect.AnnotatedElement;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.parallel.ResourceAccessMode;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.ResourceLocksProvider;
+import org.junit.platform.commons.JUnitException;
+import org.junit.platform.commons.util.ReflectionUtils;
+import org.junit.platform.commons.util.StringUtils;
+import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
+
+/**
+ * @since 5.12
+ */
+abstract class ExclusiveResourceCollector {
+
+	private static final ExclusiveResourceCollector NO_EXCLUSIVE_RESOURCES = new ExclusiveResourceCollector() {
+
+		@Override
+		Stream<ExclusiveResource> getAllExclusiveResources(
+				Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
+			return Stream.empty();
+		}
+
+		@Override
+		public Stream<ExclusiveResource> getStaticResources() {
+			return Stream.empty();
+		}
+
+		@Override
+		Stream<ExclusiveResource> getDynamicResources(
+				Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
+			return Stream.empty();
+		}
+	};
+
+	Stream<ExclusiveResource> getAllExclusiveResources(
+			Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
+		return Stream.concat(getStaticResources(), getDynamicResources(providerToLocks));
+	}
+
+	abstract Stream<ExclusiveResource> getStaticResources();
+
+	abstract Stream<ExclusiveResource> getDynamicResources(
+			Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks);
+
+	static ExclusiveResourceCollector from(AnnotatedElement element) {
+		List<ResourceLock> annotations = findRepeatableAnnotations(element, ResourceLock.class);
+		return annotations.isEmpty() ? NO_EXCLUSIVE_RESOURCES : new DefaultExclusiveResourceCollector(annotations);
+	}
+
+	private static class DefaultExclusiveResourceCollector extends ExclusiveResourceCollector {
+
+		private final List<ResourceLock> annotations;
+		private List<ResourceLocksProvider> providers;
+
+		DefaultExclusiveResourceCollector(List<ResourceLock> annotations) {
+			this.annotations = annotations;
+		}
+
+		@Override
+		public Stream<ExclusiveResource> getStaticResources() {
+			return annotations.stream() //
+					.filter(annotation -> StringUtils.isNotBlank(annotation.value())) //
+					.map(annotation -> new ExclusiveResource(annotation.value(), toLockMode(annotation.mode())));
+		}
+
+		@Override
+		Stream<ExclusiveResource> getDynamicResources(
+				Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
+			List<ResourceLocksProvider> providers = getProviders();
+			if (providers.isEmpty()) {
+				return Stream.empty();
+			}
+			return providers.stream() //
+					.map(providerToLocks) //
+					.flatMap(Collection::stream) //
+					.map(lock -> new ExclusiveResource(lock.getKey(), toLockMode(lock.getAccessMode())));
+		}
+
+		private List<ResourceLocksProvider> getProviders() {
+			if (this.providers == null) {
+				this.providers = annotations.stream() //
+						.flatMap(annotation -> Stream.of(annotation.providers()).map(ReflectionUtils::newInstance)) //
+						.collect(toUnmodifiableList());
+			}
+			return providers;
+		}
+
+		private static ExclusiveResource.LockMode toLockMode(ResourceAccessMode mode) {
+			switch (mode) {
+				case READ:
+					return ExclusiveResource.LockMode.READ;
+				case READ_WRITE:
+					return ExclusiveResource.LockMode.READ_WRITE;
+			}
+			throw new JUnitException("Unknown ResourceAccessMode: " + mode);
+		}
+	}
+}
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterTestDescriptor.java
index 54673dcb91d5..86fecaf73ddd 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterTestDescriptor.java
@@ -10,10 +10,9 @@
 
 package org.junit.jupiter.engine.descriptor;
 
-import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
 import static java.util.stream.Collectors.collectingAndThen;
 import static java.util.stream.Collectors.toCollection;
-import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.apiguardian.api.API.Status.INTERNAL;
 import static org.junit.jupiter.engine.descriptor.DisplayNameUtils.determineDisplayName;
@@ -21,25 +20,18 @@
 import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations;
 
 import java.lang.reflect.AnnotatedElement;
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.function.Supplier;
-import java.util.stream.Stream;
 
 import org.apiguardian.api.API;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.extension.ConditionEvaluationResult;
 import org.junit.jupiter.api.extension.Extension;
 import org.junit.jupiter.api.parallel.Execution;
-import org.junit.jupiter.api.parallel.ResourceAccessMode;
-import org.junit.jupiter.api.parallel.ResourceLock;
-import org.junit.jupiter.api.parallel.ResourceLocksProvider;
 import org.junit.jupiter.engine.config.JupiterConfiguration;
 import org.junit.jupiter.engine.execution.ConditionEvaluator;
 import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
@@ -48,8 +40,6 @@
 import org.junit.platform.commons.logging.Logger;
 import org.junit.platform.commons.logging.LoggerFactory;
 import org.junit.platform.commons.util.ExceptionUtils;
-import org.junit.platform.commons.util.ReflectionUtils;
-import org.junit.platform.commons.util.StringUtils;
 import org.junit.platform.commons.util.UnrecoverableExceptions;
 import org.junit.platform.engine.TestDescriptor;
 import org.junit.platform.engine.TestSource;
@@ -57,7 +47,6 @@
 import org.junit.platform.engine.UniqueId;
 import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
 import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
-import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode;
 import org.junit.platform.engine.support.hierarchical.Node;
 
 /**
@@ -109,17 +98,6 @@ static Set<TestTag> getTags(AnnotatedElement element) {
 		// @formatter:on
 	}
 
-	public List<Class<?>> getEnclosingTestClasses() {
-		TestDescriptor parent = getParent().orElse(null);
-		if (parent instanceof ClassBasedTestDescriptor) {
-			ClassBasedTestDescriptor parentClassDescriptor = (ClassBasedTestDescriptor) parent;
-			List<Class<?>> result = new ArrayList<>(parentClassDescriptor.getEnclosingTestClasses());
-			result.add(parentClassDescriptor.getTestClass());
-			return result;
-		}
-		return emptyList();
-	}
-
 	/**
 	 * Invoke exception handlers for the supplied {@code Throwable} one-by-one
 	 * until none are left or the throwable to handle has been swallowed.
@@ -200,57 +178,12 @@ public static ExecutionMode toExecutionMode(org.junit.jupiter.api.parallel.Execu
 		throw new JUnitException("Unknown ExecutionMode: " + mode);
 	}
 
-	Set<ExclusiveResource> getExclusiveResourcesFromAnnotations(AnnotatedElement element,
-			Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
-		// @formatter:off
-		List<ResourceLock> ownAnnotations = findRepeatableAnnotations(element, ResourceLock.class);
-		List<ResourceLock> enclosingClassesAnnotations = getEnclosingTestClasses().stream()
-				.map(clazz -> findRepeatableAnnotations(clazz, ResourceLock.class))
-				.flatMap(Collection::stream)
-				.collect(toList());
-
-		return Stream.of(
-				getExclusiveResourcesFromValues(ownAnnotations),
-				getExclusiveResourcesFromProviders(ownAnnotations, providerToLocks),
-				getExclusiveResourcesFromProviders(enclosingClassesAnnotations, providerToLocks)
-		).flatMap(s -> s)
-		.collect(toSet());
-		// @formatter:on
-	}
-
-	private Stream<ExclusiveResource> getExclusiveResourcesFromValues(List<ResourceLock> annotations) {
-		// @formatter:off
-		return annotations.stream()
-				.flatMap(annotation -> {
-					if (StringUtils.isBlank(annotation.value())) {
-						return Stream.empty();
-					}
-					return Stream.of(new ExclusiveResource(annotation.value(), toLockMode(annotation.mode())));
-				});
-		// @formatter:on
-	}
-
-	private Stream<ExclusiveResource> getExclusiveResourcesFromProviders(List<ResourceLock> annotations,
-			Function<ResourceLocksProvider, Set<ResourceLocksProvider.Lock>> providerToLocks) {
-		// @formatter:off
-		return annotations.stream()
-				.flatMap(annotation -> Stream.of(annotation.providers())
-						.map(ReflectionUtils::newInstance)
-						.map(providerToLocks)
-						.flatMap(Collection::stream)
-						.map(lock -> new ExclusiveResource(lock.getKey(), toLockMode(lock.getAccessMode())))
-				);
-		// @formatter:on
-	}
-
-	private static LockMode toLockMode(ResourceAccessMode mode) {
-		switch (mode) {
-			case READ:
-				return LockMode.READ;
-			case READ_WRITE:
-				return LockMode.READ_WRITE;
+	@Override
+	public Set<ExclusiveResource> getExclusiveResources() {
+		if (this instanceof ResourceLockAware) {
+			return ((ResourceLockAware) this).determineExclusiveResources().collect(toSet());
 		}
-		throw new JUnitException("Unknown ResourceAccessMode: " + mode);
+		return emptySet();
 	}
 
 	@Override
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java
index 6a21a3ae6660..9c8e4bcb1ce3 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java
@@ -24,6 +24,7 @@
 import org.apiguardian.api.API;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.TestWatcher;
+import org.junit.jupiter.api.parallel.ResourceLocksProvider;
 import org.junit.jupiter.engine.config.JupiterConfiguration;
 import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
 import org.junit.platform.commons.logging.Logger;
@@ -36,7 +37,6 @@
 import org.junit.platform.engine.TestTag;
 import org.junit.platform.engine.UniqueId;
 import org.junit.platform.engine.support.descriptor.MethodSource;
-import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
 
 /**
  * Base class for {@link TestDescriptor TestDescriptors} based on Java methods.
@@ -44,7 +44,7 @@
  * @since 5.0
  */
 @API(status = INTERNAL, since = "5.0")
-public abstract class MethodBasedTestDescriptor extends JupiterTestDescriptor {
+public abstract class MethodBasedTestDescriptor extends JupiterTestDescriptor implements ResourceLockAware {
 
 	private static final Logger logger = LoggerFactory.getLogger(MethodBasedTestDescriptor.class);
 
@@ -80,13 +80,14 @@ public final Set<TestTag> getTags() {
 	}
 
 	@Override
-	public Set<ExclusiveResource> getExclusiveResources() {
-		// @formatter:off
-		return getExclusiveResourcesFromAnnotations(
-				getTestMethod(),
-				provider -> provider.provideForMethod(getTestClass(), getTestMethod())
-		);
-		// @formatter:on
+	public ExclusiveResourceCollector getExclusiveResourceCollector() {
+		// There's no need to cache this as this method should only be called once
+		return ExclusiveResourceCollector.from(getTestMethod());
+	}
+
+	@Override
+	public Set<ResourceLocksProvider.Lock> evaluateResourceLocksProvider(ResourceLocksProvider provider) {
+		return provider.provideForMethod(getTestClass(), getTestMethod());
 	}
 
 	@Override
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java
index 7732a85854f8..0c1f697b2686 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java
@@ -10,16 +10,20 @@
 
 package org.junit.jupiter.engine.descriptor;
 
+import static java.util.Collections.emptyList;
 import static org.apiguardian.api.API.Status.INTERNAL;
 import static org.junit.jupiter.engine.descriptor.DisplayNameUtils.createDisplayNameSupplierForNestedClass;
 
+import java.util.ArrayList;
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
 import org.apiguardian.api.API;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.TestInstances;
+import org.junit.jupiter.api.parallel.ResourceLocksProvider;
 import org.junit.jupiter.engine.config.JupiterConfiguration;
 import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
 import org.junit.jupiter.engine.extension.ExtensionRegistrar;
@@ -27,7 +31,6 @@
 import org.junit.platform.engine.TestDescriptor;
 import org.junit.platform.engine.TestTag;
 import org.junit.platform.engine.UniqueId;
-import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
 import org.junit.platform.engine.support.hierarchical.ThrowableCollector;
 
 /**
@@ -59,6 +62,18 @@ public final Set<TestTag> getTags() {
 		return allTags;
 	}
 
+	@Override
+	public List<Class<?>> getEnclosingTestClasses() {
+		TestDescriptor parent = getParent().orElse(null);
+		if (parent instanceof ClassBasedTestDescriptor) {
+			ClassBasedTestDescriptor parentClassDescriptor = (ClassBasedTestDescriptor) parent;
+			List<Class<?>> result = new ArrayList<>(parentClassDescriptor.getEnclosingTestClasses());
+			result.add(parentClassDescriptor.getTestClass());
+			return result;
+		}
+		return emptyList();
+	}
+
 	// --- Node ----------------------------------------------------------------
 
 	@Override
@@ -74,13 +89,8 @@ protected TestInstances instantiateTestClass(JupiterEngineExecutionContext paren
 	}
 
 	@Override
-	public Set<ExclusiveResource> getExclusiveResources() {
-		// @formatter:off
-		return getExclusiveResourcesFromAnnotations(
-				getTestClass(),
-				provider -> provider.provideForNestedClass(getTestClass())
-		);
-		// @formatter:on
+	public Set<ResourceLocksProvider.Lock> evaluateResourceLocksProvider(ResourceLocksProvider provider) {
+		return provider.provideForNestedClass(getTestClass());
 	}
 
 }
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ResourceLockAware.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ResourceLockAware.java
new file mode 100644
index 000000000000..858644d48520
--- /dev/null
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ResourceLockAware.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2015-2024 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.jupiter.engine.descriptor;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.parallel.ResourceLocksProvider;
+import org.junit.platform.engine.TestDescriptor;
+import org.junit.platform.engine.support.hierarchical.ExclusiveResource;
+
+/**
+ * @since 5.12
+ */
+interface ResourceLockAware extends TestDescriptor {
+
+	default Stream<ExclusiveResource> determineExclusiveResources() {
+
+		Deque<ResourceLockAware> ancestors = new ArrayDeque<>();
+		TestDescriptor parent = this.getParent().orElse(null);
+		while (parent instanceof ResourceLockAware) {
+			ancestors.addFirst((ResourceLockAware) parent);
+			parent = parent.getParent().orElse(null);
+		}
+
+		if (ancestors.isEmpty()) {
+			return determineOwnExclusiveResources();
+		}
+
+		Stream<ExclusiveResource> ancestorDynamicResources = ancestors.stream() //
+				.map(ResourceLockAware::getExclusiveResourceCollector) //
+				.flatMap(collector -> collector.getDynamicResources(this::evaluateResourceLocksProvider));
+
+		return Stream.concat(ancestorDynamicResources, determineOwnExclusiveResources());
+	}
+
+	default Stream<ExclusiveResource> determineOwnExclusiveResources() {
+		return this.getExclusiveResourceCollector().getAllExclusiveResources(this::evaluateResourceLocksProvider);
+	}
+
+	ExclusiveResourceCollector getExclusiveResourceCollector();
+
+	Set<ResourceLocksProvider.Lock> evaluateResourceLocksProvider(ResourceLocksProvider provider);
+
+}


Test patch:
diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/ResourceLocksProviderTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/ResourceLocksProviderTests.java
index f9b350ccbc6b..3b60bc75569a 100644
--- a/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/ResourceLocksProviderTests.java
+++ b/platform-tests/src/test/java/org/junit/platform/engine/support/descriptor/ResourceLocksProviderTests.java
@@ -77,6 +77,7 @@ private Stream<Event> execute(Class<?> testCase) {
 
 	// -------------------------------------------------------------------------
 
+	@SuppressWarnings("JUnitMalformedDeclaration")
 	@ResourceLock(providers = ClassLevelProviderTestCase.Provider.class)
 	static class ClassLevelProviderTestCase {
 
@@ -150,6 +151,7 @@ public Set<Lock> provideForMethod(Class<?> testClass, Method testMethod) {
 		}
 	}
 
+	@SuppressWarnings("JUnitMalformedDeclaration")
 	static class NestedClassLevelProviderTestCase {
 
 		@Test
@@ -202,6 +204,7 @@ public Set<Lock> provideForMethod(Class<?> testClass, Method testMethod) {
 		}
 	}
 
+	@SuppressWarnings("JUnitMalformedDeclaration")
 	static class MethodLevelProviderTestCase {
 
 		@Test
@@ -249,6 +252,7 @@ public Set<Lock> provideForMethod(Class<?> testClass, Method testMethod) {
 		}
 	}
 
+	@SuppressWarnings("JUnitMalformedDeclaration")
 	static class MethodLevelProviderInNestedClassTestCase {
 
 		@Test