From 6e1269ed75422e06d43106992c8e66d7f17ed77f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 14 Oct 2026 17:41:34 -0700 Subject: [PATCH 1/2] feat(junit): allow OptionsFactory to access test instance fields Fixes: https://github.com/microsoft/playwright-java/issues/1881 --- .../impl/junit/OptionsExtension.java | 52 ++++++++++++-- .../playwright/junit/OptionsFactory.java | 23 ++++++ .../junit/TestFixtureInnerOptionsFactory.java | 70 +++++++++++++++++++ ...estFixtureInnerOptionsFactoryPerClass.java | 60 ++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java create mode 100644 playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java index d36eb700e..e4aad616d 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java @@ -18,33 +18,71 @@ import com.microsoft.playwright.PlaywrightException; import com.microsoft.playwright.junit.Options; -import com.microsoft.playwright.junit.UsePlaywright; +import com.microsoft.playwright.junit.OptionsFactory; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + import static com.microsoft.playwright.impl.junit.ExtensionUtils.*; public class OptionsExtension implements AfterAllCallback { private static final ThreadLocal threadLocalOptions = new ThreadLocal<>(); + // Test instance that the options were created for, when the factory is an inner class of the test class. + private static final ThreadLocal threadLocalEnclosingInstance = new ThreadLocal<>(); @Override public void afterAll(ExtensionContext extensionContext) { threadLocalOptions.remove(); + threadLocalEnclosingInstance.remove(); } static Options getOptions(ExtensionContext extensionContext) { + Class factoryClass = getUsePlaywrightAnnotation(extensionContext).value(); Options options = threadLocalOptions.get(); - if (options != null) { + if (!isInnerClass(factoryClass)) { + if (options == null) { + options = createFactory(factoryClass, null).getOptions(); + threadLocalOptions.set(options); + } return options; } - UsePlaywright usePlaywrightAnnotation = getUsePlaywrightAnnotation(extensionContext); + // Inner class factories can access fields of the test instance, so the options are + // re-created for each test instance. + Class enclosingClass = factoryClass.getDeclaringClass(); + Object enclosingInstance = extensionContext.getTestInstances() + .flatMap(instances -> instances.findInstance(enclosingClass).map(Object.class::cast)) + .orElse(null); + if (options != null && (enclosingInstance == null || enclosingInstance == threadLocalEnclosingInstance.get())) { + return options; + } + if (enclosingInstance == null) { + throw new PlaywrightException("Cannot create " + factoryClass.getName() + " without an instance of " + + enclosingClass.getName() + ". Make the options factory a static class or use " + + "@TestInstance(TestInstance.Lifecycle.PER_CLASS) to request Playwright fixtures in @BeforeAll methods."); + } + options = createFactory(factoryClass, enclosingInstance).getOptions(); + threadLocalOptions.set(options); + threadLocalEnclosingInstance.set(enclosingInstance); + return options; + } + + private static boolean isInnerClass(Class clazz) { + return clazz.isMemberClass() && !Modifier.isStatic(clazz.getModifiers()); + } + + private static OptionsFactory createFactory(Class factoryClass, Object enclosingInstance) { try { - options = usePlaywrightAnnotation.value().newInstance().getOptions(); - threadLocalOptions.set(options); - } catch (InstantiationException | IllegalAccessException e) { + if (enclosingInstance == null) { + return factoryClass.newInstance(); + } + Constructor constructor = factoryClass.getDeclaredConstructor(factoryClass.getDeclaringClass()); + constructor.setAccessible(true); + return constructor.newInstance(enclosingInstance); + } catch (ReflectiveOperationException e) { throw new PlaywrightException("Failed to create options", e); } - return options; } } diff --git a/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java b/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java index 3a6256a1a..c2b994254 100644 --- a/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java +++ b/playwright/src/main/java/com/microsoft/playwright/junit/OptionsFactory.java @@ -54,6 +54,29 @@ * } * } * + *

If the options depend on the state of the test instance (e.g. a server injected by + * a dependency injection framework), declare the factory as a non-static inner class of + * the test class. It will be created for each test instance and can access its fields: + *

{@code
+ * @UsePlaywright(MyTest.CustomOptions.class)
+ * public class MyTest {
+ *   @Inject
+ *   EmbeddedServer server;
+ *
+ *   class CustomOptions implements OptionsFactory {
+ *     @Override
+ *     public Options getOptions() {
+ *       return new Options().setBaseUrl(server.getURL().toString());
+ *     }
+ *   }
+ * }
+ * }
+ * Only context level options (e.g. base URL, context options, trace) are re-evaluated for each + * test instance; {@code Playwright} and {@code Browser} are created with the options of the first + * test instance and reused. Unless the test class uses {@code @TestInstance(Lifecycle.PER_CLASS)}, + * Playwright fixtures cannot be requested in {@code @BeforeAll} methods because there is no + * test instance yet. + * *

For more details and usage examples see our * JUnit guide. */ diff --git a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java new file mode 100644 index 000000000..0e87b36fa --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * 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 com.microsoft.playwright.junit; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Server; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertSame; + +@FixtureTest +@UsePlaywright(TestFixtureInnerOptionsFactory.CustomOptions.class) +public class TestFixtureInnerOptionsFactory { + private static final List factoryInstances = new ArrayList<>(); + // Set per instance, similar to fields injected by Spring or Micronaut. + private final Server server; + + TestFixtureInnerOptionsFactory(Server server) { + this.server = server; + } + + class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + factoryInstances.add(TestFixtureInnerOptionsFactory.this); + return new Options().setBaseUrl(server.PREFIX); + } + } + + @Test + void shouldUseTestInstanceFields(Page page) { + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + } + + @Test + void shouldCreateOptionsForEachTestInstance(Page page) { + page.navigate("/empty.html"); + assertSame(this, factoryInstances.get(factoryInstances.size() - 1)); + } + + @Nested + class NestedClass { + @Test + void shouldUseOuterInstanceInNestedClass(Page page) { + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + assertSame(TestFixtureInnerOptionsFactory.this, factoryInstances.get(factoryInstances.size() - 1)); + } + } +} diff --git a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java new file mode 100644 index 000000000..f4b865e80 --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * 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 com.microsoft.playwright.junit; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Server; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +@FixtureTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@UsePlaywright(TestFixtureInnerOptionsFactoryPerClass.CustomOptions.class) +public class TestFixtureInnerOptionsFactoryPerClass { + private final Server server; + + TestFixtureInnerOptionsFactoryPerClass(Server server) { + this.server = server; + } + + class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + return new Options().setBaseUrl(server.PREFIX); + } + } + + @BeforeAll + void beforeAll(Browser browser, Browser.NewContextOptions contextOptions) { + BrowserContext context = browser.newContext(contextOptions); + Page page = context.newPage(); + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + context.close(); + } + + @Test + void shouldUseTestInstanceFields(Page page) { + page.navigate("/empty.html"); + assertThat(page).hasURL(server.EMPTY_PAGE); + } +} From bffd1060edbe84efa6dd3c164cde15383b525ca6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 14 Oct 2026 17:47:20 -0700 Subject: [PATCH 2/2] chore(junit): simplify inner OptionsFactory support and tests --- .../impl/junit/OptionsExtension.java | 35 ++++++----- .../junit/TestFixtureInnerOptionsFactory.java | 37 ++++++------ ...estFixtureInnerOptionsFactoryPerClass.java | 60 ------------------- 3 files changed, 37 insertions(+), 95 deletions(-) delete mode 100644 playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java index e4aad616d..7e14e1873 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/junit/OptionsExtension.java @@ -40,29 +40,13 @@ public void afterAll(ExtensionContext extensionContext) { static Options getOptions(ExtensionContext extensionContext) { Class factoryClass = getUsePlaywrightAnnotation(extensionContext).value(); - Options options = threadLocalOptions.get(); - if (!isInnerClass(factoryClass)) { - if (options == null) { - options = createFactory(factoryClass, null).getOptions(); - threadLocalOptions.set(options); - } - return options; - } - // Inner class factories can access fields of the test instance, so the options are // re-created for each test instance. - Class enclosingClass = factoryClass.getDeclaringClass(); - Object enclosingInstance = extensionContext.getTestInstances() - .flatMap(instances -> instances.findInstance(enclosingClass).map(Object.class::cast)) - .orElse(null); + Object enclosingInstance = findEnclosingInstance(factoryClass, extensionContext); + Options options = threadLocalOptions.get(); if (options != null && (enclosingInstance == null || enclosingInstance == threadLocalEnclosingInstance.get())) { return options; } - if (enclosingInstance == null) { - throw new PlaywrightException("Cannot create " + factoryClass.getName() + " without an instance of " + - enclosingClass.getName() + ". Make the options factory a static class or use " + - "@TestInstance(TestInstance.Lifecycle.PER_CLASS) to request Playwright fixtures in @BeforeAll methods."); - } options = createFactory(factoryClass, enclosingInstance).getOptions(); threadLocalOptions.set(options); threadLocalEnclosingInstance.set(enclosingInstance); @@ -73,11 +57,26 @@ private static boolean isInnerClass(Class clazz) { return clazz.isMemberClass() && !Modifier.isStatic(clazz.getModifiers()); } + private static Object findEnclosingInstance(Class factoryClass, ExtensionContext extensionContext) { + if (!isInnerClass(factoryClass)) { + return null; + } + return extensionContext.getTestInstances() + .flatMap(instances -> instances.findInstance(factoryClass.getDeclaringClass())) + .orElse(null); + } + private static OptionsFactory createFactory(Class factoryClass, Object enclosingInstance) { + if (enclosingInstance == null && isInnerClass(factoryClass)) { + throw new PlaywrightException("Cannot create " + factoryClass.getName() + " without an instance of " + + factoryClass.getDeclaringClass().getName() + ". Make the options factory a static class or use " + + "@TestInstance(TestInstance.Lifecycle.PER_CLASS) to request Playwright fixtures in @BeforeAll methods."); + } try { if (enclosingInstance == null) { return factoryClass.newInstance(); } + // Use the declaring class rather than enclosingInstance.getClass(), which may be a subclass. Constructor constructor = factoryClass.getDeclaredConstructor(factoryClass.getDeclaringClass()); constructor.setAccessible(true); return constructor.newInstance(enclosingInstance); diff --git a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java index 0e87b36fa..3411fd2b1 100644 --- a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java +++ b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactory.java @@ -16,23 +16,25 @@ package com.microsoft.playwright.junit; +import com.microsoft.playwright.Browser; import com.microsoft.playwright.Page; import com.microsoft.playwright.Server; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; +import org.junit.jupiter.api.TestInstance; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @FixtureTest @UsePlaywright(TestFixtureInnerOptionsFactory.CustomOptions.class) public class TestFixtureInnerOptionsFactory { - private static final List factoryInstances = new ArrayList<>(); // Set per instance, similar to fields injected by Spring or Micronaut. private final Server server; + private boolean optionsCreated; TestFixtureInnerOptionsFactory(Server server) { this.server = server; @@ -41,30 +43,31 @@ public class TestFixtureInnerOptionsFactory { class CustomOptions implements OptionsFactory { @Override public Options getOptions() { - factoryInstances.add(TestFixtureInnerOptionsFactory.this); + optionsCreated = true; return new Options().setBaseUrl(server.PREFIX); } } - @Test - void shouldUseTestInstanceFields(Page page) { - page.navigate("/empty.html"); - assertThat(page).hasURL(server.EMPTY_PAGE); - } - - @Test + // Each repetition gets a new test instance. + @RepeatedTest(2) void shouldCreateOptionsForEachTestInstance(Page page) { + assertTrue(optionsCreated); page.navigate("/empty.html"); - assertSame(this, factoryInstances.get(factoryInstances.size() - 1)); + assertThat(page).hasURL(server.EMPTY_PAGE); } @Nested - class NestedClass { + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class PerClassLifecycle { + @BeforeAll + void beforeAll(Browser.NewContextOptions contextOptions) { + assertEquals(server.PREFIX, contextOptions.baseURL); + } + @Test - void shouldUseOuterInstanceInNestedClass(Page page) { + void shouldUseOuterInstance(Page page) { page.navigate("/empty.html"); assertThat(page).hasURL(server.EMPTY_PAGE); - assertSame(TestFixtureInnerOptionsFactory.this, factoryInstances.get(factoryInstances.size() - 1)); } } } diff --git a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java b/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java deleted file mode 100644 index f4b865e80..000000000 --- a/playwright/src/test/java/com/microsoft/playwright/junit/TestFixtureInnerOptionsFactoryPerClass.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. - * - * 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 com.microsoft.playwright.junit; - -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.BrowserContext; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Server; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; - -import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; - -@FixtureTest -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@UsePlaywright(TestFixtureInnerOptionsFactoryPerClass.CustomOptions.class) -public class TestFixtureInnerOptionsFactoryPerClass { - private final Server server; - - TestFixtureInnerOptionsFactoryPerClass(Server server) { - this.server = server; - } - - class CustomOptions implements OptionsFactory { - @Override - public Options getOptions() { - return new Options().setBaseUrl(server.PREFIX); - } - } - - @BeforeAll - void beforeAll(Browser browser, Browser.NewContextOptions contextOptions) { - BrowserContext context = browser.newContext(contextOptions); - Page page = context.newPage(); - page.navigate("/empty.html"); - assertThat(page).hasURL(server.EMPTY_PAGE); - context.close(); - } - - @Test - void shouldUseTestInstanceFields(Page page) { - page.navigate("/empty.html"); - assertThat(page).hasURL(server.EMPTY_PAGE); - } -}