Every serious test suite has at least one thing that changes between environments: the base URL, the browser, the API key, the timeout. Hardcoding any of those into Java forces you to edit source code to change the target environment — which means recompiling, and means developers can accidentally check in production credentials. @Parameters and testng.xml solve this cleanly: environment-specific values live in the XML file (or come from CLI system properties), and the Java test code reads them at runtime with no recompilation required. This lesson covers the full parameter system, the resolution order that decides where a value actually comes from, and how to combine XML parameters with system properties for maximum flexibility.
The basic @Parameters pattern
Declare parameters in testng.xml:
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Parameterised Suite" verbose="1">
<test name="Staging Tests">
<parameter name="baseUrl" value="https://staging.myapp.com"/>
<parameter name="browser" value="chrome"/>
<parameter name="timeout" value="10"/>
<classes>
<class name="com.mycompany.tests.tests.LoginTest"/>
<class name="com.mycompany.tests.tests.ProductTest"/>
</classes>
</test>
<test name="Production Tests">
<parameter name="baseUrl" value="https://myapp.com"/>
<parameter name="browser" value="chrome"/>
<parameter name="timeout" value="5"/>
<classes>
<class name="com.mycompany.tests.tests.LoginTest"/>
<class name="com.mycompany.tests.tests.ProductTest"/>
</classes>
</test>
</suite>Inject them into test methods or configuration methods with @Parameters:
package com.mycompany.tests.base;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.testng.annotations.*;
public class BaseTest {
protected WebDriver driver;
protected String baseUrl;
protected int timeout;
@BeforeMethod
@Parameters({"baseUrl", "browser", "timeout"})
public void setup(
String baseUrl,
String browser,
@Optional("10") String timeout) {
this.baseUrl = baseUrl;
this.timeout = Integer.parseInt(timeout);
WebDriverManager.chromedriver().setup();
switch (browser.toLowerCase()) {
case "firefox" -> {
WebDriverManager.firefoxdriver().setup();
driver = new FirefoxDriver();
}
case "edge" -> {
WebDriverManager.edgedriver().setup();
driver = new org.openqa.selenium.edge.EdgeDriver();
}
default -> driver = new ChromeDriver();
}
driver.manage().window().maximize();
driver.manage().timeouts().implicitlyWait(
java.time.Duration.ofSeconds(this.timeout)
);
driver.get(this.baseUrl);
}
@AfterMethod(alwaysRun = true)
public void teardown() {
if (driver != null) driver.quit();
}
}The same LoginTest.java and ProductTest.java now run against staging with a 10-second timeout and production with a 5-second timeout — zero Java changes.
@Optional — default values
@Optional("chrome") provides a default when no matching <parameter> exists in the XML. This matters in two situations:
- Running a single test class from IntelliJ (no
testng.xmlis used, so no parameters are injected) - A
<test>block that inherits from the<suite>level but doesn't override every parameter
@BeforeMethod
@Parameters({"browser", "baseUrl"})
public void setup(
@Optional("chrome") String browser,
@Optional("http://localhost:3000") String baseUrl) {
// Works even when called outside a suite
}Without @Optional, TestNG throws org.testng.TestNGException: Parameter 'browser' is required by @Parameters on method setup but has not been defined when the parameter is missing. Add @Optional to every parameter that might be absent when running outside a complete suite.
Parameters at different scope levels
Parameters can be declared at the <suite> level (available everywhere) or at the <test> level (overrides the suite value for that block):
<suite name="Multi-env">
<!-- Default: all tests use chrome unless overridden -->
<parameter name="browser" value="chrome"/>
<test name="Staging">
<!-- Override base URL for staging -->
<parameter name="baseUrl" value="https://staging.myapp.com"/>
<packages><package name="com.mycompany.tests.tests"/></packages>
</test>
<test name="Production">
<!-- Override both for production -->
<parameter name="baseUrl" value="https://myapp.com"/>
<parameter name="browser" value="edge"/>
<packages><package name="com.mycompany.tests.tests"/></packages>
</test>
</suite>The staging <test> block inherits browser=chrome from the suite and overrides baseUrl. The production block overrides both.
Parameters vs DataProviders
These two mechanisms solve different problems and are often confused:
@Parameters — static values from XML, same for every test in a <test> block. Right choice for:
- Environment config: base URL, API host, database connection string
- Browser choice: chrome, firefox, edge
- Suite-level timeouts and settings
@DataProvider — dynamic data returned from a Java method, different per test invocation. Right choice for:
- Login scenarios: 10 different username/password pairs
- Product data: 50 different SKUs to verify
- Input validation: 20 edge-case strings
Never use @Parameters for test data (it can only inject one value at a time per parameter name). Never use @DataProvider for environment config (it runs at the method level, not the suite level). Chapter 3 covers @DataProvider in depth.
System properties as an override layer
System properties let you override parameters without editing XML — essential for CI pipelines:
mvn test -DbaseUrl=https://uat.myapp.com -Dbrowser=firefoxCombined with @Parameters, you can read both:
@BeforeMethod
@Parameters({"baseUrl", "browser"})
public void setup(
@Optional("http://localhost:3000") String xmlBaseUrl,
@Optional("chrome") String xmlBrowser) {
// System property wins; XML parameter is the fallback
String baseUrl = System.getProperty("baseUrl", xmlBaseUrl);
String browser = System.getProperty("browser", xmlBrowser);
System.out.println("Running against: " + baseUrl + " on " + browser);
// ... driver setup
}This is the pattern used in most professional frameworks: testng.xml carries the default environment, and the CI pipeline can override any parameter with -D flags without anyone editing files.
Parameter resolution order
A complete parameterised test
package com.mycompany.tests.tests;
import com.mycompany.tests.base.BaseTest;
import org.openqa.selenium.By;
import org.testng.Assert;
import org.testng.annotations.Parameters;
import org.testng.annotations.Test;
public class LoginTest extends BaseTest {
// baseUrl and driver are injected by BaseTest.setup()
@Test(groups = {"smoke", "regression"},
description = "Valid credentials land on the inventory page")
public void validLoginSucceeds() {
driver.findElement(By.id("user-name")).sendKeys("standard_user");
driver.findElement(By.id("password")).sendKeys("secret_sauce");
driver.findElement(By.id("login-button")).click();
Assert.assertTrue(driver.getCurrentUrl().contains("/inventory.html"),
"Expected inventory URL after login");
}
@Test(groups = {"regression"},
description = "Locked user sees the locked-out error message")
public void lockedUserCannotLogin() {
driver.findElement(By.id("user-name")).sendKeys("locked_out_user");
driver.findElement(By.id("password")).sendKeys("secret_sauce");
driver.findElement(By.id("login-button")).click();
String error = driver.findElement(By.cssSelector("[data-test='error']")).getText();
Assert.assertTrue(error.contains("locked out"), "Expected locked-out message");
}
}LoginTest has no hardcoded URL or browser name. It reads whatever BaseTest.setup() was given from the XML (or the CLI override). Run it against staging, production, or localhost without touching LoginTest.java.
⚠️ Common mistakes
- Forgetting
@Optionalon parameters injected by@BeforeMethod. When a developer runsLoginTestfrom IntelliJ by right-clicking the class — not viatestng.xml— TestNG finds no parameters to inject. Without@Optional, it throwsParameter 'browser' is required. The fix is one annotation:@Optional("chrome"). Add it to every parameter that may be absent. - Using
@Parametersfor test data. A@Parameters({"username", "password"})on a@Testmethod means only one username/password pair can be injected from the XML. For multiple scenarios, that's@DataProvider. Trying to do data-driven testing through parameters leads to copy-pasted XML and one test class per scenario. - Hardcoding a URL in even one place. One hardcoded
"https://production.myapp.com"in a base class means every developer who runs the suite locally accidentally hits production. Make it a rule: every URL, every environment-specific value, is a parameter or a system property.
🎯 Practice task
Run the same tests against two environments. 25–35 minutes.
- Create a
testng.xmlwith two<test>blocks —StagingandProduction(orlocalhostandstagingif you don't have a production URL). Each block should have<parameter name="baseUrl" value="..."/>and<parameter name="browser" value="chrome"/>. - Update
BaseTest.@BeforeMethodto use@Parameters({"baseUrl", "browser"})and add@Optionaldefaults. Print the active URL and browser to the console so you can see which block is running. - Run the suite. Confirm in the console that the staging block runs first against its URL and the production block runs second against its URL.
- Test CLI override. Run
mvn test -DbaseUrl=https://example.com. Confirm the console printsexample.comfor both blocks — the CLI flag overrides the XML. - Test IntelliJ direct run. Right-click
LoginTestand run it directly (no XML). Confirm@Optionaldefaults are used and the test doesn't throwTestNGException. - Stretch — three browsers. Add three
<test>blocks withbrowser=chrome,browser=firefox,browser=edge. Setparallel="tests" thread-count="3"at the suite level. Run — all three browsers should launch and run simultaneously.
Next chapter: data-driven testing. @DataProvider turns one test method into dozens of test invocations, each named and reported separately, pulling data from Java, CSV, Excel, and JSON.