What Is a Test Fixture in JUnit, and Why Is @BeforeEach Not a Fixture?
Where does a test fixture end and the test lifecycle begin? This article examines @BeforeEach, Fresh Fixture and Shared Fixture, TestNG, Selenium, Playwright, pytest, and the basics of parallel test execution.A simple example of a fixture in JUnit
Full version for non-Medium members
Table of Contents
1. Why the terminology is confusing
2. Fixtures and lifecycle methods are not the same thing
3. A simple example of a fixture in JUnit
4. What can a fixture include?
5. Fresh Fixture and Shared Fixture
6. Why you shouldn’t hide the entire setup in @BeforeEach
7. Fixtures in API tests
8. UI fixtures: Selenium, Selenide and Playwright Java
9. Nuances of fixtures in TestNG
10. Why pytest, Cypress, and Playwright JS cause terminological confusion
11. A few rules of thumb
Conclusion
Technical Appendices
Appendix A. Selenium + JUnit: ThreadLocal, ThreadGuard, and parallel tests
Appendix B. Selenium + TestNG: ThreadLocal, ThreadGuard, and parallel=”methods”
Appendix C. Playwright Java + TestNG: ThreadLocal, BrowserContext, and parallel tests
Appendix D. API fixtures: REST Assured, sessions, and parallel tests
1. Why the terminology is confusing
Technical terminology can be confusing. The same term may be used every day by many professionals yet carry several meanings — and each of those meanings may be valid.
Programming has no shortage of examples. A module may mean part of a project, a Maven module, a Java module, or a Python module. The word service may refer to a service-layer class, a standalone microservice, or a Kubernetes Service resource. Different developers may even define familiar concepts such as integration testing or object-oriented programming somewhat differently.
The reason is not necessarily that someone is using the term incorrectly. Programming languages, libraries, and frameworks have evolved independently of each other and sometimes assigned different, albeit related, meanings to the same word.
The term “fixture” has a similar history.
What do we mean by a fixture?
The word “fixture” is used slightly differently in different situations. In classic xUnit terminology, a test fixture is the state and environment necessary to execute a test: data, objects, resources and their initial configuration.
In informal usage, however, “fixture” may refer not only to the state itself but also to the code that creates, configures, or manages it.
So you may hear:
“The method annotated
@BeforeEachis a fixture."
The meaning is usually clear, but technically it is more accurate to say:
The method annotated
@BeforeEachis a lifecycle method that can prepare a test fixture.
The differences in terminology become even more noticeable when moving between different frameworks and stacks.
For example, LinkedIn regularly features posts recommending that test-state setup and cleanup be placed in the beforeEach and afterEach hooks in Playwright JS.
Such advice is often written by an experienced Java or C# test automation engineer who has recently moved to JavaScript. They may be a senior engineer in their primary stack and still be unfamiliar with Playwright’s fixture mechanism.
In Playwright JS, fixtures and the beforeEach/afterEach hooks are different mechanisms. They can solve similar problems, but they work differently.
This is a good example of how terminology from one framework can quietly carry over into another and take on a slightly different meaning.
I wrote this article to clarify the distinction, primarily for Java test automation engineers working with JUnit.
Many of the principles discussed here also apply to TestNG, especially those concerning lifecycle methods, state management, and parallel test execution. We will also see why the familiar word fixture can mean something slightly different when you move to pytest, Cypress, or Playwright JS.
The main part is deliberately accessible and is also intended for readers who are just getting started with JUnit.
The appendices examine technical aspects of fixtures in parallel test execution: ThreadLocal, Selenium ThreadGuard, Playwright Java, REST Assured, state isolation, and resource cleanup.
2. Fixtures and lifecycle methods are not the same thing
In the JUnit documentation methods marked:
are called lifecycle methods. JUnit invokes lifecycle methods at specific points in the lifecycle of tests and test classes. These methods can perform fixture setup or teardown.
A lifecycle method does not have to work with a fixture; it may contain other supporting code instead.
In contrast, a test fixture is the prepared state, data, objects, and resources needed by a test.
Interestingly, in the JUnit 6 User Guide, the word fixture does not appear at all. Modern official JUnit documentation describes the test instance lifecycle, lifecycle methods, and associated annotations, but does not introduce fixtures as a separate framework mechanism.
This doesn’t mean that modern JUnit tests no longer have fixtures. It’s just that JUnit doesn’t use this term as the name of a separate API feature.
Therefore, in this article, the word “fixture” is used in the classic xUnit sense, and not as the official name for JUnit lifecycle methods.
It is important not to conflate three distinct concepts. A fixture is the state and resources a test requires. Lifecycle methods are methods that JUnit invokes before or after tests. The test instance lifecycle determines how JUnit creates instances of the test class. In PER_METHOD mode, for example, JUnit creates a new class instance for every test method. This isolates ordinary instance fields, but not the entire fixture: a database, files, static state, or a browser session may still be shared. Section 5 discusses this distinction in more detail.
This was the case in older versions of JUnit
This distinction predates JUnit Jupiter.
The JUnit 4 Cookbook and JUnit 4 FAQ likewise treat a fixture as the state and objects a test requires, and @Before and @After as the code that sets up and tears down that state.
The same model was used back in junit.framework.TestCase from JUnit 3: the state was stored in the fields of the test class, setUp() prepared it, and tearDown() did the cleanup.
In other words, the distinction between a fixture and the code that manages its lifecycle is far from new in JUnit.
Why are setup and lifecycle methods sometimes also called fixtures?
This is where some of the terminology confusion comes from. In educational materials and colloquial speech, the word fixture is sometimes used more broadly — not only to refer to the prepared state, but also to the code or mechanism that controls that state.
Consequently, @Before / @After and @BeforeEach / @AfterEach methods and annotations are sometimes called simply fixtures, fixture annotations, or fixture methods. This usage appears, for example, in the old JUnit 4 Wiki, which explicitly calls @Before, @After, @BeforeClass, and @AfterClass fixture annotations. It also appears in material from AtomicJar, JavaGuides, and the article Anatomy of a Good Test, which calls an @BeforeEach method a fixture method.
The context usually makes it clear that the speaker means test-state setup or cleanup, so this usage is not necessarily wrong. When discussing JUnit, however, distinguishing the concepts is useful.
The expressions fixture setup and fixture teardown are precise: they refer to preparing and cleaning up the fixture, not to the name of the method itself.
The shortest way to express the distinction is:
A fixture is what is prepared. Setup and teardown are the code that prepares and cleans it up. Lifecycle methods are methods that JUnit invokes at specific points before or after test execution; they may perform fixture setup or teardown.
How a fixture, setup/teardown, and a lifecycle method differ
A lifecycle method can prepare or clean up a fixture, but that does not make the method itself a fixture.
The reverse is also true: fixture setup does not have to happen in a lifecycle method. A fixture can be created directly in a test, in a helper method, in a JUnit extension, or elsewhere. The next section shows an example.
3. A simple example of a fixture in JUnit
Consider a test for an online shopping cart:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void createEmptyCart() {
cart = new ShoppingCart();
}
@Test
void shouldCalculateTotalForTwoItems() {
cart.addItem(200, 2);
assertEquals(400, cart.getTotal());
}
}What does each part represent?
- an empty
ShoppingCartis part of the test fixture; thecartfield holds a reference to this object; createEmptyCart()is a lifecycle method because it is annotated with@BeforeEach;cart = new ShoppingCart()is fixture setup;cart.addItem(200, 2)is an action performed on the object under test;assertEquals(...)is an assertion that checks the expected result.
Important: the fixture does not need to be prepared in the method with @BeforeEach.
The same test can be written like this:
@Test
void shouldCalculateTotalForTwoItems() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(200, 2);
assertEquals(400, cart.getTotal());
}The fixture has not disappeared. Only the location of its setup has changed: ShoppingCart is now created directly inside the test method.
In the terminology of the book xUnit Test Patterns, this approach is called In-line Setup: the fixture is prepared directly in the test method. In the first example, setup is moved to @BeforeEach; this corresponds to Implicit Setup, in which a setup method runs common fixture-setup code before the test.
The way the setup code is organized may change, but the fixture itself remains the same.
4. What Can a Fixture Include?
A fixture is not necessarily one object. It can include several objects, data, resources and the state required by the test.
For a unit test this could be:
- an instance of the class being tested;
- its dependencies;
- input data;
- mocks, stubs, or fakes;
- pre-configured state of objects.
For example:
PaymentService service =
new PaymentService(paymentGateway, exchangeRateProvider);The PaymentService instance, its dependencies, and their state can be part of a fixture.
In API, UI, and integration tests, a fixture often includes more components.
API test
A fixture may include:
- an API client;
- an access token;
- a user created for the test;
- an order in the required state.
UI test
For example:
- a browser session;
- a
WebDriverinstance; - cookies;
- an authenticated user;
- an application page opened in the required state;
- data that already exists in the system.
Integration test
For example:
- records in the database;
- a container started for the test;
- a message in a queue;
- temporary files.
It’s helpful to ask a simple question:
What state does the test expect before the action being tested begins?
The answer to this question often describes the fixture.
It is important to separate the fixture itself from the code and tools that are used to prepare it:
- a
Userobject created specifically for the test and required for its execution can be part of the fixture; - the
UserBuilderused to create that object is a fixture-setup tool; - an authenticated browser session can be part of the fixture;
- the
@BeforeEachmethod that creates this session is a lifecycle method that prepares the fixture; - an assertion is not a fixture; it checks the result.
This distinction may seem minor, but it makes it easier to discuss test lifecycles, JUnit extensions, shared fixtures, and parallel execution without conflating different concepts.
5. Fresh Fixture and Shared Fixture
In classic xUnit terminology, it is useful to distinguish between two ways of organizing a fixture: Fresh Fixture and Shared Fixture. Both patterns are described in detail in Chapter 18 of xUnit Test Patterns.
This article uses the original terms Fresh Fixture and Shared Fixture from xUnit Test Patterns.
Fresh Fixture
A Fresh Fixture is created separately for each test and is not reused by other tests.
This is usually the simplest and most reliable way to provide isolation: each test starts from a known state and is less dependent on what happened before it.
JUnit by default creates a new instance of the test class for each test method. This mode is called PER_METHOD.
For example:
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
cart = new ShoppingCart();
}
}For each test method, JUnit creates a separate instance of ShoppingCartTest, and the method with @BeforeEach creates a separate ShoppingCart object for it.
In this example, the cart is a Fresh Fixture.
Martin Fowler in JUnit New Instance shows why creating a new instance of the test class for each test helps guard against unexpected sharing of mutable instance fields.
It is important to understand: PER_METHOD in itself does not mean that the entire fixture automatically becomes a Fresh Fixture.
A new test-class instance isolates its ordinary instance fields, but tests may still share:
staticfields;- records in the database;
- files;
- servers and containers;
- browser or HTTP sessions;
- other external resources.
Therefore, isolating instances of a test class and isolating the entire test fixture are related, but they are not the same thing.
Shared Fixture
A Shared Fixture is a fixture, or part of one, that is shared by multiple tests.
This approach is particularly useful when preparing a resource is expensive. For example, a test server or container can be started once and used by several tests:
private static TestServer server;
@BeforeAll
static void startServer() {
server = new TestServer();
}
@AfterAll
static void stopServer() {
server.stop();
}Here, the methods annotated with @BeforeAll and @AfterAll are not fixtures themselves. They are lifecycle methods that can create and clean up a Shared Fixture.
However, not every part of a test fixture has to be shared.
For example:
- the test server can be shared by all tests in the class;
- a new user can be created for each test;
- a new order can be created for each test;
- modified test data can remain completely isolated.
In practice, this hybrid approach is very common: an expensive and relatively immutable resource is reused, and mutable state is created separately for each test.
A Shared Fixture can significantly speed up a test suite, but it comes at a cost:
- tests can influence each other;
- tests may become order-dependent;
- parallel execution requires additional care;
- a cleanup failure can affect several tests at once;
- flakiness becomes harder to diagnose.
The safest Shared Fixture is one whose state the tests do not modify. xUnit Test Patterns calls this an Immutable Shared Test Fixture: the shared part of the fixture remains immutable, while mutable data is created separately for each test.
Take particular care when reusing a shared authenticated session, whether browser-based or HTTP-based.
At first glance, this seems like a convenient optimization, but cookies, server-side session state, a shopping cart, the selected organization, access rights, or other user data can silently leak from one test to another.
It makes sense to reuse such a session only if recreating it has a measurable impact on execution time. This is worth checking with measurements: for example, comparing the cost of creating a new session with the total execution time of a set of tests and assessing the real gain from its reuse.
Where possible, prefer reusing a stable, lower-level resource without sharing mutable user state. For example, a shared server is generally safer than a shared user session.
A useful rule of thumb is:
Prefer an isolated fixture by default. Share only the parts whose reuse provides a measurable benefit without creating unwanted dependencies between tests.
And here again it is important not to confuse the concepts:
Fresh Fixture and Shared Fixture describe how fixture state is created and reused across tests, while @BeforeEach and @BeforeAll are lifecycle methods in which that fixture can be prepared.
6. Why you shouldn’t hide the entire setup in @BeforeEach
In Section 3, we looked at Implicit Setup, an approach in which common fixture setup is performed in @BeforeEach.
There is nothing inherently wrong with this approach. Problems arise when almost all scenario setup is moved to @BeforeEach.
For example:
@BeforeEach
void setUp() {
user = createPremiumUser();
product = createDiscountedProduct();
cart = createCart(user);
addProduct(cart, product, 3);
enablePromotion(user);
}The test itself looks almost perfect:
@Test
void shouldApplyPromotion() {
assertEquals(120, cart.getTotal());
}But the test method alone is now difficult to understand. The reader has to jump to setUp() to discover:
- what user was created;
- what product is used;
- how many products have been added to the cart;
- whether the promotion is enabled;
- why the expected result is
120.
The problem here is not the @BeforeEach itself, or even necessarily its size. @BeforeEach hides fixture details that are important to understanding a particular scenario.
In xUnit Test Patterns terminology, excessive Implicit Setup can lead to Obscure Test — a test whose meaning is difficult to understand at first glance.
Conditions that directly determine a test’s behavior and expected result are therefore usually better expressed in the test method itself, or in code closely associated with it, rather than hidden in shared setup.
For example:
@Test
void shouldApplyPromotion() {
User user = createPremiumUser();
Product product = createProductWithPrice(50);
ShoppingCart cart = createCart(user);
addProduct(cart, product, 3);
enablePercentageDiscountFor(user, 20);
assertEquals(120, cart.getTotal());
}The test now makes it clear that three products priced at 50 receive a twenty-percent discount, producing the expected total of 120.
This does not mean that all setup must always be moved into the tests. Lifecycle methods are a good place for common technical fixture setup that is identical across many scenarios and not essential to understanding a particular test.
For example, browser-session creation can remain in @BeforeEach, and the price of the product, its quantity and the conditions for applying the discount can be left inside the test.
Here we have to find a balance:
Too much In-line Setup duplicates code and bloats tests, while too much Implicit Setup hides important scenario conditions.
A useful guideline is simple: express the key conditions and data for a particular scenario directly in the test instead of hiding them in shared setup.
7. Fixtures in API tests
In API tests, the fixture often consists of authorization, test data, request configuration, and preconfigured backend state.
Let’s imagine an order cancellation test. Before executing the request itself, we may already need:
- a configured API client or
RequestSpecification; - an access token or HTTP session;
- a user created for the test;
- an order in the required state;
- an external-service stub or a prepared database record.
Here is a simplified example using REST Assured:
class OrderApiTest {
private RequestSpecification requestSpec;
private String orderId;
@BeforeEach
void setUp() {
requestSpec = new RequestSpecBuilder()
.setBaseUri("https://api.example.test")
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + getAccessToken())
.build();
orderId = createOrder(requestSpec);
}
@AfterEach
void cleanUp() {
deleteOrderIfExists(requestSpec, orderId);
}
@Test
void userCanCancelOrder() {
given()
.spec(requestSpec)
.when()
.delete("/orders/{id}", orderId)
.then()
.statusCode(204);
}
}The example is deliberately simplified and kept in a single test class so that the fixture’s entire lifecycle is visible.
The fixture consists of an authenticated, configured RequestSpecification and an order created in the required state.
The setUp() method annotated with @BeforeEach performs fixture setup, while the cleanUp() method annotated with @AfterEach performs fixture teardown. The lifecycle methods themselves are not fixtures.
What changes when running in parallel
In API tests, the main problem with concurrency is usually not the lack of ThreadLocal, but rather shared mutable state.
For example:
static String currentOrderId;If two tests simultaneously create orders and write their IDs into the same field, one test may end up operating on the other test’s order.
The same problem occurs if parallel tests share:
- one mutable HTTP session;
- the same user or order being modified by multiple tests;
- a shared global
currentAccessToken; - mutable global REST Assured settings such as
RestAssured.sessionIdorRestAssured.requestSpecification.
With standard JUnit PER_METHOD, normal instance fields such as requestSpec and orderId are not shared between test methods: for each of them, JUnit creates a separate instance of the test class.
But PER_METHOD isolates only the test instance state. It does not automatically isolate database entries, users, HTTP server sessions, or the global static state.
Is ThreadLocal needed here?
Usually no.
For example, REST Assured provides SessionFilter, which can store the resulting server session and use it in subsequent requests. If each test owns its own SessionFilter instance and the corresponding server session, parallel execution alone does not require storing the filter in ThreadLocal.
ThreadLocal becomes relevant only when the framework is deliberately built around a thread-bound “current session” — for example, when static helper methods need access to session state associated with the current thread.
This is not an inherent property of API fixtures or a REST Assured requirement. It is an architectural choice that should be considered separately, together with the lifecycle and cleanup of ThreadLocal values.
A compact example with local RequestSpecification, SessionFilter and possible thread-bound session state is given in Appendix D.
The main rule for API tests remains the same:
Isolate the test data, user and stateful session first.
ThreadLocaldoes not replace this isolation and is not needed just because the tests are running in parallel.
8. UI fixtures: Selenium, Selenide and Playwright Java
In UI automation, it is especially clear that a fixture is not necessarily a single object. A UI fixture may include the browser session, application state, and test data required by a particular scenario.
Selenium WebDriver
For Selenium, a UI fixture may include:
- a
WebDriverobject and the browser session it controls; - prepared session state — for example, cookies or authentication state;
- an application page opened in the required state, if it is prepared before the action under test;
- test data that determines the UI state.
A simple JUnit example:
class ProfileTest {
private WebDriver driver;
@BeforeEach
void startBrowser() {
driver = new ChromeDriver();
}
@AfterEach
void stopBrowser() {
driver.quit();
}
@Test
void userCanOpenProfile() {
driver.get("https://example.test/profile");
assertEquals(
"Profile",
driver.findElement(By.id("profile-title")).getText()
);
}
}Here the WebDriver object and the created browser session are part of the test fixture.
startBrowser() and stopBrowser() are lifecycle methods. Creating ChromeDriver performs fixture setup, and driver.quit() performs fixture teardown.
In this example, WebDriver is stored in a field in the test class. This works without additional storage because by default JUnit creates a new instance of the test class for each test method. This mode is called PER_METHOD, and @BeforeEach creates a new WebDriver before each test is executed.
This follows the Selenium recommendations do not share state between tests and create a new WebDriver for each test.
This short example explains how to prepare and clean up a fixture, but does not define the architecture of a full-fledged Selenium framework.
In a real Selenium framework, access to the current WebDriver is often centralized through DriverManager.getDriver() to avoid passing WebDriver through every Page Object and helper class. If the framework needs to support parallel execution, the current WebDriver can be stored in a ThreadLocal<WebDriver> inside DriverManager, allowing each thread to access its own instance.
Lifecycle management and thread-bound storage solve different problems. Lifecycle management creates and closes a separate browser session, while ThreadLocal gives the current thread access to the corresponding WebDriver. ThreadLocal itself does not create a new browser session for each test and does not make WebDriver thread-safe.
Selenium ThreadGuard verifies that a WebDriver is used only from the thread on which it was created. If another thread accesses the driver, ThreadGuard throws an exception. It does not replace ThreadLocal or create separate drivers for different threads.
In a real framework, WebDriver creation and configuration are usually moved to a separate WebDriverFactory, while a separate manager such as DriverManager stores the current driver instance and manages its lifecycle. Compact educational implementations sometimes combine these responsibilities. Complete examples using ThreadLocal<WebDriver>, remove(), ThreadGuard, and the JUnit/TestNG lifecycle are provided in Appendix A and Appendix B.
Selenide
In Selenide, part of this infrastructure is already implemented inside the library. The standard static API open(), $() and $$() work with the WebDriver of the current thread; WebDriverRunner provides access to it.
When using Selenide’s standard static API, there is no need to create your own DriverManager with another ThreadLocal just to store the current browser.
Get Andrii Olieinik’s stories in your inbox
Join Medium for free to get updates from this writer.
By default, Selenide reuses the browser between tests running on the same thread. If each test needs a fresh browser session, JUnit can use BrowserPerTestStrategyExtension:
import com.codeborne.selenide.junit5.BrowserPerTestStrategyExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static com.codeborne.selenide.Condition.text;
import static com.codeborne.selenide.Selenide.$;
import static com.codeborne.selenide.Selenide.open;
@ExtendWith(BrowserPerTestStrategyExtension.class)
class ProfileSelenideTest {
@Test
void userCanOpenProfile() {
open("https://example.test/profile");
$("#profile-title").shouldHave(text("Profile"));
}
}BrowserPerTestStrategyExtension automatically closes the browser after each test.
Again, the responsibilities are separate:
- browser session and its state are parts of the fixture;
- Selenide’s infrastructure provides access to the current thread’s
WebDriver; - The JUnit extension manages the browser session lifecycle.
Selenide uses a global settings class, Configuration, for the browser, baseUrl, timeout, window size, and other parameters. All of its fields are declared static, and changes are immediately visible to every thread. When tests run in parallel, configure these shared settings before the test run and do not modify them inside test methods.
Playwright Java + JUnit
In Playwright Java, the primary browser state isolation boundary between tests is BrowserContext.
One Browser can be reused between several tests, and for each test a separate BrowserContext and Page can be created. The new browser context gets its own cookies, local storage and session storage and therefore isolates the browser state of the test.
Playwright Java provides an experimental JUnit integration via @UsePlaywright:
import com.microsoft.playwright.Page;
import com.microsoft.playwright.junit.UsePlaywright;
import org.junit.jupiter.api.Test;
@UsePlaywright
class ProfilePlaywrightTest {
@Test
void userCanOpenProfile(Page page) {
page.navigate("https://example.test/profile");
}
}In the documentation for this integration, Page, BrowserContext, Browser, Playwright and APIRequestContext are directly called fixtures and can be passed to test methods as parameters.
For example, Page and BrowserContext are created separately for each test, but Browser can be reused between tests.
This is a good example of why the word fixture needs to be read in the context of a specific framework. Here, a fixture is no longer just a general concept of a prepared test state, but also a specific abstraction for the integration of Playwright Java with JUnit.
When using @UsePlaywright, your own PlaywrightManager with ThreadLocal is not needed: the integration takes care of lifecycle management and provisioning of these objects.
When integrating Playwright Java yourself, you also need to account for its threading model: Playwright Java API is not thread-safe. Playwright and objects created through it cannot be arbitrarily used from different threads without additional synchronization.
One of the Playwright Java + TestNG architecture options with ThreadLocal is given in Appendix C. This is just one possible approach: the official Playwright documentation shows integration with TestNG and without ThreadLocal.
The main idea for all three examples is the same:
A UI fixture can include a browser session, its prepared state, and other resources required by the test. Lifecycle methods, the JUnit extension, and built-in library mechanisms help you create such a fixture, provide access to it, and manage its lifecycle.
9. Nuances of fixtures in TestNG
JUnit and TestNG solve similar problems, but use different terminology for the methods that prepare and clean up test state.
In JUnit, methods with @BeforeEach, @AfterEach, @BeforeAll, and @AfterAll are officially called lifecycle methods.
In TestNG, methods annotated with @BeforeMethod, @AfterMethod, @BeforeClass, and other before/after annotations are called configuration methods. The TestNG documentation describes them as methods that execute code before or after a specific stage of test execution.
The difference in terminology does not change the core distinction:
A TestNG configuration method, like a JUnit lifecycle method, is not itself a fixture. It can prepare, modify, or clean up one.
For example, a WebDriver is part of a UI test fixture, while the @BeforeMethod method that creates the driver is a configuration method.
public class ProfileTest {
private WebDriver driver;
@BeforeMethod
public void startBrowser() {
driver = new ChromeDriver();
}
@AfterMethod(alwaysRun = true)
public void stopBrowser() {
if (driver != null) {
driver.quit();
}
}
@Test
public void userCanOpenProfile() {
driver.get("https://example.com/profile");
// test actions and assertions
}
}Here, the fixture includes the browser and its current state. startBrowser() sets up this fixture before the test, while stopBrowser() cleans it up afterward.
Similar JUnit and TestNG annotations
TestNG also provides several additional levels of configuration methods.
TestNG has more levels of configuration methods
TestNG provides the following pairs of before/after configuration annotations:
One resource can be recreated before each test method, another can be used at the class level, and an expensive external resource can be provisioned once for a broader set of tests.
These levels let you define different fixture lifetimes: a resource can be created and cleaned up for each test method, class, group, <test>, or the entire <suite>.
@BeforeTest Is Not the Equivalent of @BeforeEach
There is an important nuance of TestNG terminology here.
In TestNG, the word test is used not only for the Java method with @Test.
In the official TestNG model:
- suite corresponds to
<suite>intestng.xml; - test corresponds to
<test>; - inside
<test>there are one or more test classes (<class>); - inside the classes there are test methods with
@Test.
For example:
<suite name="Regression">
<test name="UI tests">
<classes>
<class name="ProfileTest"/>
<class name="OrdersTest"/>
</classes>
</test>
</suite>That’s why:
@BeforeTest
public void prepareEnvironment() {
// ...
}does not mean “execute before each method with @Test".
@BeforeTest is executed before test methods of classes included in the corresponding <test>.
If setup is required before each test method, use:
@BeforeMethod
public void setUp() {
// ...
}This is especially important for developers and test automation engineers moving between JUnit and TestNG: the name @BeforeTest alone makes it easy to assume the wrong scope.
Group-level fixtures
Another feature of TestNG is configuration methods associated with groups:
@BeforeGroups("database")
public void prepareDatabase() {
// ...
}
@AfterGroups("database")
public void cleanDatabase() {
// ...
}@BeforeGroups is executed before the first test method of the corresponding group, and @AfterGroups is executed after the last one.
This does not mean that TestNG introduces a separate entity called a “group fixture.” The framework simply provides another boundary at which the lifecycle of a Shared Fixture can be managed.
Configuration methods are inherited
TestNG honors configuration methods declared in superclasses. Before methods are executed from top to bottom in the inheritance hierarchy:
BaseTest
↓
UiTest
↓
ProfileTestand after methods are in reverse order.
This makes it possible, for example, to move shared environment setup into a base test class.
But this introduces the same hidden preparation problem that we already discussed for JUnit: if a significant portion of the fixture is created far from the test itself, it becomes more difficult for the reader to understand what state the test depends on.
Therefore, inheritance of configuration methods is technically convenient, but should be used sparingly.
Cleanup and alwaysRun
For teardown methods in TestNG, another important attribute is alwaysRun.
For example:
@AfterMethod(alwaysRun = true)
public void stopBrowser() {
if (driver != null) {
driver.quit();
}
}For after-configuration methods, alwaysRun = true allows the method to run even if earlier methods failed or were skipped.
This is especially important for resource cleanup: a test failure is usually not a reason to leave an open browser, open connection, or other external resource.
What happens during parallel execution?
Parallel execution opens up a much larger topic.
TestNG supports the methods, tests, classes and instances modes. For example, with parallel="methods", test methods can be executed in different threads, and with parallel="classes", methods of the same class remain in the same thread, but different classes can be executed in parallel.
This directly affects fixture design. For each parallel-execution model, you must determine where a resource is created and stored, how long it lives, and whether multiple threads may use it.
We will not discuss this in detail in this article. This concerns test framework architecture and the organization of safe parallel execution rather than test fixtures themselves.
Readers who want to understand the implications of different execution modes after considering fixture scope should consult the official TestNG documentation on parallel execution.
TestNG shows especially clearly that a fixture’s lifetime and a configuration method’s scope are related but distinct concepts. Under parallel execution, the choice of those scopes also affects isolation and thread safety.
10. Why pytest, Cypress, and Playwright JS cause terminological confusion
The confusion with the word “fixture” becomes especially noticeable when moving from Java and JUnit to other testing frameworks.
Across stacks, the same word may denote either the general concept of a test fixture or a specific framework mechanism. Different frameworks also apply the word to different things.
pytest
In pytest, a fixture is not only a general concept but also a framework-specific mechanism. A fixture is declared with @pytest.fixture, and a test receives it through a function argument.
@pytest.fixture
def user():
created_user = create_user()
yield created_user
delete_user(created_user)The test simply requests the required fixture:
def test_user_can_log_in(user):
assert login(user).is_successfulpytest resolves the requested user fixture, invokes the fixture function, and passes the resulting value to the test.
If yield is used, the code before it does the fixture preparation, and the code after it does the cleanup. Fixtures can depend on other fixtures and have different scopes: function, class, module, package, or session.
In pytest documentation and projects, it is therefore entirely normal to encounter phrases such as “the test uses the user fixture” or “let's create a fixture with session scope.”
This is standard pytest terminology, but it should not be mapped directly onto JUnit. JUnit has no equivalent built-in fixture mechanism in which a test requests dependencies through its function arguments.
Cypress
In Cypress, the word fixture has yet another meaning.
Here, a fixture primarily means a predefined set of static data, usually stored in a separate file. By default, these files live in the cypress/fixtures directory and can be loaded with cy.fixture().
For example:
cy.fixture('user.json').then((user) => {
expect(user.role).to.equal('admin');
});The file can contain JSON, text, CSV, images, and other types of data. Such fixtures are often used, for example, for test data or HTTP response stubs.
At the same time, beforeEach() in Cypress is a hook, not a fixture:
beforeEach(() => {
cy.fixture('user.json').as('user');
});The difference is clearly visible here:
beforeEach()determines when to execute the code;cy.fixture()loads fixture data.
A hook and a fixture can therefore be used together in the same test, but they remain distinct concepts. Cypress describes hooks as a mechanism for running setup and teardown code around tests.
Playwright JS
In Playwright JS, the word “fixture” has an even broader framework-specific meaning: the test runner provides a full-fledged fixture mechanism.
For example:
test('user can open profile', async ({ page }) => {
await page.goto('/profile');
});Here, page is a built-in fixture. Playwright creates it and passes it to the test.
Built-in fixtures include, for example:
page;context;browser;request.
Fixtures can also have different scopes. For example, page is created for a single test, while browser is shared by several tests.
Custom fixtures can be declared using test.extend():
export const test = base.extend({
user: async ({ request }, use) => {
const user = await createUser(request);
await use(user);
await deleteUser(request, user);
},
});Here:
- code before
use()performs fixture setup; - the value passed to
use()becomes available to the test; - code after
use()performs fixture teardown.
Playwright also supports hooks as a separate mechanism:
test.beforeEach(async ({ page }) => {
await page.goto('/');
});That’s why Playwright is especially good at showing why you cannot treat every piece of setup code as a fixture:
test.beforeEach()is a hook;page,context,browser, andrequestare built-in fixtures;test.extend()allows you to declare custom fixtures;- fixtures can depend on each other;
- fixtures can have
testorworkerscope; - a fixture can perform its own setup and teardown.
That is, the advice “create your fixtures in beforeEach()” when applied to Playwright mixes two different mechanisms. This does not mean that beforeEach() should not be used: the hook and fixture simply solve different problems.
What Does This Mean for JUnit?
Across these frameworks, the word “fixture” can refer to very different things:
- prepared state required by the test;
- a special dependency that pytest provides to the test;
- static test data from a file, like in Cypress;
- a dependency managed by a test runner with its own lifecycle, as in Playwright.
All of these uses exist and are used in real projects.
Therefore, in this article we will stick to JUnit terminology: @BeforeEach, @AfterEach, @BeforeAll and @AfterAll are lifecycle methods, and a fixture is the state, data, objects or resources that the test needs.
Lifecycle methods can provision, modify, and clean up fixtures, but this does not make them fixtures themselves.
11. A few rules of thumb
If I had to reduce the article to a handful of practical takeaways, these would be the most important:
- A fixture is the state, data, objects, and resources that a test relies on to execute.
- Methods annotated with
@BeforeEach,@AfterEach,@BeforeAll, and@AfterAllare JUnit lifecycle methods. They can prepare and clean up fixtures, but they are not fixtures themselves. - A fixture does not have to be created in a lifecycle method. It can be prepared directly in a test, in a helper method, through an extension, or by another mechanism.
- Prefer an isolated fixture for each test by default. Reuse only those parts for which sharing provides a measurable benefit.
PER_METHODin JUnit gives each test method a separate instance of the test class, so normal instance fields are not shared between tests. But this does not guarantee the isolation of the entire fixture: the database, files, server sessions,staticstate and other external resources can still remain shared.- Do not hide conditions that determine a test’s meaning and expected result inside
@BeforeEach; doing so makes important preconditions invisible and the test harder to read. - TestNG follows the same basic principle: configuration methods control state setup and cleanup, but they are not the fixture itself. When choosing a broader scope, consider how many tests will share a resource.
- When tests run in parallel, don’t start with
ThreadLocal. First identify what state is shared, where it lives, and whether one test can modify state another test depends on. - Make test data unique and cleanup operations idempotent.
Conclusion
A test fixture is a fairly simple concept until we start carrying terminology from one framework into another.
For JUnit, one mental model is especially useful:
A fixture is the state, data, objects, and resources required by a test.
Lifecycle methods determine when the preparation and cleanup code is executed.
The specific mechanisms, and even the meaning of the word “fixture”, vary across frameworks. When designing a fixture, it is therefore useful to ask a few simple questions:
What exactly is prepared for the test? Who uses this state? How long does it live? Can one test change the state of another? Who is responsible for cleanup?
If the answers are clear, working with fixtures in any testing stack becomes much easier.
Technical Appendices
The main article can be read without this section. Here are compact implementations for Selenium, Playwright Java and REST Assured running in parallel.
Appendix A. Selenium + JUnit: ThreadLocal, ThreadGuard, and parallel tests
The main article showed a simple approach in which WebDriver is stored directly in a test-class field. This is often sufficient with JUnit's standard PER_METHOD lifecycle.
Now consider an implementation of such a DriverManager for parallel execution based on ThreadLocal<WebDriver>:
In a real framework, I would separate the creation of the driver and the storage of the current instance. Here they are combined into
DriverManagerto keep the example compact.
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ThreadGuard;
import static java.util.Objects.requireNonNull;
final class DriverManager {
private static final ThreadLocal<WebDriver> DRIVER =
new ThreadLocal<>();
private DriverManager() {
}
static void start() {
if (DRIVER.get() != null) {
throw new IllegalStateException(
"WebDriver is already started in this thread"
);
}
WebDriver driver =
ThreadGuard.protect(new ChromeDriver());
DRIVER.set(driver);
}
static WebDriver getDriver() {
return requireNonNull(
DRIVER.get(),
"WebDriver is not started in this thread"
);
}
static void stop() {
WebDriver driver = DRIVER.get();
try {
if (driver != null) {
driver.quit();
}
} finally {
DRIVER.remove();
}
}
}The test remains quite simple:
class ProfileSeleniumTest {
@BeforeEach
void startBrowser() {
DriverManager.start();
}
@AfterEach
void stopBrowser() {
DriverManager.stop();
}
@Test
void userCanOpenProfile() {
WebDriver driver = DriverManager.getDriver();
driver.get("https://example.test/profile");
assertEquals(
"Profile",
driver.findElement(By.id("profile-title")).getText()
);
}
}The code implements the model described in the main section: lifecycle methods create and close the browser session, ThreadLocal exposes the driver belonging to the current thread, and ThreadGuard detects access from another thread.
During cleanup, do not stop at driver.quit(). After closing the browser session, call ThreadLocal.remove(), especially when the test runner uses a thread pool and reuses worker threads.
Such a DriverManager is not necessary merely because JUnit can run tests in parallel. It becomes useful when the framework deliberately exposes the "current driver" through the static DriverManager facade.
Appendix B. Selenium + TestNG: ThreadLocal, ThreadGuard, and parallel=”methods"
The same DriverManager pattern is easy to demonstrate with TestNG.
For example, TestNG allows you to run test methods in parallel:
<suite name="UI suite"
parallel="methods"
thread-count="4">
...
</suite>In this mode, different test methods may execute on different threads. If the current driver is exposed through a shared static API, each thread must receive its own WebDriver.
We use the same DriverManager from Appendix A.
Only the lifecycle wiring changes:
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.By;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
public class ProfileSeleniumTest {
@BeforeMethod
public void startBrowser() {
DriverManager.start();
}
@AfterMethod(alwaysRun = true)
public void stopBrowser() {
DriverManager.stop();
}
@Test
public void userCanOpenProfile() {
WebDriver driver = DriverManager.getDriver();
driver.get("https://example.test/profile");
assertEquals(
driver.findElement(By.id("profile-title")).getText(),
"Profile"
);
}
}@BeforeMethod and @AfterMethod manage the browser portion of the fixture, while the ThreadLocal inside DriverManager associates a specific WebDriver with the thread executing the test.
For cleanup, use the already discussed @AfterMethod(alwaysRun = true).
As in the JUnit version, after quit(), remove() is executed. This prevents the value from remaining in ThreadLocal if TestNG later uses the same worker thread for another test.
The roles of DriverManager, ThreadLocal, and ThreadGuard remain the same as in Appendix A. Only lifecycle management changes here: TestNG configuration methods are used instead of JUnit methods.
The differences between parallel="methods", parallel="classes", parallel="tests", and other TestNG variants have a significant impact on the resource lifecycle. These nuances are the subject of a separate article.
Appendix C. Playwright Java + TestNG: ThreadLocal, BrowserContext, and parallel tests
Now apply the model described in the Playwright Java section to parallel TestNG execution.
Given the thread-safety limitation discussed above, a simple parallel TestNG setup can follow this rule:
PlaywrightandBrowserare confined to the same thread and reused across multiple tests, while a newBrowserContextandPageare created for each test method.
To avoid creating separate ThreadLocal<Playwright>, ThreadLocal<Browser>, ThreadLocal<BrowserContext> and ThreadLocal<Page>, related resources can be stored in one object inside DriverManager.
Playwright objects form a graph of related resources:
Browseris created throughPlaywright,BrowserContextthroughBrowser, andPagethroughBrowserContext. A single holder object helps keep these related resources together and close them in the correct order.
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import static java.util.Objects.requireNonNull;
final class DriverManager {
private static final ThreadLocal<PlaywrightResources> CURRENT =
new ThreadLocal<>();
private DriverManager() {
}
static void startBrowser() {
if (CURRENT.get() != null) {
throw new IllegalStateException(
"Playwright is already started in this thread"
);
}
Playwright playwright = Playwright.create();
try {
Browser browser = playwright.chromium().launch();
CURRENT.set(
new PlaywrightResources(playwright, browser)
);
} catch (RuntimeException error) {
playwright.close();
throw error;
}
}
static void createContext() {
PlaywrightResources resources = current();
if (resources.context != null) {
throw new IllegalStateException(
"BrowserContext is already created for this test"
);
}
resources.context = resources.browser.newContext();
resources.page = resources.context.newPage();
}
static Page getPage() {
return requireNonNull(
current().page,
"Page is not created for this test"
);
}
static void closeContext() {
PlaywrightResources resources = CURRENT.get();
if (resources == null) {
return;
}
try {
if (resources.context != null) {
resources.context.close();
}
} finally {
resources.context = null;
resources.page = null;
}
}
static void stopBrowser() {
PlaywrightResources resources = CURRENT.get();
if (resources == null) {
return;
}
try {
closeContext();
} finally {
try {
resources.browser.close();
} finally {
try {
resources.playwright.close();
} finally {
CURRENT.remove();
}
}
}
}
private static PlaywrightResources current() {
return requireNonNull(
CURRENT.get(),
"Playwright is not started in this thread"
);
}
private static final class PlaywrightResources {
private final Playwright playwright;
private final Browser browser;
private BrowserContext context;
private Page page;
private PlaywrightResources(
Playwright playwright,
Browser browser
) {
this.playwright = playwright;
this.browser = browser;
}
}
}Notice that ThreadLocal contains a single PlaywrightResources object, but its components have different lifecycles.
Playwright and Browser are created once:
@BeforeClass
public void startBrowser() {
DriverManager.startBrowser();
}And BrowserContext and Page are recreated before each test:
@BeforeMethod
public void createContext() {
DriverManager.createContext();
}
@AfterMethod(alwaysRun = true)
public void closeContext() {
DriverManager.closeContext();
}After all test methods in the class have finished, the longer-lived resources are closed:
@AfterClass(alwaysRun = true)
public void stopBrowser() {
DriverManager.stopBrowser();
}The full test looks like this:
import com.microsoft.playwright.Page;
import org.testng.annotations.AfterClass;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class ProfilePlaywrightTest {
@BeforeClass
public void startBrowser() {
DriverManager.startBrowser();
}
@BeforeMethod
public void createContext() {
DriverManager.createContext();
}
@AfterMethod(alwaysRun = true)
public void closeContext() {
DriverManager.closeContext();
}
@AfterClass(alwaysRun = true)
public void stopBrowser() {
DriverManager.stopBrowser();
}
@Test
public void userCanOpenProfile() {
Page page = DriverManager.getPage();
page.navigate("https://example.test/profile");
}
}For this design, it is convenient to use class-level parallelism:
<suite name="UI suite"
parallel="classes"
thread-count="4">
...
</suite>With parallel="classes", TestNG executes methods of one class in one thread, and different classes can be executed in parallel.
This fits well with the chosen lifecycle: Playwright and Browser are created in @BeforeClass, remain bound to the thread of that class, and each test method receives a new BrowserContext and Page.
As a result, Browser does not have to be launched again for every test method, while each test's mutable browser state remains isolated in a separate BrowserContext.
Here you can clearly see that different parts of a fixture can have different scopes:
That is why there is no need to recreate Playwright or relaunch the Browser just to get fresh browser state for the next test.
This example uses one ThreadLocal rather than four independent ones. This allows related Playwright objects to be stored as one coherent group and their lifecycle to be managed centrally in DriverManager.
If parallel="methods" is required, reusing Playwright and Browser becomes more difficult: methods of the same class may execute on different threads, so the lifecycle and guaranteed cleanup of thread-bound resources must be designed explicitly.
Appendix D. API fixtures: REST Assured, sessions, and parallel tests
In the main part, we have already seen that for API tests, ThreadLocal is usually not the first thing you need when introducing parallel execution.
It is more important to isolate each test’s data and state first: its user, order, HTTP session, and other resources.
Local RequestSpecification
REST Assured allows you to set global settings, such as RestAssured.requestSpecification or RestAssured.sessionId.
Problems arise when parallel tests modify this global state.
It is usually safer to create a separate RequestSpecification for each test or scenario:
private static final String BASE_URI =
"https://api.example.test";
static RequestSpecification newRequestSpecification() {
return new RequestSpecBuilder()
.setBaseUri(BASE_URI)
.setContentType(ContentType.JSON)
.build();
}In JUnit, such an object can simply be stored in an ordinary test-instance field:
private RequestSpecification requestSpec;
@BeforeEach
void setUp() {
requestSpec = newRequestSpecification();
}With the standard PER_METHOD, a separate ThreadLocal<RequestSpecification> is not needed for this.
Stateful HTTP session
For an API in which the server maintains user-session state, we can use the SessionFilter mentioned earlier:
SessionFilter session = new SessionFilter();
given()
.auth().form("john", "secret")
.filter(session)
.when()
.get("/profile")
.then()
.statusCode(200);
given()
.filter(session)
.when()
.get("/orders")
.then()
.statusCode(200);In the first request, SessionFilter captures the session ID returned during form authentication. In the second request, the same filter instance automatically reuses the stored session, so there is no need to call .auth().form(...) again.
Such a SessionFilter is a mutable object: different logical user sessions should not accidentally share the same instance.
In an ordinary JUnit test, it can again be stored in a test-instance field.
ThreadLocal becomes useful when the framework architecture explicitly provides a “current API session” that static helper methods need to access:
ApiSessionManager below only stores the current session and clears the reference to it. It does not create users, orders, or RequestSpecification.
final class ApiSessionManager {
private static final ThreadLocal<SessionFilter> SESSION =
ThreadLocal.withInitial(SessionFilter::new);
private ApiSessionManager() {
}
static SessionFilter current() {
return SESSION.get();
}
static void clear() {
SESSION.remove();
}
}Then the request can use the current session:
given()
.filter(ApiSessionManager.current())
.when()
.get("/profile")
.then()
.statusCode(200);After completing a test or logical user session, ThreadLocal needs to be cleared:
@AfterEach
void clearSession() {
ApiSessionManager.clear();
}If session state is maintained through several cookies, CookieFilter can perform a similar role.
An ordinary immutable access token rarely needs a separate ThreadLocal<String>. Store the token in a test-instance field or pass it explicitly when creating a RequestSpecification.
Finally, ThreadLocal does not solve the problem of shared test data. Two parallel tests can still conflict if they use the same user or modify the same order.
Therefore, for API tests, the basic rule remains the same:
Isolate test data and user-session state first. Use
ThreadLocalonly when the architecture truly needs access to state bound to the current thread.
Use unique test-data identifiers and make cleanup idempotent so that one test cannot break another and repeated cleanup does not cause an additional failure.










