diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc index 89a1753a5280..4f5261e5bfbf 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc @@ -26,7 +26,7 @@ JUnit repository on GitHub. [[release-notes-5.12.0-M1-junit-platform-new-features-and-improvements]] ==== New Features and Improvements -* ❓ +* Introduce thread-per-test-class execution model. [[release-notes-5.12.0-M1-junit-jupiter]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 49a5d4ff3f9b..13ff0303ec02 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -2963,6 +2963,29 @@ include::{testDir}/example/SharedResourcesDemo.java[tags=user_guide] ---- +[[writing-tests-isolated-execution]] +=== Thread-per-class Isolated Execution + +By default, JUnit Jupiter tests are run sequentially from a single thread. The +thread-per-class isolated execution, set `junit.jupiter.execution.threadperclass.enabled` +to `true`. Each test class will be executed in its own thread. + +The thread-per-class execution model is useful, if test classes need to ensure that +per-thread resources, for example instances of `ThreadLocal`, do not leak to other test +classes. This becomes relevant if 3rd party libraries provide no way to clean up the +`ThreadLocal` instances they created. If such a `ThreadLocal` references a class that has +been loaded via a different class loader, this can lead to class-leaks and eventually +out-of-memory errors. Running test classes using the thread-per-class execution model allows +the JVM to eventually garbage collect those `ThreadLocal` instances and prevent such +out-of-memory errors. + +`junit.jupiter.execution.threadperclass.enabled` is only evaluated, if +`junit.jupiter.execution.parallel.enabled` is `false`. + +Since every test class requires a new thread to be created and requires some synchronization, +execution with the thread-per-class model has a little overhead. + + [[writing-tests-built-in-extensions]] === Built-in Extensions diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 94b13afd4548..1f71df39e3f9 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -30,6 +30,7 @@ import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.ThreadPerClassHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; /** @@ -74,9 +75,15 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { + if (configuration.isThreadPerClassExecutionEnabled()) { + throw new IllegalArgumentException("Parallel execution and thread-per-class is not supported"); + } return new ForkJoinPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } + if (configuration.isThreadPerClassExecutionEnabled()) { + return new ThreadPerClassHierarchicalTestExecutorService(request.getConfigurationParameters()); + } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index 1280c4b12a11..a560d8169d08 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -61,6 +61,12 @@ public boolean isParallelExecutionEnabled() { key -> delegate.isParallelExecutionEnabled()); } + @Override + public boolean isThreadPerClassExecutionEnabled() { + return (boolean) cache.computeIfAbsent(THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME, + key -> delegate.isThreadPerClassExecutionEnabled()); + } + @Override public boolean isExtensionAutoDetectionEnabled() { return (boolean) cache.computeIfAbsent(EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 83ef7592534f..34791cdd34d4 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -84,6 +84,11 @@ public boolean isParallelExecutionEnabled() { return configurationParameters.getBoolean(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); } + @Override + public boolean isThreadPerClassExecutionEnabled() { + return configurationParameters.getBoolean(THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); + } + @Override public boolean isExtensionAutoDetectionEnabled() { return configurationParameters.getBoolean(EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME).orElse(false); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 7a90072363d2..3810ea11b74b 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -36,6 +36,7 @@ public interface JupiterConfiguration { String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + String THREAD_PER_CLASS_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.threadperclass.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; String EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.enabled"; @@ -50,6 +51,8 @@ public interface JupiterConfiguration { boolean isParallelExecutionEnabled(); + boolean isThreadPerClassExecutionEnabled(); + boolean isExtensionAutoDetectionEnabled(); ExecutionMode getDefaultExecutionMode(); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java index e4d2208f2bc3..b6e701883466 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java @@ -69,6 +69,10 @@ class NodeTestTask implements TestTask { this.finalizer = finalizer; } + TestDescriptor getTestDescriptor() { + return testDescriptor; + } + @Override public ResourceLock getResourceLock() { return taskContext.getExecutionAdvisor().getResourceLock(testDescriptor); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java new file mode 100644 index 000000000000..411326d49488 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassHierarchicalTestExecutorService.java @@ -0,0 +1,133 @@ +/* + * 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.platform.engine.support.hierarchical; + +import static java.lang.String.format; +import static java.time.Duration.ofMinutes; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.apiguardian.api.API.Status.STABLE; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apiguardian.api.API; +import org.junit.platform.commons.JUnitException; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; + +/** + * A {@linkplain HierarchicalTestExecutorService executor service} that creates a new thread for + * each test class, all {@linkplain TestTask test tasks}. + * + *

This execution model is useful to prevent some kinds of class / class-loader leaks. For + * example, if a test creates {@link ClassLoader}s and the tests or any of the code and libraries + * create {@link ThreadLocal}s, those thread locals would accumulate in the single {@link + * SameThreadHierarchicalTestExecutorService} causing a class-(loader)-leak. + * + * @since 5.12 + */ +@API(status = STABLE, since = "5.12") +public class ThreadPerClassHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + + private final AtomicInteger threadCount = new AtomicInteger(); + private final Duration interruptWaitDuration; + + static final Duration DEFAULT_INTERRUPT_WAIT_DURATION = ofMinutes(5); + static final String THREAD_PER_CLASS_INTERRUPTED_WAIT_TIME_SECONDS = "junit.jupiter.execution.threadperclass.interrupted.waittime.seconds"; + + public ThreadPerClassHierarchicalTestExecutorService(ConfigurationParameters config) { + interruptWaitDuration = config.get(THREAD_PER_CLASS_INTERRUPTED_WAIT_TIME_SECONDS).map(Integer::parseInt).map( + Duration::ofSeconds).orElse(DEFAULT_INTERRUPT_WAIT_DURATION); + } + + @Override + public Future submit(TestTask testTask) { + executeTask(testTask); + return completedFuture(null); + } + + @Override + public void invokeAll(List tasks) { + tasks.forEach(this::executeTask); + } + + protected void executeTask(TestTask testTask) { + NodeTestTask nodeTestTask = (NodeTestTask) testTask; + TestDescriptor testDescriptor = nodeTestTask.getTestDescriptor(); + + UniqueId.Segment lastSegment = testDescriptor.getUniqueId().getLastSegment(); + + if ("class".equals(lastSegment.getType())) { + executeOnDifferentThread(testTask, lastSegment); + } + else { + testTask.execute(); + } + } + + private void executeOnDifferentThread(TestTask testTask, UniqueId.Segment lastSegment) { + CompletableFuture future = new CompletableFuture<>(); + Thread threadPerClass = new Thread(() -> { + try { + testTask.execute(); + future.complete(null); + } + catch (Exception e) { + future.completeExceptionally(e); + } + }, threadName(lastSegment)); + threadPerClass.setDaemon(true); + threadPerClass.start(); + + try { + try { + future.get(); + } + catch (InterruptedException e) { + // propagate a thread-interrupt to the executing class + threadPerClass.interrupt(); + try { + future.get(interruptWaitDuration.toMillis(), MILLISECONDS); + } + catch (InterruptedException ie) { + threadPerClass.interrupt(); + } + catch (TimeoutException to) { + throw new JUnitException(format("Test class %s was interrupted but did not terminate within %s", + lastSegment.getValue(), interruptWaitDuration), to); + } + } + } + catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new JUnitException("TestTask execution failure", cause); + } + } + + private String threadName(UniqueId.Segment lastSegment) { + return format("TEST THREAD #%d FOR %s", threadCount.incrementAndGet(), lastSegment.getValue()); + } + + @Override + public void close() { + // nothing to do + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java new file mode 100644 index 000000000000..b4ca824573d4 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ThreadPerClassTestExecutorTests.java @@ -0,0 +1,273 @@ +/* + * 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.platform.engine.support.hierarchical; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.engine.TestExecutionResult.Status.FAILED; +import static org.junit.platform.engine.TestExecutionResult.Status.SUCCESSFUL; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.EngineExecutionListener; +import org.junit.platform.engine.ExecutionRequest; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Micro-tests that verify behavior of {@link HierarchicalTestExecutor}. + * + * @since 1.0 + */ +@ExtendWith(MockitoExtension.class) +class ThreadPerClassTestExecutorTests { + + @Spy + MyContainer root = new MyContainer(UniqueId.root("container", "root")); + + @Mock + EngineExecutionListener listener; + + MyEngineExecutionContext rootContext = new MyEngineExecutionContext(); + HierarchicalTestExecutor executor; + + Duration testMaxWaitTime = Duration.ofMinutes(5); + + @BeforeEach + void init() { + executor = createExecutor( + new ThreadPerClassHierarchicalTestExecutorService(new EmptyConfigurationParameters())); + } + + private HierarchicalTestExecutor createExecutor( + HierarchicalTestExecutorService executorService) { + var request = new ExecutionRequest(root, listener, null); + return new HierarchicalTestExecutor<>(request, rootContext, executorService, + OpenTest4JAwareThrowableCollector::new); + } + + @Test + void failures() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + + var clazzId1 = rootId.append("class", "my.Class"); + var clazz1 = spy(new MyContainer(clazzId1)); + container.addChild(clazz1); + var failure1 = new AssertionError("something went wrong"); + var thread1 = new AtomicReference(); + when(clazz1.execute(any(), any())).then(inv -> { + thread1.set(Thread.currentThread()); + throw failure1; + }); + + var clazzId2 = rootId.append("class", "my.OtherClass"); + var clazz2 = spy(new MyContainer(clazzId2)); + container.addChild(clazz2); + var failure2 = new AssertionError("something went wrong"); + var thread2 = new AtomicReference(); + when(clazz2.execute(any(), any())).then(inv -> { + thread2.set(Thread.currentThread()); + throw failure2; + }); + + executor.execute().get(); + + assertThat(thread1.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread1.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread1.get().isAlive()).isFalse(); + assertThat(thread1.get().isDaemon()).isTrue(); + + assertThat(thread2.get()).isNotNull().isNotSameAs(Thread.currentThread()).isNotSameAs(thread1.get()).extracting( + Thread::getName, STRING).startsWith("TEST THREAD ").contains(" FOR my.OtherClass"); + assertThat(thread2.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread2.get().isAlive()).isFalse(); + assertThat(thread2.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz1), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(FAILED); + assertThat(rootExecutionResult.getValue().getThrowable()).get().isInstanceOf(AssertionError.class).asInstanceOf( + type(AssertionError.class)).extracting(AssertionError::getMessage).isEqualTo("something went wrong"); + } + + @Test + void goodResults() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + + var clazzId1 = rootId.append("class", "my.Class"); + var clazz1 = spy(new MyContainer(clazzId1)); + container.addChild(clazz1); + var thread1 = new AtomicReference(); + when(clazz1.execute(any(), any())).then(inv -> { + thread1.set(Thread.currentThread()); + return inv.callRealMethod(); + }); + + var clazzId2 = rootId.append("class", "my.OtherClass"); + var clazz2 = spy(new MyContainer(clazzId2)); + container.addChild(clazz2); + var failure2 = new AssertionError("something went wrong"); + var thread2 = new AtomicReference(); + when(clazz2.execute(any(), any())).then(inv -> { + thread2.set(Thread.currentThread()); + throw failure2; + }); + + executor.execute().get(); + + assertThat(thread1.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread1.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread1.get().isAlive()).isFalse(); + assertThat(thread1.get().isDaemon()).isTrue(); + + assertThat(thread2.get()).isNotNull().isNotSameAs(Thread.currentThread()).isNotSameAs(thread1.get()).extracting( + Thread::getName, STRING).startsWith("TEST THREAD ").contains(" FOR my.OtherClass"); + assertThat(thread2.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread2.get().isAlive()).isFalse(); + assertThat(thread2.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz1), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(SUCCESSFUL); + assertThat(rootExecutionResult.getValue().getThrowable()).isEmpty(); + } + + /** Check that {@code Thread.interrupt()} is propagated to the test execution. */ + @Test + void interrupt() throws Exception { + var rootId = UniqueId.root("engine", "my engine"); + var container = spy(new MyContainer(rootId)); + root.addChild(container); + var clazzId = rootId.append("class", "my.Class"); + var clazz = spy(new MyContainer(clazzId)); + container.addChild(clazz); + var thread = new AtomicReference(); + + // latch to wait for that "our test" is currently running + var sleeping = new CountDownLatch(1); + var interruptedHandled = new CountDownLatch(1); + + when(clazz.execute(any(), any())).then(inv -> { + thread.set(Thread.currentThread()); + try { + sleeping.countDown(); + Thread.sleep(testMaxWaitTime); + } + catch (InterruptedException e) { + interruptedHandled.countDown(); + throw new RuntimeException(e); + } + return fail(); + }); + + Future future; + try (var executorService = Executors.newSingleThreadExecutor()) { + future = executorService.submit(() -> executor.execute().get()); + + // wait until "our test" is running + assertThat(sleeping.await(testMaxWaitTime.toMillis(), MILLISECONDS)).isTrue(); + + // Interrupt the executor + future.cancel(true); + + // wait for the "test execution" to finish and being interrupted + assertThatThrownBy(() -> future.get(testMaxWaitTime.toMillis(), MILLISECONDS)).isInstanceOf( + CancellationException.class); + + assertThat(interruptedHandled.await(testMaxWaitTime.toMillis(), MILLISECONDS)).isTrue(); + + assertThat(thread.get()).isNotNull().isNotSameAs(Thread.currentThread()).extracting(Thread::getName, + STRING).startsWith("TEST THREAD ").contains(" FOR my.Class"); + assertThat(thread.get().join(testMaxWaitTime)).isTrue(); + assertThat(thread.get().isAlive()).isFalse(); + assertThat(thread.get().isDaemon()).isTrue(); + + var rootExecutionResult = ArgumentCaptor.forClass(TestExecutionResult.class); + verify(listener).executionFinished(eq(clazz), rootExecutionResult.capture()); + assertThat(rootExecutionResult.getValue().getStatus()).isEqualTo(FAILED); + assertThat(rootExecutionResult.getValue().getThrowable()).get().isInstanceOf( + RuntimeException.class).extracting(Throwable::getCause).isInstanceOf(InterruptedException.class); + } + } + + // ------------------------------------------------------------------- + + private static class MyEngineExecutionContext implements EngineExecutionContext { + } + + private static class MyContainer extends AbstractTestDescriptor implements Node { + + MyContainer(UniqueId uniqueId) { + super(uniqueId, uniqueId.toString()); + } + + @Override + public Type getType() { + return Type.CONTAINER; + } + } + + private static class EmptyConfigurationParameters implements ConfigurationParameters { + @Override + public Optional get(String key) { + return Optional.empty(); + } + + @Override + public Optional getBoolean(String key) { + return Optional.empty(); + } + + @Override + @SuppressWarnings("deprecation") + public int size() { + return 0; + } + + @Override + public Set keySet() { + return Collections.emptySet(); + } + + } +}