Most TestNG write-ups open with a diagram of nine annotations and expect you to memorize it before you've run a single test. This guide goes the other way. You write a script that does one thing, watch it pass, then take away exactly one convenience and rebuild it properly. By the fourth pass you'll have a small framework, and you'll understand every line of it, because you're the one who put it there.
setup
Two things need to be installed before any of this runs: the Java JDK and Maven.
- Java JDK — download, plus a Windows install walkthrough if you need one.
- Maven — download, plus a Windows install walkthrough.
Confirm both from the command line before you touch a test file:
java -version mvn -version
If either command prints a version number, you're set. If not, fix your PATH first — a broken toolchain is the most common reason a "simple" first test won't run, and it has nothing to do with your test code.
execution order
TestNG runs your setup and teardown code in a strict, scoped order: a suite contains tests, a test contains classes, a class contains methods. Nine annotations mark those boundaries, and they fire in this sequence:
@BeforeSuiteruns once, before anything else in the suite.@BeforeTestruns before any method in the classes under this<test>tag.@BeforeClassruns before the first test method in the current class.@BeforeMethodruns before each individual test method.@Testis the method itself.@AfterMethodruns after each test method.@AfterClassruns once every test method in the class has finished.@AfterTestruns once every class under the<test>tag has finished.@AfterSuiteruns once, after the whole suite finishes.
These annotations are also inherited. Put @BeforeClass on a superclass and every subclass honors it without redeclaring anything — that's exactly how the base class in step 4 below hands a working WebDriver to every test that extends it.
three more annotations
@DataProviderfeeds one@Testmethod multiple rows of input, turning a single method into many test cases.@Listenerswires a custom listener (a screenshot on failure, a custom report) into TestNG's own lifecycle, by annotating the class that needs it.@Parametersreads values straight fromtestng.xmlinto a test method's arguments.
Parameters can come from three places, and it's worth knowing which one you're looking at when you read a report: values declared in testng.xml, values generated by a @DataProvider, and whatever value actually landed in the test report next to a given result.
Never declare test data as a class-level variable. It reads fine at ten tests. At a hundred, spread across a dozen packages, nobody can tell which method owns which value, and changing one input means grepping the whole codebase to find what else depends on it. Isolate it from day one.
step 1 — the rough draft
com.guide.beginners.testng.theinternet.individualtestclass.Step1
Before designing anything, write a test that achieves one simple goal and prove the interaction works end to end. This step is rough on purpose. It exists to confirm the browser flow is correct before you spend a minute on structure.
step 2 — pulling data out
com.guide.beginners.testng.theinternet.individualtestclass.Step2
Data and page objects separate from the test itself, though everything is still declared private at the class level here. You're not fully isolating it yet, but you've started chaining TestNG annotations to control setup order instead of doing it all inline.
step 3 — isolating data for real
com.guide.beginners.testng.theinternet.individualtestclass.Step3
Test data moves out of the class entirely and into src/test/resources/testngsuite/seleniumsuite.xml, passed in through TestNG's own parameter system. This is where @Optional earns its keep: it marks a parameter as skippable and gives it a default (or null) when the suite file doesn't supply one.
step 4 — a base class worth inheriting
com.guide.beginners.testng.theinternet.frameworktestng.base
Driver setup and teardown move to a base class: @BeforeClass and @AfterClass own the WebDriver's lifecycle, and @BeforeMethod owns per-test setup: launching the URL, resetting whatever state needs resetting. Each test class becomes a scenario with one or more @Test cases; it just extends this base and inherits the plumbing for free.
running in parallel
TestNG can spread your tests across threads five different ways, and each one solves a different problem:
- suites puts one thread per suite file, which helps when you're running several
.xmlfiles at once. - methods gives every test method its own thread. Dependent methods still run in the order you declared; they just each get a thread.
- tests: everything inside one
<test>tag shares a thread, but separate<test>tags run in parallel. Group classes that aren't thread-safe under the same tag and you keep them safe without losing parallelism elsewhere. - classes hands each class its own thread; methods inside it share that thread.
- instances: two methods on the same instance share a thread, but two methods on different instances of the same class don't.
<suite name="My suite" parallel="methods" thread-count="5">
For suite-file-level parallelism specifically, the thread pool size is a command-line flag rather than an XML attribute:
java org.testng.TestNG -suitethreadpoolsize 3 testng1.xml testng2.xml testng3.xml
One detail worth flagging in your own docs: @Test's timeOut attribute works the same way in both parallel and non-parallel runs, so it's one less thing to re-check when you change the parallel mode above.
running from the command line
mvn clean test -DsuiteXmlFiles=CrossBrowserParallelSuite.xml
Swap the file name for anything under src/test/resources/testngsuite/ and Maven's Surefire plugin picks it up — that property is already wired into this project's pom.xml.
selenium grid
Local runs are fine until you need more than one browser, or you want the suite running somewhere that isn't your laptop. Grid solves that: a hub takes your test's requests and hands them to registered nodes, which is where the browsers actually launch. Everything below runs from /src/test/resources/binaries.
Standard configuration, no config file:
java -jar selenium-server-standalone.jar -role hub
java -jar selenium-server-standalone.jar -role webdriver -hub http://192.168.0.5:4444/grid/register/
Custom configuration, with a config file per role:
java -jar selenium-server-standalone.jar -role hub --hubConfig hubconfig.json
java -Dwebdriver.chrome.driver="chromedriver.exe" \ -Dwebdriver.ie.driver="IEDriverServer.exe" \ -Dwebdriver.gecko.driver="geckodriver.exe" \ -jar selenium-server-standalone.jar -role node -nodeConfig nodeconfig.json
That's the standalone-jar version of Grid. This project pins selenium-server-standalone-3.141.59.jar, and it still runs fine for a couple of nodes on one machine. If you're setting Grid up fresh today, look at Selenium Grid 4 instead. It keeps the hub/node topology for real distributed setups but adds a single-process "standalone" mode for exactly this one-machine case, shipped as one jar or a Docker Compose file instead of separate downloads — the more natural next step once you outgrow this setup.
None of this is complicated on its own. What makes it click is the order: prove the interaction works, then separate the data, then isolate the data for real, then extract the plumbing into something reusable. Skip straight to step 4 and you'll inherit a framework you can't explain. Climb it one rung at a time and you'll have built one instead.